From 9d36b941853c74bcfcda73736b848e635d4c8ec4 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 15:27:54 +0900 Subject: [PATCH 01/30] Upgrade Fedify to 2.2.0 --- CHANGES.md | 2 + package.json | 14 +++--- pnpm-lock.yaml | 118 ++++++++++++++++++++++++------------------------- 3 files changed, 68 insertions(+), 66 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index fc695d36..7be18f64 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -16,6 +16,8 @@ To be released. post. If the quoted post is unavailable, the fallback link remains visible so the quoted URL is not lost. + - Upgraded Fedify to 2.2.0. + Version 0.8.1 ------------- diff --git a/package.json b/package.json index f8db4ad4..c9ab8eea 100644 --- a/package.json +++ b/package.json @@ -29,14 +29,14 @@ }, "dependencies": { "@aws-sdk/credential-providers": "^3.1037.0", - "@fedify/debugger": "^2.1.10", - "@fedify/fedify": "^2.1.10", - "@fedify/hono": "^2.1.10", + "@fedify/debugger": "^2.2.0", + "@fedify/fedify": "^2.2.0", + "@fedify/hono": "^2.2.0", "@fedify/markdown-it-hashtag": "~0.3.0", "@fedify/markdown-it-mention": "~0.3.0", - "@fedify/postgres": "^2.1.10", - "@fedify/vocab": "^2.1.10", - "@fedify/webfinger": "^2.1.10", + "@fedify/postgres": "^2.2.0", + "@fedify/vocab": "^2.2.0", + "@fedify/webfinger": "^2.2.0", "@hexagon/base64": "^2.0.4", "@hono/node-server": "^1.19.13", "@hono/zod-validator": "^0.7.6", @@ -82,7 +82,7 @@ }, "devDependencies": { "@dotenvx/dotenvx": "^1.52.0", - "@fedify/lint": "^2.1.10", + "@fedify/lint": "^2.2.0", "@reporters/github": "^1.13.1", "@types/fluent-ffmpeg": "^2.1.28", "@types/markdown-it": "^14.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9c5d8b9..550d48fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,14 +12,14 @@ importers: specifier: ^3.1037.0 version: 3.1037.0 '@fedify/debugger': - specifier: ^2.1.10 - version: 2.1.10(@fedify/fedify@2.1.10) + specifier: ^2.2.0 + version: 2.2.0(@fedify/fedify@2.2.0) '@fedify/fedify': - specifier: ^2.1.10 - version: 2.1.10 + specifier: ^2.2.0 + version: 2.2.0 '@fedify/hono': - specifier: ^2.1.10 - version: 2.1.10(@fedify/fedify@2.1.10)(hono@4.12.14) + specifier: ^2.2.0 + version: 2.2.0(@fedify/fedify@2.2.0)(hono@4.12.14) '@fedify/markdown-it-hashtag': specifier: ~0.3.0 version: 0.3.0 @@ -27,14 +27,14 @@ importers: specifier: ~0.3.0 version: 0.3.0 '@fedify/postgres': - specifier: ^2.1.10 - version: 2.1.10(@fedify/fedify@2.1.10)(postgres@3.4.8) + specifier: ^2.2.0 + version: 2.2.0(@fedify/fedify@2.2.0)(postgres@3.4.8) '@fedify/vocab': - specifier: ^2.1.10 - version: 2.1.10 + specifier: ^2.2.0 + version: 2.2.0 '@fedify/webfinger': - specifier: ^2.1.10 - version: 2.1.10 + specifier: ^2.2.0 + version: 2.2.0 '@hexagon/base64': specifier: ^2.0.4 version: 2.0.4 @@ -166,8 +166,8 @@ importers: specifier: ^1.52.0 version: 1.52.0 '@fedify/lint': - specifier: ^2.1.10 - version: 2.1.10(@fedify/fedify@2.1.10)(eslint@10.2.1)(typescript@6.0.3) + specifier: ^2.2.0 + version: 2.2.0(@fedify/fedify@2.2.0)(eslint@10.2.1)(typescript@6.0.3) '@reporters/github': specifier: ^1.13.1 version: 1.13.1 @@ -1379,25 +1379,25 @@ packages: peerDependencies: '@opentelemetry/api': ^1.9.0 - '@fedify/debugger@2.1.10': - resolution: {integrity: sha512-YyHAPO790umTdoCrZuDNheXYJ8sJ/AnWypXozzfU4c67rGwgbnz4JzC3zxmKvOel8KyzyxJY1LHgkk+fXKD1ew==} + '@fedify/debugger@2.2.0': + resolution: {integrity: sha512-tP2JFe+Iy36UBJESh1vD3Vc55jEv1Q8xv8zmPDcKfnVsOLxLgGSbKlTRG00ZUi2N/XQuQpfBMf1K46wH3BDEZg==} peerDependencies: - '@fedify/fedify': ^2.1.10 + '@fedify/fedify': ^2.2.0 - '@fedify/fedify@2.1.10': - resolution: {integrity: sha512-tjJ+wOnjqLGA6wA6BJnNrckKuFSk0X4wofYEA4bWVdlx844VOnT7BDwlEQ/D0SACYijQgp/h5annBOWIG/HFsQ==} + '@fedify/fedify@2.2.0': + resolution: {integrity: sha512-pJ9tid3uvao3yD+2m1gxZHnM4XpQLCFQ3mvJO6GAtBTLlE3PkdWttCTdXF7U5QQlh8NeT6HMe2MLJa2F3RzgQg==} engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} - '@fedify/hono@2.1.10': - resolution: {integrity: sha512-izGJF2KlgjtRBlGViDkYd08iq+Yg9U6Rh6/891zOQkw8WM3bVpA3KYlqNK3pRsbWwfFjHA/hlqGbYUUZjrpafQ==} + '@fedify/hono@2.2.0': + resolution: {integrity: sha512-YIEhBTNinkrutdLTvbRe0ljvEToRo4at0KlUb2Bs7wJdGBe1k+4TUJdGHdNkou4025HqhlZR19GH+ata0Tm+rw==} peerDependencies: - '@fedify/fedify': ^2.1.10 + '@fedify/fedify': ^2.2.0 hono: ^4.0.0 - '@fedify/lint@2.1.10': - resolution: {integrity: sha512-hRiVC2ynUD9tfHUpSvfQNbuu6t/27N2CuZbgkEhnySyI7q4IUwEpOzijeADA+zGUvpjUSw9/TRDfynXKiuqdOw==} + '@fedify/lint@2.2.0': + resolution: {integrity: sha512-LE392IUqMjX/aDmano43mgq3L52FkNWX1Q0l+jE3ceElHRJ4mynH26fy0BbbWShTmfDlul0meoFE2h64GPutfg==} peerDependencies: - '@fedify/fedify': ^2.1.10 + '@fedify/fedify': ^2.2.0 eslint: '>=9.0.0' peerDependenciesMeta: eslint: @@ -1409,26 +1409,26 @@ packages: '@fedify/markdown-it-mention@0.3.0': resolution: {integrity: sha512-4uVELsxh9AW0hzC6BVFqN59YWj6M/p6AZpwb/CQDjvcinfHyQp2yYtGDV4fBgsE5WC/ukpvU1pBNH20ts8TK4w==} - '@fedify/postgres@2.1.10': - resolution: {integrity: sha512-QxvIXD4dloyp6MUVN7GiliN4otS2haRs257xn8whG1aGhJo52oR91e0kgqM28LwAlwd9Zrf9gPN5prnvWLEz2w==} + '@fedify/postgres@2.2.0': + resolution: {integrity: sha512-WB4m+XvQMBLyMNL7o8H/KX0MwkRM3fpd1xD0zDxiziCwJYRDss7E5TUaBnBF/uqzEq5H9cLsfV2Jxls60MNzfA==} peerDependencies: - '@fedify/fedify': ^2.1.10 + '@fedify/fedify': ^2.2.0 postgres: ^3.4.7 - '@fedify/vocab-runtime@2.1.10': - resolution: {integrity: sha512-R8+oqw2tA1cI1I6aeoIkef97tTR1Co1UrdaAk764vQm5dYscKYVZk7ENbMHu3B60Cc3gyLiJGZcL9stuFpDtnw==} + '@fedify/vocab-runtime@2.2.0': + resolution: {integrity: sha512-yHUBcJZ4HPQzexqtI2SZBBinb1DOpJ5w6gnrnSMOKnHhnySEdyEv9BX42jPyf1Y84Fer3fLOILPA7e0q9U4NbQ==} engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} - '@fedify/vocab-tools@2.1.10': - resolution: {integrity: sha512-MMhh2a/mzdAHT1vJiJbt7LwZPVwrXFQmuPv1wyOdGyL9fLtkjRh7IwLRTj25Lq61g2xjZDhJnArwbK6MFBNviQ==} + '@fedify/vocab-tools@2.2.0': + resolution: {integrity: sha512-afIUibecPo+AiCuEJ54B/6LBhb7lEXMSeelY7hTeIET0uuJyKiwMMwCIelGzpLYP9TxjfketHrYtKajMtALC6A==} engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} - '@fedify/vocab@2.1.10': - resolution: {integrity: sha512-YhFBPM1/JYLvzuQzggCJnbrrMLFU0Um55CJSJX0w70Nbxh0MQgfUe1GV2XlXmHepVuf6XQJaPK2YJrCpKb0M9A==} + '@fedify/vocab@2.2.0': + resolution: {integrity: sha512-b0g0EAz8DoW8L+Y6BV6qywHPJpkzKA9iy4mwFMQvvVUVVj8DF2Kv9jaFm7AUbvCdjRpOjru5uGRBPxWY4bkXGQ==} engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} - '@fedify/webfinger@2.1.10': - resolution: {integrity: sha512-Yfbh+PWQBu5GsKMmoEaWxJ19T7/qagm1uutP21dV/5kbVTsMcXUie+FmWbhb8AwDcaviMQr0jVKAwMh2UvCgDA==} + '@fedify/webfinger@2.2.0': + resolution: {integrity: sha512-F5WCXTaCcdZ6zN4UbgwjOlDCWRL8SwHz+v3ge0uggze8OxihDy2XQEIMynOAy6mXtVauLiCHuNLPlnhIniTjBA==} engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} '@fxts/core@1.26.0': @@ -6104,14 +6104,14 @@ snapshots: '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.713.0 tslib: 2.8.1 optional: true '@aws-crypto/crc32c@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.713.0 tslib: 2.8.1 optional: true @@ -6119,7 +6119,7 @@ snapshots: dependencies: '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.713.0 '@aws-sdk/util-locate-window': 3.965.5 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -7539,9 +7539,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@fedify/debugger@2.1.10(@fedify/fedify@2.1.10)': + '@fedify/debugger@2.2.0(@fedify/fedify@2.2.0)': dependencies: - '@fedify/fedify': 2.1.10 + '@fedify/fedify': 2.2.0 '@js-temporal/polyfill': 0.5.1 '@logtape/logtape': 2.0.5 '@opentelemetry/api': 1.9.1 @@ -7550,11 +7550,11 @@ snapshots: '@opentelemetry/sdk-trace-base': 2.7.0(@opentelemetry/api@1.9.1) hono: 4.12.14 - '@fedify/fedify@2.1.10': + '@fedify/fedify@2.2.0': dependencies: - '@fedify/vocab': 2.1.10 - '@fedify/vocab-runtime': 2.1.10 - '@fedify/webfinger': 2.1.10 + '@fedify/vocab': 2.2.0 + '@fedify/vocab-runtime': 2.2.0 + '@fedify/webfinger': 2.2.0 '@js-temporal/polyfill': 0.5.1 '@logtape/logtape': 2.0.5 '@opentelemetry/api': 1.9.1 @@ -7570,14 +7570,14 @@ snapshots: url-template: 3.1.1 urlpattern-polyfill: 10.1.0 - '@fedify/hono@2.1.10(@fedify/fedify@2.1.10)(hono@4.12.14)': + '@fedify/hono@2.2.0(@fedify/fedify@2.2.0)(hono@4.12.14)': dependencies: - '@fedify/fedify': 2.1.10 + '@fedify/fedify': 2.2.0 hono: 4.12.14 - '@fedify/lint@2.1.10(@fedify/fedify@2.1.10)(eslint@10.2.1)(typescript@6.0.3)': + '@fedify/lint@2.2.0(@fedify/fedify@2.2.0)(eslint@10.2.1)(typescript@6.0.3)': dependencies: - '@fedify/fedify': 2.1.10 + '@fedify/fedify': 2.2.0 '@fxts/core': 1.26.0 '@typescript-eslint/parser': 8.59.0(eslint@10.2.1)(typescript@6.0.3) '@typescript-eslint/utils': 8.59.0(eslint@10.2.1)(typescript@6.0.3) @@ -7595,14 +7595,14 @@ snapshots: dependencies: markdown-it: 14.1.1 - '@fedify/postgres@2.1.10(@fedify/fedify@2.1.10)(postgres@3.4.8)': + '@fedify/postgres@2.2.0(@fedify/fedify@2.2.0)(postgres@3.4.8)': dependencies: - '@fedify/fedify': 2.1.10 + '@fedify/fedify': 2.2.0 '@js-temporal/polyfill': 0.5.1 '@logtape/logtape': 2.0.5 postgres: 3.4.8 - '@fedify/vocab-runtime@2.1.10': + '@fedify/vocab-runtime@2.2.0': dependencies: '@logtape/logtape': 2.0.5 '@multiformats/base-x': 4.0.1 @@ -7612,18 +7612,18 @@ snapshots: jsonld: 9.0.0 pkijs: 3.4.0 - '@fedify/vocab-tools@2.1.10': + '@fedify/vocab-tools@2.2.0': dependencies: '@cfworker/json-schema': 4.1.1 byte-encodings: 1.0.11 es-toolkit: 1.46.0 yaml: 2.8.3 - '@fedify/vocab@2.1.10': + '@fedify/vocab@2.2.0': dependencies: - '@fedify/vocab-runtime': 2.1.10 - '@fedify/vocab-tools': 2.1.10 - '@fedify/webfinger': 2.1.10 + '@fedify/vocab-runtime': 2.2.0 + '@fedify/vocab-tools': 2.2.0 + '@fedify/webfinger': 2.2.0 '@js-temporal/polyfill': 0.5.1 '@logtape/logtape': 2.0.5 '@multiformats/base-x': 4.0.1 @@ -7633,9 +7633,9 @@ snapshots: jsonld: 9.0.0 pkijs: 3.4.0 - '@fedify/webfinger@2.1.10': + '@fedify/webfinger@2.2.0': dependencies: - '@fedify/vocab-runtime': 2.1.10 + '@fedify/vocab-runtime': 2.2.0 '@logtape/logtape': 2.0.5 '@opentelemetry/api': 1.9.1 es-toolkit: 1.43.0 From 3567f15b1a407cc965ee8403f664211976c180fc Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 15:58:33 +0900 Subject: [PATCH 02/30] Implement FEP-044f quote controls Add persistent quote state, approval policy, and authorization fields so Hollo can track pending, accepted, rejected, revoked, and unauthorized quotes. Serialize FEP-044f quote properties and interaction policy data, and parse incoming quote targets from the FEP-044f quote vocabulary. Implement Mastodon-compatible quote policy editing, quote listing, and quote revocation behavior. Add QuoteRequest, Accept, Reject, and QuoteAuthorization Delete handling for federation, plus a dispatcher for local QuoteAuthorization objects. Cover the Mastodon API behavior, ActivityPub serialization and parsing, and the federation quote request lifecycle with tests. Assisted-by: Codex:gpt-5.5 --- drizzle/0086_quote_controls.sql | 25 +++ drizzle/meta/_journal.json | 9 +- src/api/v1/statuses.test.ts | 143 +++++++++++++++ src/api/v1/statuses.ts | 303 +++++++++++++++++++++++++++++--- src/entities/medium.ts | 16 +- src/entities/status.ts | 85 ++++++--- src/federation/inbox.test.ts | 214 +++++++++++++++++++++- src/federation/inbox.ts | 243 ++++++++++++++++++++++++- src/federation/index.ts | 32 +++- src/federation/objects.ts | 69 +++++++- src/federation/post.test.ts | 83 +++++++++ src/federation/post.ts | 87 ++++++++- src/notification.ts | 3 +- src/schema.ts | 25 +++ 14 files changed, 1266 insertions(+), 71 deletions(-) create mode 100644 drizzle/0086_quote_controls.sql diff --git a/drizzle/0086_quote_controls.sql b/drizzle/0086_quote_controls.sql new file mode 100644 index 00000000..6b3fffa8 --- /dev/null +++ b/drizzle/0086_quote_controls.sql @@ -0,0 +1,25 @@ +CREATE TYPE "public"."quote_state" AS ENUM( + 'pending', + 'accepted', + 'rejected', + 'revoked', + 'unauthorized' +);--> statement-breakpoint +CREATE TYPE "public"."quote_approval_policy" AS ENUM( + 'public', + 'followers', + 'nobody' +);--> statement-breakpoint +ALTER TABLE "posts" ADD COLUMN "quote_target_iri" text;--> statement-breakpoint +ALTER TABLE "posts" ADD COLUMN "quote_state" "quote_state";--> statement-breakpoint +ALTER TABLE "posts" ADD COLUMN "quote_authorization_iri" text;--> statement-breakpoint +ALTER TABLE "posts" ADD COLUMN "quote_approval_policy" "quote_approval_policy" DEFAULT 'public' NOT NULL;--> statement-breakpoint +UPDATE "posts" AS "post" +SET + "quote_target_iri" = "target"."iri", + "quote_state" = 'accepted' +FROM "posts" AS "target" +WHERE "post"."quote_target_id" = "target"."id";--> statement-breakpoint +UPDATE "posts" +SET "quote_approval_policy" = 'nobody' +WHERE "visibility" IN ('private', 'direct'); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e098160e..396151b4 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -603,6 +603,13 @@ "when": 1777191395619, "tag": "0085_optimize_follower_visibility", "breakpoints": true + }, + { + "idx": 86, + "version": "7", + "when": 1777358526000, + "tag": "0086_quote_controls", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/api/v1/statuses.test.ts b/src/api/v1/statuses.test.ts index 18b099a2..cb8bc374 100644 --- a/src/api/v1/statuses.test.ts +++ b/src/api/v1/statuses.test.ts @@ -214,6 +214,149 @@ describe.sequential("/api/v1/accounts/verify_credentials", () => { }); }); +describe.sequential("/api/v1/statuses quotes", () => { + let author: Awaited>; + let quoter: Awaited>; + let client: Awaited>; + let authorToken: Awaited>; + let quoterToken: Awaited>; + + beforeEach(async () => { + await cleanDatabase(); + + author = await createAccount({ + generateKeyPair: true, + username: "quote-author", + }); + quoter = await createAccount({ + generateKeyPair: true, + username: "quote-quoter", + }); + client = await createOAuthApplication({ + scopes: ["read:statuses", "write:statuses"], + }); + authorToken = await getAccessToken(client, author, [ + "read:statuses", + "write:statuses", + ]); + quoterToken = await getAccessToken(client, quoter, [ + "read:statuses", + "write:statuses", + ]); + }); + + async function createStatus( + token: typeof authorToken, + body: Record, + ) { + return await app.request("/api/v1/statuses", { + method: "POST", + headers: { + authorization: bearerAuthorization(token), + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + } + + it("denies quotes from other users when quote policy is nobody", async () => { + expect.assertions(5); + + const quotedResponse = await createStatus(authorToken, { + status: "Please do not quote", + quote_approval_policy: "nobody", + }); + expect(quotedResponse.status).toBe(200); + const quoted = await quotedResponse.json(); + expect(quoted.quote_approval.automatic).toEqual([]); + + const deniedResponse = await createStatus(quoterToken, { + status: "I should not be able to quote this", + quoted_status_id: quoted.id, + }); + expect(deniedResponse.status).toBe(422); + + const selfQuoteResponse = await createStatus(authorToken, { + status: "Self quotes are allowed", + quoted_status_id: quoted.id, + }); + expect(selfQuoteResponse.status).toBe(200); + const selfQuote = await selfQuoteResponse.json(); + expect(selfQuote.quote.state).toBe("accepted"); + }); + + it("edits quote policy through the interaction policy endpoint", async () => { + expect.assertions(5); + + const createResponse = await createStatus(authorToken, { + status: "Followers can quote this later", + quote_approval_policy: "public", + }); + expect(createResponse.status).toBe(200); + const created = await createResponse.json(); + expect(created.quote_approval.automatic).toEqual(["public"]); + + const updateResponse = await app.request( + `/api/v1/statuses/${created.id}/interaction_policy`, + { + method: "PUT", + headers: { + authorization: bearerAuthorization(authorToken), + "Content-Type": "application/json", + }, + body: JSON.stringify({ quote_approval_policy: "followers" }), + }, + ); + expect(updateResponse.status).toBe(200); + const updated = await updateResponse.json(); + expect(updated.quote_approval.automatic).toEqual(["followers"]); + expect(updated.quote_approval.manual).toEqual([]); + }); + + it("returns revoked quote state when a quote is revoked", async () => { + expect.assertions(7); + + const quotedResponse = await createStatus(authorToken, { + status: "Quoted post", + }); + expect(quotedResponse.status).toBe(200); + const quoted = await quotedResponse.json(); + + const quoteResponse = await createStatus(quoterToken, { + status: "Quoting this", + quoted_status_id: quoted.id, + }); + expect(quoteResponse.status).toBe(200); + const quote = await quoteResponse.json(); + expect(quote.quote.state).toBe("accepted"); + + const revokeResponse = await app.request( + `/api/v1/statuses/${quoted.id}/quotes/${quote.id}/revoke`, + { + method: "POST", + headers: { + authorization: bearerAuthorization(authorToken), + }, + }, + ); + expect(revokeResponse.status).toBe(200); + const revoked = await revokeResponse.json(); + expect(revoked.quote.state).toBe("revoked"); + expect(revoked.quote.quoted_status).toBeNull(); + + const quotedAgainResponse = await app.request( + `/api/v1/statuses/${quoted.id}`, + { + headers: { + authorization: bearerAuthorization(authorToken), + }, + }, + ); + const quotedAgain = await quotedAgainResponse.json(); + expect(quotedAgain.quotes_count).toBe(0); + }); +}); + describe.sequential("/api/v1/statuses visibility", () => { let viewer: Awaited>; let approvedAuthor: Awaited>; diff --git a/src/api/v1/statuses.ts b/src/api/v1/statuses.ts index e26de422..4665b127 100644 --- a/src/api/v1/statuses.ts +++ b/src/api/v1/statuses.ts @@ -40,6 +40,7 @@ import { toAnnounce, toCreate, toDelete, + toObject, toUpdate, } from "../../federation/post"; import { appendPostToTimelines } from "../../federation/timeline"; @@ -52,9 +53,11 @@ import { } from "../../oauth/middleware"; import { fetchPreviewCard, type PreviewCard } from "../../previewcard"; import { + accountOwners, blocks, bookmarks, customEmojis, + follows, type Like, likes, type Mention, @@ -71,6 +74,7 @@ import { pollOptions, polls, posts, + type QuoteApprovalPolicy, reactions, } from "../../schema"; import { isUuid, type Uuid, uuid, uuidv7 } from "../../uuid"; @@ -82,6 +86,8 @@ import { const app = new Hono<{ Variables: Variables }>(); const logger = getLogger(["hollo", "api", "v1", "statuses"]); +const quoteApprovalPolicySchema = z.enum(["public", "followers", "nobody"]); + function getPostOrderingKey(postIri: string): string { return `post:${postIri}`; } @@ -141,6 +147,109 @@ function buildMuteAndBlockConditions(viewerAccountId: Uuid | null | undefined) { ); } +function normalizeQuoteApprovalPolicy( + policy: QuoteApprovalPolicy | null | undefined, + visibility: "public" | "unlisted" | "private" | "direct", +): QuoteApprovalPolicy { + if (visibility === "private" || visibility === "direct") return "nobody"; + return policy ?? "public"; +} + +async function isApprovedFollower( + followerId: Uuid, + followingId: Uuid, +): Promise { + const follow = await db.query.follows.findFirst({ + where: and( + eq(follows.followerId, followerId), + eq(follows.followingId, followingId), + isNotNull(follows.approved), + ), + }); + return follow != null; +} + +async function isBlockedBetween(accountId: Uuid, otherAccountId: Uuid) { + const block = await db.query.blocks.findFirst({ + where: or( + and( + eq(blocks.accountId, accountId), + eq(blocks.blockedAccountId, otherAccountId), + ), + and( + eq(blocks.accountId, otherAccountId), + eq(blocks.blockedAccountId, accountId), + ), + ), + }); + return block != null; +} + +async function validateQuoteTarget( + quoteTargetId: Uuid, + owner: { id: Uuid }, + mentionedIds: Uuid[], + requestedVisibility: "public" | "unlisted" | "private" | "direct", +): Promise< + | { + ok: true; + quoteTarget: typeof posts.$inferSelect; + visibility: "public" | "unlisted" | "private" | "direct"; + } + | { ok: false; status: 404 | 422; error: string } +> { + const visibilityScope = await getPostVisibilityScope(owner.id); + const quoteTarget = await db.query.posts.findFirst({ + where: and( + eq(posts.id, quoteTargetId), + buildPostVisibilityConditions(visibilityScope), + ), + }); + if (quoteTarget == null) { + return { ok: false, status: 404, error: "Quote target not found" }; + } + if (quoteTarget.visibility === "direct") { + return { ok: false, status: 422, error: "Cannot quote a direct message" }; + } + + let visibility = requestedVisibility; + if ( + quoteTarget.visibility === "private" && + (visibility === "public" || visibility === "unlisted") + ) { + visibility = "private"; + } + if ( + visibility === "direct" && + !mentionedIds.includes(quoteTarget.accountId) + ) { + return { + ok: false, + status: 422, + error: "Cannot quote without mentioning the quoted status author", + }; + } + if (await isBlockedBetween(owner.id, quoteTarget.accountId)) { + return { ok: false, status: 422, error: "Quote target is not quotable" }; + } + if (quoteTarget.accountId !== owner.id) { + const policy = normalizeQuoteApprovalPolicy( + quoteTarget.quoteApprovalPolicy, + quoteTarget.visibility, + ); + if (policy === "nobody") { + return { ok: false, status: 422, error: "Quote target is not quotable" }; + } + if ( + policy === "followers" && + !(await isApprovedFollower(owner.id, quoteTarget.accountId)) + ) { + return { ok: false, status: 422, error: "Quote target is not quotable" }; + } + } + return { ok: true, quoteTarget, visibility }; +} + const statusSchema = z.object({ status: z.string().min(1).optional().nullable(), media_ids: z.array(uuid).optional().nullable(), @@ -162,7 +271,7 @@ const statusSchema = z.object({ sensitive: z.boolean().default(false), spoiler_text: z.string().optional().nullable(), language: z.string().min(2).optional().nullable(), - quote_approval_policy: z.string().optional().nullable(), + quote_approval_policy: quoteApprovalPolicySchema.optional().nullable(), }); const createStatusSchema = statusSchema.extend({ @@ -239,6 +348,7 @@ app.post("/", tokenRequired, scopeRequired(["write:statuses"]), async (c) => { previewCard = await fetchPreviewCard(content.previewLink); } let quoteTargetId: Uuid | null = null; + let quoteTarget: typeof posts.$inferSelect | null = null; if (data.quoted_status_id != null) quoteTargetId = data.quoted_status_id; else if (data.quote_id != null) quoteTargetId = data.quote_id; else if (content?.quoteTarget != null) { @@ -252,18 +362,31 @@ app.post("/", tokenRequired, scopeRequired(["write:statuses"]), async (c) => { } let effectiveVisibility = data.visibility ?? owner.visibility; if (quoteTargetId != null) { - const quoteTarget = await db.query.posts.findFirst({ - where: eq(posts.id, quoteTargetId), - }); - if (quoteTarget == null) { - return c.json({ error: "Quote target not found" }, 404); - } - if (quoteTarget.visibility === "direct") { - return c.json({ error: "Cannot quote a direct message" }, 422); - } - if (quoteTarget.visibility === "private") { - effectiveVisibility = "private"; + const validation = await validateQuoteTarget( + quoteTargetId, + owner, + mentionedIds, + effectiveVisibility, + ); + if (!validation.ok) { + return c.json({ error: validation.error }, validation.status); } + quoteTarget = validation.quoteTarget; + effectiveVisibility = validation.visibility; + } + const quoteApprovalPolicy = normalizeQuoteApprovalPolicy( + data.quote_approval_policy, + effectiveVisibility, + ); + let quoteState: "accepted" | "pending" | null = null; + if (quoteTarget != null) { + const localQuoteTargetOwner = + quoteTarget.accountId === owner.id + ? owner + : await db.query.accountOwners.findFirst({ + where: eq(accountOwners.id, quoteTarget.accountId), + }); + quoteState = localQuoteTargetOwner == null ? "pending" : "accepted"; } await db.transaction(async (tx) => { let poll: Poll | null = null; @@ -298,6 +421,9 @@ app.post("/", tokenRequired, scopeRequired(["write:statuses"]), async (c) => { applicationId: token.applicationId, replyTargetId: data.in_reply_to_id, quoteTargetId, + quoteTargetIri: quoteTarget?.iri ?? null, + quoteState, + quoteApprovalPolicy, sharingId: null, visibility: effectiveVisibility, summary, @@ -339,7 +465,10 @@ app.post("/", tokenRequired, scopeRequired(["write:statuses"]), async (c) => { ) .returning(); } - if (quoteTargetId != null) { + if ( + quoteTargetId != null && + (quoteState == null || quoteState === "accepted") + ) { await tx .update(posts) .set({ quotesCount: sql`coalesce(${posts.quotesCount}, 0) + 1` }) @@ -380,6 +509,32 @@ app.post("/", tokenRequired, scopeRequired(["write:statuses"]), async (c) => { excludeBaseUris: [new URL(c.req.url)], }); } + if (post.quoteState === "pending" && post.quoteTarget != null) { + await fedCtx.sendActivity( + { username: handle }, + { + id: new URL(post.quoteTarget.account.iri), + inboxId: new URL(post.quoteTarget.account.inboxUrl), + endpoints: + post.quoteTarget.account.sharedInboxUrl == null + ? null + : { + sharedInbox: new URL(post.quoteTarget.account.sharedInboxUrl), + }, + }, + new vocab.QuoteRequest({ + id: new URL("#quote-request", post.iri), + actor: new URL(owner.account.iri), + object: new URL(post.quoteTarget.iri), + instrument: toObject(post, fedCtx), + }), + { + orderingKey, + preferSharedInbox: true, + excludeBaseUris: [new URL(c.req.url)], + }, + ); + } return c.json(serializePost(post, owner, c.req.url)); }); @@ -433,6 +588,16 @@ app.put("/:id", tokenRequired, scopeRequired(["write:statuses"]), async (c) => { if (content?.previewLink != null) { previewCard = await fetchPreviewCard(content.previewLink); } + const existingPost = await db.query.posts.findFirst({ + where: and(eq(posts.id, id), eq(posts.accountId, owner.id)), + }); + if (existingPost == null) { + return c.json({ error: "Record not found" }, 404); + } + const quoteApprovalPolicy = normalizeQuoteApprovalPolicy( + data.quote_approval_policy ?? existingPost.quoteApprovalPolicy, + existingPost.visibility, + ); await db.transaction(async (tx) => { const result = await tx .update(posts) @@ -445,9 +610,10 @@ app.put("/:id", tokenRequired, scopeRequired(["write:statuses"]), async (c) => { tags, emojis, previewCard, + quoteApprovalPolicy, updated: new Date(), }) - .where(eq(posts.id, id)) + .where(and(eq(posts.id, id), eq(posts.accountId, owner.id))) .returning(); if (result.length < 1) return c.json({ error: "Record not found" }, 404); await tx.delete(mentions).where(eq(mentions.postId, id)); @@ -484,6 +650,78 @@ app.put("/:id", tokenRequired, scopeRequired(["write:statuses"]), async (c) => { return c.json(serializePost(post!, owner, c.req.url)); }); +const interactionPolicySchema = z.object({ + quote_approval_policy: quoteApprovalPolicySchema, +}); + +app.put( + "/:id/interaction_policy", + tokenRequired, + scopeRequired(["write:statuses"]), + async (c) => { + const token = c.get("token"); + const owner = token.accountOwner; + if (owner == null) { + return c.json( + { error: "This method requires an authenticated user" }, + 422, + ); + } + const id = c.req.param("id"); + if (!isUuid(id)) return c.json({ error: "Record not found" }, 404); + + const result = await requestBody(c.req, interactionPolicySchema); + if (!result.success) { + logger.debug("Invalid request: {error}", { error: result.error.issues }); + return c.json({ error: "invalid_request", zod_error: result.error }, 422); + } + + const post = await db.query.posts.findFirst({ + where: and(eq(posts.id, id), eq(posts.accountId, owner.id)), + }); + if (post == null) return c.json({ error: "Record not found" }, 404); + + const quoteApprovalPolicy = normalizeQuoteApprovalPolicy( + result.data.quote_approval_policy, + post.visibility, + ); + await db + .update(posts) + .set({ quoteApprovalPolicy, updated: new Date() }) + .where(and(eq(posts.id, id), eq(posts.accountId, owner.id))); + + const updatedPost = await db.query.posts.findFirst({ + where: eq(posts.id, id), + with: getPostRelations(owner.id), + }); + if (updatedPost == null) return c.json({ error: "Record not found" }, 404); + + const fedCtx = federation.createContext(c.req.raw, undefined); + const activity = toUpdate(updatedPost, fedCtx); + const orderingKey = getPostOrderingKey(updatedPost.iri); + await fedCtx.sendActivity( + { username: owner.handle }, + getRecipients(updatedPost), + activity, + { + orderingKey, + excludeBaseUris: [new URL(c.req.url)], + }, + ); + await fedCtx.sendActivity( + { username: owner.handle }, + "followers", + activity, + { + orderingKey, + preferSharedInbox: true, + excludeBaseUris: [new URL(c.req.url)], + }, + ); + return c.json(serializePost(updatedPost, owner, c.req.url)); + }, +); + app.get("/:id", async (c) => { const token = await getAccessToken(c); const owner = @@ -528,7 +766,10 @@ app.delete( if (post == null) return c.json({ error: "Record not found" }, 404); await db.transaction(async (tx) => { await tx.delete(posts).where(eq(posts.id, id)); - if (post.quoteTargetId != null) { + if ( + post.quoteTargetId != null && + (post.quoteState == null || post.quoteState === "accepted") + ) { await tx .update(posts) .set({ @@ -1455,6 +1696,7 @@ app.get("/:id/quotes", async (c) => { const quotes = await db.query.posts.findMany({ where: and( eq(posts.quoteTargetId, id), + or(eq(posts.quoteState, "accepted"), isNull(posts.quoteState)), isNull(posts.sharingId), buildPostVisibilityConditions(visibilityScope), buildMuteAndBlockConditions(owner?.id), @@ -1495,15 +1737,16 @@ app.post( return c.json({ error: "Record not found" }, 404); } - // Verify the target post is owned by the current user const targetPost = await db.query.posts.findFirst({ - where: and(eq(posts.id, id), eq(posts.accountId, owner.id)), + where: eq(posts.id, id), }); if (targetPost == null) { return c.json({ error: "Record not found" }, 404); } + if (targetPost.accountId !== owner.id) { + return c.json({ error: "This status is not yours" }, 403); + } - // Verify the quoting post actually quotes the target post const quotingPost = await db.query.posts.findFirst({ where: and(eq(posts.id, quotingStatusId), eq(posts.quoteTargetId, id)), }); @@ -1511,21 +1754,29 @@ app.post( return c.json({ error: "Record not found" }, 404); } - // Revoke: set quoteTargetId to null and decrement quotesCount await db.transaction(async (tx) => { - await tx - .update(posts) - .set({ quoteTargetId: null }) - .where(eq(posts.id, quotingStatusId)); await tx .update(posts) .set({ - quotesCount: sql`GREATEST(coalesce(${posts.quotesCount}, 0) - 1, 0)`, + quoteState: "revoked", + quoteTargetIri: quotingPost.quoteTargetIri ?? targetPost.iri, + quoteAuthorizationIri: null, + updated: new Date(), }) - .where(eq(posts.id, id)); + .where(eq(posts.id, quotingStatusId)); + if ( + quotingPost.quoteState == null || + quotingPost.quoteState === "accepted" + ) { + await tx + .update(posts) + .set({ + quotesCount: sql`GREATEST(coalesce(${posts.quotesCount}, 0) - 1, 0)`, + }) + .where(eq(posts.id, id)); + } }); - // Return the updated quoting status with revoked quote state const updatedPost = await db.query.posts.findFirst({ where: eq(posts.id, quotingStatusId), with: getPostRelations(owner.id), diff --git a/src/entities/medium.ts b/src/entities/medium.ts index 51c230b6..72ba3b0e 100644 --- a/src/entities/medium.ts +++ b/src/entities/medium.ts @@ -1,4 +1,14 @@ -import { and, eq, exists, ilike, lt, not, notExists } from "drizzle-orm"; +import { + and, + eq, + exists, + ilike, + isNull, + lt, + not, + notExists, + or, +} from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import db from "../db"; @@ -149,6 +159,10 @@ export async function getMediaWithDeletableThumbnails( .where( and( eq(posts.id, quotingPosts.quoteTargetId), + or( + eq(quotingPosts.quoteState, "accepted"), + isNull(quotingPosts.quoteState), + ), exists( db .select() diff --git a/src/entities/status.ts b/src/entities/status.ts index a1cb6847..1fe8228f 100644 --- a/src/entities/status.ts +++ b/src/entities/status.ts @@ -17,6 +17,8 @@ import { type PollOption, type PollVote, type Post, + type QuoteApprovalPolicy, + type QuoteState, pollOptions, pollVotes, posts, @@ -29,6 +31,45 @@ import { serializeEmojis, serializeReactions } from "./emoji"; import { serializeMedium } from "./medium"; import { serializePoll } from "./poll"; +function getEffectiveQuoteState( + post: Post & { quoteTarget: Post | null }, +): QuoteState | "deleted" | null { + const state = + post.quoteState ?? (post.quoteTargetId == null ? null : "accepted"); + if (state === "accepted" && post.quoteTarget == null) return "deleted"; + return state; +} + +function serializeQuoteApproval( + policy: QuoteApprovalPolicy, + currentAccountOwner: { id: string } | undefined | null, + post: Pick, +) { + const effectivePolicy = + post.visibility === "private" || post.visibility === "direct" + ? "nobody" + : policy; + const automatic = + effectivePolicy === "public" + ? ["public"] + : effectivePolicy === "followers" + ? ["followers"] + : []; + return { + automatic, + manual: [], + ...(currentAccountOwner == null + ? {} + : { + current_user: + currentAccountOwner.id === post.accountId || + effectivePolicy === "public" + ? "automatic" + : "denied", + }), + }; +} + export function getPostRelations(ownerId: Uuid | undefined | null) { return { account: { with: { owner: true, successor: true } }, @@ -260,6 +301,9 @@ export function serializePost( baseUrl: URL | string, // oxlint-disable-next-line typescript/no-explicit-any ): Record { + const quoteState = getEffectiveQuoteState(post); + const quoteIsDisplayable = + quoteState === "accepted" && post.quoteTarget != null; return { id: post.id, created_at: post.published ?? post.updated, @@ -297,7 +341,7 @@ export function serializePost( ? false : post.pin != null && post.pin.accountId === currentAccountOwner.id, content: sanitizeHtml( - post.quoteTarget == null + !quoteIsDisplayable ? (post.contentHtml ?? "") : stripQuoteInlineFallbacks(post.contentHtml ?? ""), ), @@ -311,32 +355,23 @@ export function serializePost( ), quote_id: post.quoteTargetId, quote: - post.quoteTarget != null - ? { - state: "accepted", - quoted_status: serializePost( - { ...post.quoteTarget, quoteTarget: null, sharing: null }, - currentAccountOwner, - baseUrl, - ), - } - : post.quoteTargetId != null - ? { state: "deleted", quoted_status: null } - : null, - quote_approval: - post.visibility === "public" || post.visibility === "unlisted" - ? { - automatic: ["public"], - manual: [], - ...(currentAccountOwner != null - ? { current_user: "automatic" } - : {}), - } + quoteState == null + ? null : { - automatic: [], - manual: [], - ...(currentAccountOwner != null ? { current_user: "denied" } : {}), + state: quoteState, + quoted_status: quoteIsDisplayable + ? serializePost( + { ...post.quoteTarget!, quoteTarget: null, sharing: null }, + currentAccountOwner, + baseUrl, + ) + : null, }, + quote_approval: serializeQuoteApproval( + post.quoteApprovalPolicy, + currentAccountOwner, + post, + ), application: post.application == null ? null diff --git a/src/federation/inbox.test.ts b/src/federation/inbox.test.ts index c6431ed8..56b2d195 100644 --- a/src/federation/inbox.test.ts +++ b/src/federation/inbox.test.ts @@ -1,14 +1,29 @@ import type { InboxContext } from "@fedify/fedify"; -import { Accept, Reject } from "@fedify/vocab"; +import { + Accept, + Delete, + Note, + Person, + QuoteAuthorization, + QuoteRequest, + Reject, +} from "@fedify/vocab"; import { and, eq } from "drizzle-orm"; -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { cleanDatabase } from "../../tests/helpers"; import { createAccount } from "../../tests/helpers/oauth"; import db from "../db"; -import { accounts, follows } from "../schema"; +import { accounts, follows, posts } from "../schema"; import type { Uuid } from "../uuid"; -import { onFollowAccepted, onFollowRejected } from "./inbox"; +import { + onFollowAccepted, + onFollowRejected, + onQuoteAuthorizationDeleted, + onQuoteRequested, + onQuoteRequestAccepted, + onQuoteRequestRejected, +} from "./inbox"; type SeededFollow = { followerId: Uuid; @@ -166,3 +181,194 @@ describe("onFollowRejected", () => { expect(follow).toBeUndefined(); }); }); + +describe("quote request lifecycle", () => { + beforeEach(async () => { + await cleanDatabase(); + }); + + async function seedPendingQuote() { + const author = await createAccount({ username: "quote-author" }); + const quoter = await createAccount({ username: "quote-quoter" }); + const quotedPostId = crypto.randomUUID() as Uuid; + const quotePostId = crypto.randomUUID() as Uuid; + const quotedPostIri = `https://hollo.test/@quote-author/${quotedPostId}`; + const quotePostIri = `https://hollo.test/@quote-quoter/${quotePostId}`; + + await db.insert(posts).values([ + { + id: quotedPostId, + iri: quotedPostIri, + type: "Note", + accountId: author.id as Uuid, + visibility: "public", + contentHtml: "

Quoted post

", + content: "Quoted post", + published: new Date(), + }, + { + id: quotePostId, + iri: quotePostIri, + type: "Note", + accountId: quoter.id as Uuid, + quoteTargetId: quotedPostId, + quoteTargetIri: quotedPostIri, + quoteState: "pending", + visibility: "public", + contentHtml: "

Quote post

", + content: "Quote post", + published: new Date(), + }, + ]); + + return { quotedPostId, quotedPostIri, quotePostId, quotePostIri }; + } + + it("marks a pending quote accepted from Accept", async () => { + expect.assertions(3); + + const seeded = await seedPendingQuote(); + const authorizationIri = `${seeded.quotedPostIri}/quote_authorizations/${seeded.quotePostId}`; + const accept = new Accept({ + actor: new URL("https://hollo.test/@quote-author"), + object: new QuoteRequest({ + object: new URL(seeded.quotedPostIri), + instrument: new URL(seeded.quotePostIri), + }), + result: new URL(authorizationIri), + }); + + await onQuoteRequestAccepted(ctx, accept); + + const quote = await db.query.posts.findFirst({ + where: eq(posts.id, seeded.quotePostId), + }); + const quoted = await db.query.posts.findFirst({ + where: eq(posts.id, seeded.quotedPostId), + }); + expect(quote?.quoteState).toBe("accepted"); + expect(quote?.quoteAuthorizationIri).toBe(authorizationIri); + expect(quoted?.quotesCount).toBe(1); + }); + + it("marks a pending quote rejected from Reject", async () => { + expect.assertions(2); + + const seeded = await seedPendingQuote(); + const reject = new Reject({ + actor: new URL("https://hollo.test/@quote-author"), + object: new QuoteRequest({ + object: new URL(seeded.quotedPostIri), + instrument: new URL(seeded.quotePostIri), + }), + }); + + await onQuoteRequestRejected(ctx, reject); + + const quote = await db.query.posts.findFirst({ + where: eq(posts.id, seeded.quotePostId), + }); + const quoted = await db.query.posts.findFirst({ + where: eq(posts.id, seeded.quotedPostId), + }); + expect(quote?.quoteState).toBe("rejected"); + expect(quoted?.quotesCount).toBe(0); + }); + + it("marks an accepted quote revoked when its authorization is deleted", async () => { + expect.assertions(2); + + const seeded = await seedPendingQuote(); + const authorizationIri = `${seeded.quotedPostIri}/quote_authorizations/${seeded.quotePostId}`; + await db + .update(posts) + .set({ + quoteState: "accepted", + quoteAuthorizationIri: authorizationIri, + quotesCount: 1, + }) + .where(eq(posts.id, seeded.quotePostId)); + await db + .update(posts) + .set({ quotesCount: 1 }) + .where(eq(posts.id, seeded.quotedPostId)); + + await onQuoteAuthorizationDeleted( + ctx, + new Delete({ + actor: new URL("https://hollo.test/@quote-author"), + object: new QuoteAuthorization({ + id: new URL(authorizationIri), + attribution: new URL("https://hollo.test/@quote-author"), + interactingObject: new URL(seeded.quotePostIri), + interactionTarget: new URL(seeded.quotedPostIri), + }), + }), + ); + + const quote = await db.query.posts.findFirst({ + where: eq(posts.id, seeded.quotePostId), + }); + const quoted = await db.query.posts.findFirst({ + where: eq(posts.id, seeded.quotedPostId), + }); + expect(quote?.quoteState).toBe("revoked"); + expect(quoted?.quotesCount).toBe(0); + }); + + it("accepts an allowed QuoteRequest for a local post", async () => { + expect.assertions(4); + + const author = await createAccount({ username: "quote-author" }); + const quotedPostId = crypto.randomUUID() as Uuid; + const quotedPostIri = `https://hollo.test/@quote-author/${quotedPostId}`; + const quotePostIri = "https://remote.test/@quoter/quote-1"; + const sendActivity = vi.fn(async () => undefined); + const requestCtx = { + ...ctx, + sendActivity, + } as unknown as InboxContext; + + await db.insert(posts).values({ + id: quotedPostId, + iri: quotedPostIri, + type: "Note", + accountId: author.id as Uuid, + visibility: "public", + quoteApprovalPolicy: "public", + contentHtml: "

Quoted post

", + content: "Quoted post", + published: new Date(), + }); + + const request = new QuoteRequest({ + actor: new URL("https://remote.test/@quoter"), + object: new URL(quotedPostIri), + instrument: new Note({ + id: new URL(quotePostIri), + attribution: new Person({ + id: new URL("https://remote.test/@quoter"), + name: "quoter", + preferredUsername: "quoter", + inbox: new URL("https://remote.test/@quoter/inbox"), + }), + quote: new URL(quotedPostIri), + to: new URL("https://www.w3.org/ns/activitystreams#Public"), + content: "

Remote quote

", + }), + }); + + await onQuoteRequested(requestCtx, request); + + const quote = await db.query.posts.findFirst({ + where: eq(posts.iri, quotePostIri), + }); + const quoted = await db.query.posts.findFirst({ + where: eq(posts.id, quotedPostId), + }); + expect(quote?.quoteState).toBe("accepted"); + expect(quote?.quoteTargetId).toBe(quotedPostId); + expect(quoted?.quotesCount).toBe(1); + expect(sendActivity).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/federation/inbox.ts b/src/federation/inbox.ts index 6981c21d..609153f6 100644 --- a/src/federation/inbox.ts +++ b/src/federation/inbox.ts @@ -18,13 +18,15 @@ import { type Move, Note, Question, - type Reject, + QuoteAuthorization, + QuoteRequest, + Reject, type Remove, type Undo, type Update, } from "@fedify/vocab"; import { getLogger } from "@logtape/logtape"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq, inArray, isNotNull, isNull, or, sql } from "drizzle-orm"; import { db } from "../db"; import { @@ -378,6 +380,228 @@ export async function onFollowRejected( } } +async function getQuoteRequestFromActivity( + activity: Accept | Reject, +): Promise { + const object = await activity.getObject({ crossOrigin: "trust" }); + return object instanceof QuoteRequest ? object : null; +} + +async function updateQuoteRequestState( + request: QuoteRequest, + state: "accepted" | "rejected", + quoteAuthorizationIri: string | null, +): Promise { + const quoteIri = request.instrumentId?.href; + if (quoteIri == null) return; + const quote = await db.query.posts.findFirst({ + where: eq(posts.iri, quoteIri), + }); + if (quote == null) return; + const target = + request.objectId == null + ? null + : await db.query.posts.findFirst({ + where: eq(posts.iri, request.objectId.href), + }); + await db.transaction(async (tx) => { + await tx + .update(posts) + .set({ + quoteState: state, + quoteAuthorizationIri, + quoteTargetId: target?.id ?? quote.quoteTargetId, + quoteTargetIri: + request.objectId?.href ?? quote.quoteTargetIri ?? target?.iri ?? null, + updated: new Date(), + }) + .where(eq(posts.id, quote.id)); + if ( + state === "accepted" && + target != null && + quote.quoteState !== "accepted" + ) { + await tx + .update(posts) + .set({ quotesCount: sql`coalesce(${posts.quotesCount}, 0) + 1` }) + .where(eq(posts.id, target.id)); + } + }); +} + +export async function onQuoteRequestAccepted( + _ctx: InboxContext, + accept: Accept, +): Promise { + const request = await getQuoteRequestFromActivity(accept); + if (request == null) return; + await updateQuoteRequestState( + request, + "accepted", + accept.resultId?.href ?? null, + ); +} + +export async function onQuoteRequestRejected( + _ctx: InboxContext, + reject: Reject, +): Promise { + const request = await getQuoteRequestFromActivity(reject); + if (request == null) return; + await updateQuoteRequestState(request, "rejected", null); +} + +export async function onQuoteAuthorizationDeleted( + _ctx: InboxContext, + del: Delete, +): Promise { + const object = await del.getObject({ crossOrigin: "trust" }); + const authorizationIri = + object instanceof QuoteAuthorization ? object.id?.href : del.objectId?.href; + const quoteIri = + object instanceof QuoteAuthorization + ? object.interactingObjectId?.href + : null; + const quote = + authorizationIri == null + ? quoteIri == null + ? null + : await db.query.posts.findFirst({ where: eq(posts.iri, quoteIri) }) + : await db.query.posts.findFirst({ + where: eq(posts.quoteAuthorizationIri, authorizationIri), + }); + if (quote == null) return; + await db.transaction(async (tx) => { + await tx + .update(posts) + .set({ + quoteState: "revoked", + quoteAuthorizationIri: null, + updated: new Date(), + }) + .where(eq(posts.id, quote.id)); + if ( + quote.quoteTargetId != null && + (quote.quoteState == null || quote.quoteState === "accepted") + ) { + await tx + .update(posts) + .set({ + quotesCount: sql`GREATEST(coalesce(${posts.quotesCount}, 0) - 1, 0)`, + }) + .where(eq(posts.id, quote.quoteTargetId)); + } + }); +} + +function getQuoteAuthorizationIri(target: Post, quote: Post): string { + return `${target.iri}/quote_authorizations/${quote.id}`; +} + +async function canAutomaticallyAcceptQuoteRequest( + target: Post, + quote: Post, +): Promise { + if (target.accountId === quote.accountId) return true; + if (target.visibility === "direct") return false; + const policy = + target.visibility === "private" ? "nobody" : target.quoteApprovalPolicy; + if (policy === "public") return true; + if (policy === "nobody") return false; + const follow = await db.query.follows.findFirst({ + where: and( + eq(follows.followerId, quote.accountId), + eq(follows.followingId, target.accountId), + isNotNull(follows.approved), + ), + }); + return follow != null; +} + +export async function onQuoteRequested( + ctx: InboxContext, + request: QuoteRequest, +): Promise { + if (request.objectId == null) return; + const target = await db.query.posts.findFirst({ + where: eq(posts.iri, request.objectId.href), + with: { account: { with: { owner: true } } }, + }); + if (target?.account.owner == null) return; + const instrument = await request.getInstrument({ crossOrigin: "trust" }); + if (!isPost(instrument)) return; + const persistedQuote = await persistPost( + db, + instrument, + ctx.origin, + getPersistOptions(ctx), + ); + if (persistedQuote == null) return; + + const accepted = await canAutomaticallyAcceptQuoteRequest( + target, + persistedQuote, + ); + const authorizationIri = getQuoteAuthorizationIri(target, persistedQuote); + await db.transaction(async (tx) => { + await tx + .update(posts) + .set({ + quoteTargetId: target.id, + quoteTargetIri: target.iri, + quoteState: accepted ? "accepted" : "rejected", + quoteAuthorizationIri: accepted ? authorizationIri : null, + updated: new Date(), + }) + .where(eq(posts.id, persistedQuote.id)); + if (accepted && persistedQuote.quoteState !== "accepted") { + 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 }); + } + }); + + if ( + request.actorId != null && + request.actorId.href !== persistedQuote.account.iri + ) { + return; + } + const recipient = { + id: new URL(persistedQuote.account.iri), + inboxId: new URL(persistedQuote.account.inboxUrl), + endpoints: + persistedQuote.account.sharedInboxUrl == null + ? null + : { + sharedInbox: new URL(persistedQuote.account.sharedInboxUrl), + }, + }; + const response = accepted + ? new Accept({ + actor: new URL(target.account.iri), + object: request, + result: new QuoteAuthorization({ + id: new URL(authorizationIri), + attribution: new URL(target.account.iri), + interactingObject: new URL(persistedQuote.iri), + interactionTarget: new URL(target.iri), + }), + }) + : new Reject({ + actor: new URL(target.account.iri), + object: request, + }); + await ctx.sendActivity( + { username: target.account.owner.handle }, + recipient, + response, + ); +} + export async function onBlocked( ctx: InboxContext, block: Block, @@ -476,7 +700,10 @@ export async function onPostCreated( if (post?.replyTargetId != null) { await updatePostStats(db, { id: post.replyTargetId }); } - if (post?.quoteTargetId != null) { + if ( + post?.quoteTargetId != null && + (post.quoteState == null || post.quoteState === "accepted") + ) { await updatePostStats(db, { id: post.quoteTargetId }); } @@ -527,7 +754,10 @@ export async function onPostCreated( } // Create quote notification if this post quotes another post - if (post.quoteTargetId != null) { + if ( + post.quoteTargetId != null && + (post.quoteState == null || post.quoteState === "accepted") + ) { const quoteTarget = await db.query.posts.findFirst({ where: eq(posts.id, post.quoteTargetId), with: { @@ -602,7 +832,10 @@ export async function onPostUpdated( // Create quoted_update notifications for users who quoted this post if (existingPost != null) { const quotePosts = await db.query.posts.findMany({ - where: eq(posts.quoteTargetId, existingPost.id), + where: and( + eq(posts.quoteTargetId, existingPost.id), + or(eq(posts.quoteState, "accepted"), isNull(posts.quoteState)), + ), with: { account: { with: { owner: true } }, }, diff --git a/src/federation/index.ts b/src/federation/index.ts index 5039a3ff..02dec87a 100644 --- a/src/federation/index.ts +++ b/src/federation/index.ts @@ -13,6 +13,7 @@ import { Like, Move, Note, + QuoteRequest, Reject, Remove, Undo, @@ -22,7 +23,7 @@ import { getLogger } from "@logtape/logtape"; import { eq } from "drizzle-orm"; import { db } from "../db"; -import { accounts, follows } from "../schema"; +import { accounts, follows, posts } from "../schema"; import { updateAccountStats } from "./account"; import "./actor"; import { federation } from "./federation"; @@ -47,6 +48,10 @@ import { onPostUnpinned, onPostUnshared, onPostUpdated, + onQuoteAuthorizationDeleted, + onQuoteRequestAccepted, + onQuoteRequestRejected, + onQuoteRequested, onUnblocked, onUnfollowed, onUnliked, @@ -81,8 +86,23 @@ federation return anyOwner == null ? null : { username: anyOwner.handle }; }) .on(Follow, onFollowed) - .on(Accept, onFollowAccepted) - .on(Reject, onFollowRejected) + .on(Accept, async (ctx, accept) => { + const object = await accept.getObject({ crossOrigin: "trust" }); + if (object instanceof QuoteRequest) { + await onQuoteRequestAccepted(ctx, accept); + } else { + await onFollowAccepted(ctx, accept); + } + }) + .on(Reject, async (ctx, reject) => { + const object = await reject.getObject({ crossOrigin: "trust" }); + if (object instanceof QuoteRequest) { + await onQuoteRequestRejected(ctx, reject); + } else { + await onFollowRejected(ctx, reject); + } + }) + .on(QuoteRequest, onQuoteRequested) .on(Create, async (ctx, create) => { const object = await create.getObject(); if ( @@ -124,6 +144,12 @@ federation if (actorId == null || objectId == null) return; if (objectId.href === actorId.href) { await onAccountDeleted(ctx, del); + } else if ( + (await db.query.posts.findFirst({ + where: eq(posts.quoteAuthorizationIri, objectId.href), + })) != null + ) { + await onQuoteAuthorizationDeleted(ctx, del); } else { await onPostDeleted(ctx, del); } diff --git a/src/federation/objects.ts b/src/federation/objects.ts index 9215f118..7d08364a 100644 --- a/src/federation/objects.ts +++ b/src/federation/objects.ts @@ -1,5 +1,5 @@ -import { Emoji, Flag, Note } from "@fedify/vocab"; -import { and, eq, inArray, like } from "drizzle-orm"; +import { Emoji, Flag, Note, QuoteAuthorization } from "@fedify/vocab"; +import { and, eq, inArray, isNull, like, or } from "drizzle-orm"; import { db } from "../db"; import { @@ -84,6 +84,71 @@ federation.setObjectDispatcher( }, ); +federation.setObjectDispatcher( + QuoteAuthorization, + "/@{username}/{id}/quote_authorizations/{quoteId}", + async (ctx, values) => { + if (!values.id?.match(/^[-a-f0-9]+$/)) return null; + if (!values.quoteId?.match(/^[-a-f0-9]+$/)) return null; + const owner = await db.query.accountOwners.findFirst({ + where: like(accountOwners.handle, values.username), + with: { account: true }, + }); + if (owner == null) return null; + if (!isUuid(values.id) || !isUuid(values.quoteId)) return null; + const targetPost = await db.query.posts.findFirst({ + where: and( + eq(posts.id, values.id), + eq(posts.accountId, owner.account.id), + ), + with: { + account: { with: { owner: true } }, + mentions: { with: { account: true } }, + }, + }); + if (targetPost == null) return null; + if (targetPost.visibility === "private") { + const keyOwner = await ctx.getSignedKeyOwner(); + if (keyOwner?.id == null) return null; + const found = await db.query.follows.findFirst({ + where: and( + inArray( + follows.followerId, + db + .select({ id: accounts.id }) + .from(accounts) + .where(eq(accounts.iri, keyOwner.id.href)), + ), + eq(follows.followingId, owner.id), + ), + }); + if (found == null) return null; + } else if (targetPost.visibility === "direct") { + const keyOwner = await ctx.getSignedKeyOwner(); + const keyOwnerId = keyOwner?.id; + if (keyOwnerId == null) return null; + const found = targetPost.mentions.some( + (m) => m.account.iri === keyOwnerId.href, + ); + if (!found) return null; + } + const quotePost = await db.query.posts.findFirst({ + where: and( + eq(posts.id, values.quoteId), + eq(posts.quoteTargetId, targetPost.id), + or(eq(posts.quoteState, "accepted"), isNull(posts.quoteState)), + ), + }); + if (quotePost == null) return null; + return new QuoteAuthorization({ + id: new URL(`${targetPost.iri}/quote_authorizations/${quotePost.id}`), + attribution: new URL(targetPost.account.iri), + interactingObject: new URL(quotePost.iri), + interactionTarget: new URL(targetPost.iri), + }); + }, +); + federation.setObjectDispatcher(Flag, "/reports/{id}", async (ctx, { id }) => { if (!isUuid(id)) return null; const report = await db.query.reports.findFirst({ diff --git a/src/federation/post.test.ts b/src/federation/post.test.ts index 6098234a..fd72ff11 100644 --- a/src/federation/post.test.ts +++ b/src/federation/post.test.ts @@ -598,4 +598,87 @@ describe("toObject", () => { expect(json).toMatchObject({ content: contentHtml }); }); + + it("emits FEP-044f quote and quote policy fields", async () => { + const account = await createAccount({ username: "quote-author" }); + const quotedPostId = crypto.randomUUID() as Uuid; + const quotePostId = crypto.randomUUID() as Uuid; + + await db.insert(posts).values([ + { + id: quotedPostId, + iri: "https://remote.test/objects/fep-quote-target", + type: "Note", + accountId: account.id as Uuid, + visibility: "public", + contentHtml: "

Quoted post

", + content: "Quoted post", + published: new Date(), + }, + { + id: quotePostId, + iri: `https://hollo.test/@quote-author/${quotePostId}`, + type: "Note", + accountId: account.id as Uuid, + quoteTargetId: quotedPostId, + quoteTargetIri: "https://remote.test/objects/fep-quote-target", + quoteState: "accepted", + visibility: "public", + contentHtml: "

My take

", + content: "My take", + published: new Date(), + }, + ]); + + const json = await getObjectJson(quotePostId); + + expect(json).toMatchObject({ + quote: "https://remote.test/objects/fep-quote-target", + quoteUrl: "https://remote.test/objects/fep-quote-target", + interactionPolicy: { + canQuote: { + automaticApproval: "as:Public", + }, + }, + }); + }); +}); + +describe("persistPost quotes", () => { + beforeEach(async () => { + await cleanDatabase(); + }); + + it("persists quote targets from the FEP-044f quote property", async () => { + const author = await seedRemoteAccount("quote-author"); + const quotedPostId = crypto.randomUUID() as Uuid; + const quotedPostIri = "https://remote.test/objects/quoted-with-fep"; + + await db.insert(posts).values({ + id: quotedPostId, + iri: quotedPostIri, + type: "Note", + accountId: author.id, + visibility: "public", + contentHtml: "

Quoted post

", + content: "Quoted post", + published: new Date(), + }); + + const persisted = await persistPost( + db, + new Note({ + id: new URL("https://remote.test/objects/quote-with-fep"), + attribution: createPerson(author), + quote: new URL(quotedPostIri), + to: PUBLIC_COLLECTION, + content: "

Quote post

", + }), + "https://hollo.test", + ); + + expect(persisted?.quoteTargetId).toBe(quotedPostId); + expect(persisted?.quoteTargetIri).toBe(quotedPostIri); + expect(persisted?.quoteState).toBe("accepted"); + }); }); diff --git a/src/federation/post.ts b/src/federation/post.ts index 2ceceeac..849a627a 100644 --- a/src/federation/post.ts +++ b/src/federation/post.ts @@ -11,6 +11,8 @@ import { Emoji, Hashtag, Image, + InteractionPolicy, + InteractionRule, isActor, LanguageString, Link, @@ -33,6 +35,8 @@ import { gte, inArray, isNotNull, + isNull, + or, sql, } from "drizzle-orm"; import type { PgDatabase } from "drizzle-orm/pg-core"; @@ -60,6 +64,7 @@ import { type PollOption, type PollVote, type Post, + type QuoteApprovalPolicy, pollOptions, polls, pollVotes, @@ -102,6 +107,27 @@ export function isPost(object?: vocab.Object | Link | null): object is ASPost { ); } +function getQuoteApprovalPolicy( + object: ASPost, + account: Account, +): QuoteApprovalPolicy { + const automaticApprovals = + object.interactionPolicy?.canQuote?.automaticApprovals ?? []; + if (automaticApprovals.length < 1) return "public"; + if ( + automaticApprovals.some((url) => url.href === vocab.PUBLIC_COLLECTION.href) + ) { + return "public"; + } + if ( + account.followersUrl != null && + automaticApprovals.some((url) => url.href === account.followersUrl) + ) { + return "followers"; + } + return "nobody"; +} + export async function persistPost( db: PgDatabase< PostgresJsQueryResultHKT, @@ -206,17 +232,24 @@ export async function persistPost( } } let quoteTargetId: Uuid | null = null; + let quoteTargetIri: string | null = null; + let quoteTargetAccountId: Uuid | null = null; + if (objectLink == null && object.quoteId != null) { + objectLink = object.quoteId; + } if (objectLink == null && object.quoteUrl != null) { objectLink = object.quoteUrl; } if (objectLink != null) { + quoteTargetIri = objectLink.href; const result = await db - .select({ id: posts.id }) + .select({ id: posts.id, accountId: posts.accountId }) .from(posts) .where(eq(posts.iri, objectLink.href)) .limit(1); if (result != null && result.length > 0) { quoteTargetId = result[0].id; + quoteTargetAccountId = result[0].accountId; logger.debug("The quote target is already persisted: {quoteTargetId}", { quoteTargetId, }); @@ -232,6 +265,7 @@ export async function persistPost( quoteTarget: quoteTargetObj, }); quoteTargetId = quoteTargetObj?.id ?? null; + quoteTargetAccountId = quoteTargetObj?.accountId ?? null; } } } @@ -260,6 +294,15 @@ export async function persistPost( replyTargetId, sharingId: null, quoteTargetId, + quoteTargetIri, + quoteState: + quoteTargetId == null + ? null + : quoteTargetAccountId === account.id || + object.quoteAuthorizationId != null + ? "accepted" + : "unauthorized", + quoteAuthorizationIri: object.quoteAuthorizationId?.href, visibility: to.has(vocab.PUBLIC_COLLECTION.href) ? "public" : cc.has(vocab.PUBLIC_COLLECTION.href) @@ -279,6 +322,7 @@ export async function persistPost( tags, emojis, sensitive: object.sensitive ?? false, + quoteApprovalPolicy: getQuoteApprovalPolicy(object, account), url: object.url instanceof Link ? object.url.href?.href : object.url?.href, sharesCount: shares?.totalItems ?? 0, likesCount: likes?.totalItems ?? 0, @@ -712,7 +756,12 @@ export async function updatePostStats( const quotesCount = db .select({ cnt: count() }) .from(posts) - .where(eq(posts.quoteTargetId, id)); + .where( + and( + eq(posts.quoteTargetId, id), + or(eq(posts.quoteState, "accepted"), isNull(posts.quoteState)), + ), + ); await db .update(posts) .set({ @@ -766,6 +815,7 @@ export function toObject( post.contentHtml, post.quoteTarget, ); + const quoteTargetIri = post.quoteTargetIri ?? post.quoteTarget?.iri; return new cls({ id: new URL(post.iri), attribution: new URL(post.account.iri), @@ -877,7 +927,15 @@ export function toObject( height: medium.height, }), ), - quoteUrl: post.quoteTarget == null ? null : new URL(post.quoteTarget.iri), + quote: quoteTargetIri == null ? null : new URL(quoteTargetIri), + quoteUrl: quoteTargetIri == null ? null : new URL(quoteTargetIri), + quoteAuthorization: + post.quoteAuthorizationIri == null + ? null + : new URL(post.quoteAuthorizationIri), + interactionPolicy: new InteractionPolicy({ + canQuote: getCanQuoteRule(post, ctx), + }), published: toTemporalInstant(post.published), url: post.url ? new URL(post.url) : null, updated: toTemporalInstant( @@ -898,6 +956,29 @@ export function toObject( }); } +function getCanQuoteRule( + post: Post & { account: Account & { owner: AccountOwner | null } }, + ctx: Context, +): InteractionRule { + const policy = + post.visibility === "private" || post.visibility === "direct" + ? "nobody" + : post.quoteApprovalPolicy; + if (policy === "public") { + return new InteractionRule({ + automaticApproval: vocab.PUBLIC_COLLECTION, + }); + } + if (policy === "followers" && post.account.owner != null) { + return new InteractionRule({ + automaticApproval: ctx.getFollowersUri(post.account.owner.handle), + }); + } + return new InteractionRule({ + automaticApproval: new URL(post.account.iri), + }); +} + function addQuoteInlineFallback( contentHtml: string | null, quoteTarget: Post | null, diff --git a/src/notification.ts b/src/notification.ts index 5fc02b1c..09f34e67 100644 --- a/src/notification.ts +++ b/src/notification.ts @@ -1,5 +1,5 @@ import { getLogger } from "@logtape/logtape"; -import { and, eq, inArray, sql } from "drizzle-orm"; +import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; import { db } from "./db"; import type { Account, AccountOwner, Poll, Post } from "./schema"; @@ -557,6 +557,7 @@ export async function createQuotedUpdateNotifications( where: and( eq(posts.accountId, author.id), eq(posts.quoteTargetId, editedPost.id), + or(eq(posts.quoteState, "accepted"), isNull(posts.quoteState)), ), }); diff --git a/src/schema.ts b/src/schema.ts index 8afadc26..cceafbc1 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -140,6 +140,25 @@ export const postVisibilityEnum = pgEnum("post_visibility", [ export type PostVisibility = (typeof postVisibilityEnum.enumValues)[number]; +export const quoteStateEnum = pgEnum("quote_state", [ + "pending", + "accepted", + "rejected", + "revoked", + "unauthorized", +]); + +export type QuoteState = (typeof quoteStateEnum.enumValues)[number]; + +export const quoteApprovalPolicyEnum = pgEnum("quote_approval_policy", [ + "public", + "followers", + "nobody", +]); + +export type QuoteApprovalPolicy = + (typeof quoteApprovalPolicyEnum.enumValues)[number]; + export const themeColorEnum = pgEnum("theme_color", [ "amber", "azure", @@ -439,6 +458,12 @@ export const posts = pgTable( quoteTargetId: uuid("quote_target_id") .$type() .references((): AnyPgColumn => posts.id, { onDelete: "set null" }), + quoteTargetIri: text("quote_target_iri"), + quoteState: quoteStateEnum("quote_state"), + quoteAuthorizationIri: text("quote_authorization_iri"), + quoteApprovalPolicy: quoteApprovalPolicyEnum("quote_approval_policy") + .notNull() + .default("public"), visibility: postVisibilityEnum("visibility").notNull(), summary: text("summary"), contentHtml: text("content_html"), From f05a8c14ff656057013f92b07c9fd07a03ae1c6a Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 16:02:53 +0900 Subject: [PATCH 03/30] Document FEP-044f quote support Add a changelog entry under the unreleased 0.9.0 section describing the new FEP-044f quote authorization and quote policy behavior. Assisted-by: Codex:gpt-5.5 --- CHANGES.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 7be18f64..b84bbba2 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,6 +6,39 @@ Version 0.9.0 To be released. + - Added FEP-044f quote authorization and policy support on top of the + Mastodon-compatible quote APIs. [[#457]] + + - Added persistent quote states for `pending`, `accepted`, `rejected`, + `revoked`, and `unauthorized` quotes, plus quote target and + authorization IRIs for federation. + - Hollo now enforces quote policy, quote target visibility, block + relationships, follower-only quote permissions, and direct-message + mention requirements when creating a quote through + `POST /api/v1/statuses`. + - Implemented `quote_approval_policy` handling on status creation and + editing, and added `PUT /api/v1/statuses/:id/interaction_policy` for + updating a status' quote policy after publication. + - `quotes_count` now includes only accepted quotes and is updated when + quotes are accepted, rejected, revoked, created, deleted, or received + through federation. + - `GET /api/v1/statuses/:id/quotes` now lists only accepted quotes, and + quote revocation keeps quote target metadata while removing the quote + from accepted quote lists and counts. + - Published outbound FEP-044f `quote`, `quoteAuthorization`, and + `interactionPolicy.canQuote` properties on ActivityPub objects, while + keeping the legacy `quoteUrl` property for compatibility. + - Parsed inbound FEP-044f `quote` targets and quote approval policies + from remote objects, including support for the legacy `quoteUrl` + property. + - Added federation handling for `QuoteRequest`, `Accept(QuoteRequest)`, + `Reject(QuoteRequest)`, and `Delete(QuoteAuthorization)`, allowing + Hollo to request quote authorization from remote servers, accept or + reject incoming quote requests, and revoke quotes when a remote quote + authorization is deleted. + - Added dereferenceable local `QuoteAuthorization` ActivityPub objects + for accepted quotes. + - Added an ActivityPub `quote-inline` fallback to the `content` of explicit quote posts created through the Mastodon API. Software that does not support quote posts can now still show the quoted post permalink, while @@ -18,6 +51,8 @@ To be released. - Upgraded Fedify to 2.2.0. +[#457]: https://github.com/fedify-dev/hollo/pull/457 + Version 0.8.1 ------------- From 8816c2eee8f183924e91b624b08e5c7b248480c2 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 16:33:33 +0900 Subject: [PATCH 04/30] Validate quote activity actors Require QuoteAuthorization Delete activities to come from the account that issued the authorization before revoking local quote state. Also reject QuoteRequest activities whose actor does not match the attributed quote object before persisting the quote or mutating counters. Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3152236408 Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3152236414 Assisted-by: Codex:gpt-5.5 --- src/federation/inbox.test.ts | 97 ++++++++++++++++++++++++++++++++++++ src/federation/inbox.ts | 19 ++++--- 2 files changed, 109 insertions(+), 7 deletions(-) diff --git a/src/federation/inbox.test.ts b/src/federation/inbox.test.ts index 56b2d195..2cb958d5 100644 --- a/src/federation/inbox.test.ts +++ b/src/federation/inbox.test.ts @@ -316,6 +316,48 @@ describe("quote request lifecycle", () => { expect(quoted?.quotesCount).toBe(0); }); + it("ignores quote authorization deletion from another actor", async () => { + expect.assertions(3); + + const seeded = await seedPendingQuote(); + const authorizationIri = `${seeded.quotedPostIri}/quote_authorizations/${seeded.quotePostId}`; + await db + .update(posts) + .set({ + quoteState: "accepted", + quoteAuthorizationIri: authorizationIri, + quotesCount: 1, + }) + .where(eq(posts.id, seeded.quotePostId)); + await db + .update(posts) + .set({ quotesCount: 1 }) + .where(eq(posts.id, seeded.quotedPostId)); + + await onQuoteAuthorizationDeleted( + ctx, + new Delete({ + actor: new URL("https://hollo.test/@quote-quoter"), + object: new QuoteAuthorization({ + id: new URL(authorizationIri), + attribution: new URL("https://hollo.test/@quote-author"), + interactingObject: new URL(seeded.quotePostIri), + interactionTarget: new URL(seeded.quotedPostIri), + }), + }), + ); + + const quote = await db.query.posts.findFirst({ + where: eq(posts.id, seeded.quotePostId), + }); + const quoted = await db.query.posts.findFirst({ + where: eq(posts.id, seeded.quotedPostId), + }); + expect(quote?.quoteState).toBe("accepted"); + expect(quote?.quoteAuthorizationIri).toBe(authorizationIri); + expect(quoted?.quotesCount).toBe(1); + }); + it("accepts an allowed QuoteRequest for a local post", async () => { expect.assertions(4); @@ -371,4 +413,59 @@ describe("quote request lifecycle", () => { expect(quoted?.quotesCount).toBe(1); expect(sendActivity).toHaveBeenCalledOnce(); }); + + it("ignores a QuoteRequest whose actor does not match the quote", async () => { + expect.assertions(3); + + const author = await createAccount({ username: "quote-author" }); + const quotedPostId = crypto.randomUUID() as Uuid; + const quotedPostIri = `https://hollo.test/@quote-author/${quotedPostId}`; + const quotePostIri = "https://remote.test/@quoter/quote-1"; + const sendActivity = vi.fn(async () => undefined); + const requestCtx = { + ...ctx, + sendActivity, + } as unknown as InboxContext; + + await db.insert(posts).values({ + id: quotedPostId, + iri: quotedPostIri, + type: "Note", + accountId: author.id as Uuid, + visibility: "public", + quoteApprovalPolicy: "public", + contentHtml: "

Quoted post

", + content: "Quoted post", + published: new Date(), + }); + + const request = new QuoteRequest({ + actor: new URL("https://remote.test/@attacker"), + object: new URL(quotedPostIri), + instrument: new Note({ + id: new URL(quotePostIri), + attribution: new Person({ + id: new URL("https://remote.test/@quoter"), + name: "quoter", + preferredUsername: "quoter", + inbox: new URL("https://remote.test/@quoter/inbox"), + }), + quote: new URL(quotedPostIri), + to: new URL("https://www.w3.org/ns/activitystreams#Public"), + content: "

Remote quote

", + }), + }); + + await onQuoteRequested(requestCtx, request); + + const quote = await db.query.posts.findFirst({ + where: eq(posts.iri, quotePostIri), + }); + const quoted = await db.query.posts.findFirst({ + where: eq(posts.id, quotedPostId), + }); + expect(quote).toBeUndefined(); + expect(quoted?.quotesCount).toBe(0); + expect(sendActivity).not.toHaveBeenCalled(); + }); }); diff --git a/src/federation/inbox.ts b/src/federation/inbox.ts index 609153f6..ddf4a489 100644 --- a/src/federation/inbox.ts +++ b/src/federation/inbox.ts @@ -466,11 +466,16 @@ export async function onQuoteAuthorizationDeleted( authorizationIri == null ? quoteIri == null ? null - : await db.query.posts.findFirst({ where: eq(posts.iri, quoteIri) }) + : await db.query.posts.findFirst({ + where: eq(posts.iri, quoteIri), + with: { quoteTarget: { with: { account: true } } }, + }) : await db.query.posts.findFirst({ where: eq(posts.quoteAuthorizationIri, authorizationIri), + with: { quoteTarget: { with: { account: true } } }, }); if (quote == null) return; + if (del.actorId?.href !== quote.quoteTarget?.account.iri) return; await db.transaction(async (tx) => { await tx .update(posts) @@ -530,6 +535,12 @@ export async function onQuoteRequested( if (target?.account.owner == null) return; const instrument = await request.getInstrument({ crossOrigin: "trust" }); if (!isPost(instrument)) return; + if ( + request.actorId != null && + request.actorId.href !== instrument.attributionId?.href + ) { + return; + } const persistedQuote = await persistPost( db, instrument, @@ -564,12 +575,6 @@ export async function onQuoteRequested( } }); - if ( - request.actorId != null && - request.actorId.href !== persistedQuote.account.iri - ) { - return; - } const recipient = { id: new URL(persistedQuote.account.iri), inboxId: new URL(persistedQuote.account.inboxUrl), From a447a6916c60745801c33ac7f0327b10738bd276 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 16:36:28 +0900 Subject: [PATCH 05/30] Report follower quote approval Load the current viewer's approved follow relationship while serializing statuses so followers-only quote policies report current_user as automatic for approved followers instead of denied. Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3152236419 Assisted-by: Codex:gpt-5.5 --- src/api/v1/statuses.test.ts | 28 ++++++++++++++++ src/entities/status.ts | 65 +++++++++++++++++++++++++++++++------ 2 files changed, 83 insertions(+), 10 deletions(-) diff --git a/src/api/v1/statuses.test.ts b/src/api/v1/statuses.test.ts index cb8bc374..ce35894a 100644 --- a/src/api/v1/statuses.test.ts +++ b/src/api/v1/statuses.test.ts @@ -313,6 +313,34 @@ describe.sequential("/api/v1/statuses quotes", () => { expect(updated.quote_approval.manual).toEqual([]); }); + it("reports quote approval as automatic for approved followers", async () => { + expect.assertions(4); + + const quotedResponse = await createStatus(authorToken, { + status: "Followers can quote this", + quote_approval_policy: "followers", + }); + expect(quotedResponse.status).toBe(200); + const quoted = await quotedResponse.json(); + + await db.insert(follows).values({ + iri: `https://hollo.test/follows/${crypto.randomUUID()}`, + followingId: author.id, + followerId: quoter.id, + approved: new Date(), + }); + + const response = await app.request(`/api/v1/statuses/${quoted.id}`, { + headers: { + authorization: bearerAuthorization(quoterToken), + }, + }); + expect(response.status).toBe(200); + const status = await response.json(); + expect(status.quote_approval.automatic).toEqual(["followers"]); + expect(status.quote_approval.current_user).toBe("automatic"); + }); + it("returns revoked quote state when a quote is revoked", async () => { expect.assertions(7); diff --git a/src/entities/status.ts b/src/entities/status.ts index 1fe8228f..8236c3b2 100644 --- a/src/entities/status.ts +++ b/src/entities/status.ts @@ -1,4 +1,4 @@ -import { eq, sql } from "drizzle-orm"; +import { and, eq, isNotNull, sql } from "drizzle-orm"; import { stripQuoteInlineFallbacks } from "../html"; import type { PreviewCard } from "../previewcard"; @@ -8,6 +8,8 @@ import { type Application, type Bookmark, bookmarks, + type Follow, + follows, type Like, likes, type Medium, @@ -31,6 +33,11 @@ import { serializeEmojis, serializeReactions } from "./emoji"; import { serializeMedium } from "./medium"; import { serializePoll } from "./poll"; +type StatusAccount = Account & { + successor: Account | null; + followers?: Follow[]; +}; + function getEffectiveQuoteState( post: Post & { quoteTarget: Post | null }, ): QuoteState | "deleted" | null { @@ -44,6 +51,7 @@ function serializeQuoteApproval( policy: QuoteApprovalPolicy, currentAccountOwner: { id: string } | undefined | null, post: Pick, + viewerIsApprovedFollower: boolean, ) { const effectivePolicy = post.visibility === "private" || post.visibility === "direct" @@ -63,26 +71,52 @@ function serializeQuoteApproval( : { current_user: currentAccountOwner.id === post.accountId || - effectivePolicy === "public" + effectivePolicy === "public" || + (effectivePolicy === "followers" && viewerIsApprovedFollower) ? "automatic" : "denied", }), }; } +function getViewerFollowerRelation(ownerId: Uuid | undefined | null) { + return { + where: + ownerId == null + ? sql`false` + : and(eq(follows.followerId, ownerId), isNotNull(follows.approved)), + }; +} + export function getPostRelations(ownerId: Uuid | undefined | null) { return { - account: { with: { owner: true, successor: true } }, + account: { + with: { + owner: true, + successor: true, + followers: getViewerFollowerRelation(ownerId), + }, + }, application: true, replyTarget: true, sharing: { with: { - account: { with: { successor: true } }, + account: { + with: { + successor: true, + followers: getViewerFollowerRelation(ownerId), + }, + }, application: true, replyTarget: true, quoteTarget: { with: { - account: { with: { successor: true } }, + account: { + with: { + successor: true, + followers: getViewerFollowerRelation(ownerId), + }, + }, application: true, replyTarget: true, media: true, @@ -149,7 +183,12 @@ export function getPostRelations(ownerId: Uuid | undefined | null) { }, quoteTarget: { with: { - account: { with: { successor: true } }, + account: { + with: { + successor: true, + followers: getViewerFollowerRelation(ownerId), + }, + }, application: true, replyTarget: true, media: true, @@ -210,17 +249,17 @@ export function getPostRelations(ownerId: Uuid | undefined | null) { export function serializePost( post: Post & { - account: Account & { successor: Account | null }; + account: StatusAccount; application: Application | null; replyTarget: Post | null; sharing: | (Post & { - account: Account & { successor: Account | null }; + account: StatusAccount; application: Application | null; replyTarget: Post | null; quoteTarget: | (Post & { - account: Account & { successor: Account | null }; + account: StatusAccount; application: Application | null; replyTarget: Post | null; media: Medium[]; @@ -261,7 +300,7 @@ export function serializePost( | null; quoteTarget: | (Post & { - account: Account & { successor: Account | null }; + account: StatusAccount; application: Application | null; replyTarget: Post | null; media: Medium[]; @@ -304,6 +343,11 @@ export function serializePost( const quoteState = getEffectiveQuoteState(post); const quoteIsDisplayable = quoteState === "accepted" && post.quoteTarget != null; + const viewerIsApprovedFollower = + currentAccountOwner != null && + post.account.followers?.some( + (follow) => follow.followerId === currentAccountOwner.id, + ) === true; return { id: post.id, created_at: post.published ?? post.updated, @@ -371,6 +415,7 @@ export function serializePost( post.quoteApprovalPolicy, currentAccountOwner, post, + viewerIsApprovedFollower, ), application: post.application == null From 3183bef73d279daf614c5a94680fdd50fd8a3239 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 17:01:56 +0900 Subject: [PATCH 06/30] Validate quote request responses Accept and Reject responses for quote requests now require the responder to match the quoted post author before mutating quote state. Responses that use the stored QuoteRequest IRI form are also resolved from the local pending quote, so peers do not have to embed the QuoteRequest object. Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3152375370 Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3152375380 Assisted-by: Codex:gpt-5.5 --- src/federation/inbox.test.ts | 77 ++++++++++++++++++++++++++++++ src/federation/inbox.ts | 90 ++++++++++++++++++++++++++---------- src/federation/index.ts | 16 ++----- 3 files changed, 147 insertions(+), 36 deletions(-) diff --git a/src/federation/inbox.test.ts b/src/federation/inbox.test.ts index 2cb958d5..83753cc2 100644 --- a/src/federation/inbox.test.ts +++ b/src/federation/inbox.test.ts @@ -251,6 +251,62 @@ describe("quote request lifecycle", () => { expect(quoted?.quotesCount).toBe(1); }); + it("marks a pending quote accepted from Accept", async () => { + expect.assertions(3); + + const seeded = await seedPendingQuote(); + const authorizationIri = `${seeded.quotedPostIri}/quote_authorizations/${seeded.quotePostId}`; + const accept = new Accept({ + actor: new URL("https://hollo.test/@quote-author"), + object: new URL(`${seeded.quotePostIri}#quote-request`), + result: new URL(authorizationIri), + }); + + await onQuoteRequestAccepted(ctx, accept); + + const quote = await db.query.posts.findFirst({ + where: eq(posts.id, seeded.quotePostId), + }); + const quoted = await db.query.posts.findFirst({ + where: eq(posts.id, seeded.quotedPostId), + }); + expect(quote?.quoteState).toBe("accepted"); + expect(quote?.quoteAuthorizationIri).toBe(authorizationIri); + expect(quoted?.quotesCount).toBe(1); + }); + + it("ignores quote request responses from another actor", async () => { + expect.assertions(3); + + const seeded = await seedPendingQuote(); + const authorizationIri = `${seeded.quotedPostIri}/quote_authorizations/${seeded.quotePostId}`; + const accept = new Accept({ + actor: new URL("https://hollo.test/@quote-quoter"), + object: new QuoteRequest({ + object: new URL(seeded.quotedPostIri), + instrument: new URL(seeded.quotePostIri), + }), + result: new URL(authorizationIri), + }); + const reject = new Reject({ + actor: new URL("https://hollo.test/@quote-quoter"), + object: new URL(`${seeded.quotePostIri}#quote-request`), + }); + + await onQuoteRequestAccepted(ctx, accept); + await onQuoteRequestRejected(ctx, reject); + + const quote = await db.query.posts.findFirst({ + where: eq(posts.id, seeded.quotePostId), + }); + const quoted = await db.query.posts.findFirst({ + where: eq(posts.id, seeded.quotedPostId), + }); + expect(quote?.quoteState).toBe("pending"); + expect(quote?.quoteAuthorizationIri).toBeNull(); + expect(quoted?.quotesCount).toBe(0); + }); + it("marks a pending quote rejected from Reject", async () => { expect.assertions(2); @@ -275,6 +331,27 @@ describe("quote request lifecycle", () => { expect(quoted?.quotesCount).toBe(0); }); + it("marks a pending quote rejected from Reject", async () => { + expect.assertions(2); + + const seeded = await seedPendingQuote(); + const reject = new Reject({ + actor: new URL("https://hollo.test/@quote-author"), + object: new URL(`${seeded.quotePostIri}#quote-request`), + }); + + await onQuoteRequestRejected(ctx, reject); + + const quote = await db.query.posts.findFirst({ + where: eq(posts.id, seeded.quotePostId), + }); + const quoted = await db.query.posts.findFirst({ + where: eq(posts.id, seeded.quotedPostId), + }); + expect(quote?.quoteState).toBe("rejected"); + expect(quoted?.quotesCount).toBe(0); + }); + it("marks an accepted quote revoked when its authorization is deleted", async () => { expect.assertions(2); diff --git a/src/federation/inbox.ts b/src/federation/inbox.ts index ddf4a489..3e7f927c 100644 --- a/src/federation/inbox.ts +++ b/src/federation/inbox.ts @@ -380,39 +380,74 @@ export async function onFollowRejected( } } -async function getQuoteRequestFromActivity( +type QuoteRequestReference = { + quoteIri: string; + targetIri: string | null; +}; + +function getQuoteIriFromQuoteRequestId(requestId: URL): string { + const quoteIri = new URL(requestId.href); + if (quoteIri.hash === "#quote-request") quoteIri.hash = ""; + return quoteIri.href; +} + +async function getQuoteRequestReferenceFromActivity( activity: Accept | Reject, -): Promise { - const object = await activity.getObject({ crossOrigin: "trust" }); - return object instanceof QuoteRequest ? object : null; +): Promise { + const object = await activity.getObject({ + crossOrigin: "trust", + suppressError: true, + }); + if (object instanceof QuoteRequest) { + const quoteIri = object.instrumentId?.href; + if (quoteIri == null) return null; + return { + quoteIri, + targetIri: object.objectId?.href ?? null, + }; + } + if (activity.objectId == null) return null; + return { + quoteIri: getQuoteIriFromQuoteRequestId(activity.objectId), + targetIri: null, + }; } async function updateQuoteRequestState( - request: QuoteRequest, + request: QuoteRequestReference, + responderIri: string | null, state: "accepted" | "rejected", quoteAuthorizationIri: string | null, -): Promise { - const quoteIri = request.instrumentId?.href; - if (quoteIri == null) return; +): Promise { const quote = await db.query.posts.findFirst({ - where: eq(posts.iri, quoteIri), + where: eq(posts.iri, request.quoteIri), + with: { quoteTarget: { with: { account: true } } }, }); - if (quote == null) return; + if (quote == null) return false; + if (quote.quoteState !== "pending") return false; const target = - request.objectId == null - ? null + request.targetIri == null + ? quote.quoteTarget : await db.query.posts.findFirst({ - where: eq(posts.iri, request.objectId.href), + where: eq(posts.iri, request.targetIri), + with: { account: true }, }); + if (target == null) return false; + if (responderIri == null || responderIri !== target.account.iri) { + return false; + } + if (quote.quoteTargetIri == null) return false; + if (request.targetIri != null && quote.quoteTargetIri !== request.targetIri) { + return false; + } await db.transaction(async (tx) => { await tx .update(posts) .set({ quoteState: state, quoteAuthorizationIri, - quoteTargetId: target?.id ?? quote.quoteTargetId, - quoteTargetIri: - request.objectId?.href ?? quote.quoteTargetIri ?? target?.iri ?? null, + quoteTargetId: target.id, + quoteTargetIri: request.targetIri ?? quote.quoteTargetIri ?? target.iri, updated: new Date(), }) .where(eq(posts.id, quote.id)); @@ -427,16 +462,18 @@ async function updateQuoteRequestState( .where(eq(posts.id, target.id)); } }); + return true; } export async function onQuoteRequestAccepted( _ctx: InboxContext, accept: Accept, -): Promise { - const request = await getQuoteRequestFromActivity(accept); - if (request == null) return; - await updateQuoteRequestState( +): Promise { + const request = await getQuoteRequestReferenceFromActivity(accept); + if (request == null) return false; + return await updateQuoteRequestState( request, + accept.actorId?.href ?? null, "accepted", accept.resultId?.href ?? null, ); @@ -445,10 +482,15 @@ export async function onQuoteRequestAccepted( export async function onQuoteRequestRejected( _ctx: InboxContext, reject: Reject, -): Promise { - const request = await getQuoteRequestFromActivity(reject); - if (request == null) return; - await updateQuoteRequestState(request, "rejected", null); +): Promise { + const request = await getQuoteRequestReferenceFromActivity(reject); + if (request == null) return false; + return await updateQuoteRequestState( + request, + reject.actorId?.href ?? null, + "rejected", + null, + ); } export async function onQuoteAuthorizationDeleted( diff --git a/src/federation/index.ts b/src/federation/index.ts index 02dec87a..8c2ee4eb 100644 --- a/src/federation/index.ts +++ b/src/federation/index.ts @@ -87,20 +87,12 @@ federation }) .on(Follow, onFollowed) .on(Accept, async (ctx, accept) => { - const object = await accept.getObject({ crossOrigin: "trust" }); - if (object instanceof QuoteRequest) { - await onQuoteRequestAccepted(ctx, accept); - } else { - await onFollowAccepted(ctx, accept); - } + if (await onQuoteRequestAccepted(ctx, accept)) return; + await onFollowAccepted(ctx, accept); }) .on(Reject, async (ctx, reject) => { - const object = await reject.getObject({ crossOrigin: "trust" }); - if (object instanceof QuoteRequest) { - await onQuoteRequestRejected(ctx, reject); - } else { - await onFollowRejected(ctx, reject); - } + if (await onQuoteRequestRejected(ctx, reject)) return; + await onFollowRejected(ctx, reject); }) .on(QuoteRequest, onQuoteRequested) .on(Create, async (ctx, create) => { From 0c79710d2a240c85a065f9caf5ab33c82650fe87 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 17:17:21 +0900 Subject: [PATCH 07/30] Validate quote request targets Ignore inbound QuoteRequest activities when the persisted quote instrument quotes a different object than the requested local target, preventing invalid state changes, authorization responses, and counter updates. Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3152492478 Assisted-by: Codex:gpt-5.5 --- src/federation/inbox.test.ts | 71 ++++++++++++++++++++++++++++++++++++ src/federation/inbox.ts | 1 + 2 files changed, 72 insertions(+) diff --git a/src/federation/inbox.test.ts b/src/federation/inbox.test.ts index 83753cc2..eece4564 100644 --- a/src/federation/inbox.test.ts +++ b/src/federation/inbox.test.ts @@ -545,4 +545,75 @@ describe("quote request lifecycle", () => { expect(quoted?.quotesCount).toBe(0); expect(sendActivity).not.toHaveBeenCalled(); }); + + it("ignores a QuoteRequest whose quote targets another object", async () => { + expect.assertions(4); + + const author = await createAccount({ username: "quote-author" }); + const quotedPostId = crypto.randomUUID() as Uuid; + const otherPostId = crypto.randomUUID() as Uuid; + const quotedPostIri = `https://hollo.test/@quote-author/${quotedPostId}`; + const otherPostIri = `https://hollo.test/@quote-author/${otherPostId}`; + const quotePostIri = "https://remote.test/@quoter/quote-1"; + const sendActivity = vi.fn(async () => undefined); + const requestCtx = { + ...ctx, + sendActivity, + } as unknown as InboxContext; + + await db.insert(posts).values([ + { + id: quotedPostId, + iri: quotedPostIri, + type: "Note", + accountId: author.id as Uuid, + visibility: "public", + quoteApprovalPolicy: "public", + contentHtml: "

Quoted post

", + content: "Quoted post", + published: new Date(), + }, + { + id: otherPostId, + iri: otherPostIri, + type: "Note", + accountId: author.id as Uuid, + visibility: "public", + quoteApprovalPolicy: "public", + contentHtml: "

Other post

", + content: "Other post", + published: new Date(), + }, + ]); + + const request = new QuoteRequest({ + actor: new URL("https://remote.test/@quoter"), + object: new URL(quotedPostIri), + instrument: new Note({ + id: new URL(quotePostIri), + attribution: new Person({ + id: new URL("https://remote.test/@quoter"), + name: "quoter", + preferredUsername: "quoter", + inbox: new URL("https://remote.test/@quoter/inbox"), + }), + quote: new URL(otherPostIri), + to: new URL("https://www.w3.org/ns/activitystreams#Public"), + content: "

Remote quote

", + }), + }); + + await onQuoteRequested(requestCtx, request); + + const quote = await db.query.posts.findFirst({ + where: eq(posts.iri, quotePostIri), + }); + const quoted = await db.query.posts.findFirst({ + where: eq(posts.id, quotedPostId), + }); + expect(quote?.quoteTargetIri).toBe(otherPostIri); + expect(quote?.quoteState).toBe("unauthorized"); + expect(quoted?.quotesCount).toBe(0); + expect(sendActivity).not.toHaveBeenCalled(); + }); }); diff --git a/src/federation/inbox.ts b/src/federation/inbox.ts index 3e7f927c..601b11f9 100644 --- a/src/federation/inbox.ts +++ b/src/federation/inbox.ts @@ -590,6 +590,7 @@ export async function onQuoteRequested( getPersistOptions(ctx), ); if (persistedQuote == null) return; + if (persistedQuote.quoteTargetIri !== target.iri) return; const accepted = await canAutomaticallyAcceptQuoteRequest( target, From bcdc4391213636c92a47f52a7a748d911dbc4270 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 17:19:07 +0900 Subject: [PATCH 08/30] Respect manual quote policies Do not persist remote manual-only canQuote interaction policies as public. When Hollo cannot represent a remote manual approval requirement locally, store it conservatively as nobody while preserving the no-policy public default and followers automatic approvals. Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3152492483 Assisted-by: Codex:gpt-5.5 --- src/federation/post.test.ts | 63 +++++++++++++++++++++++++++++++++++++ src/federation/post.ts | 7 +++-- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/federation/post.test.ts b/src/federation/post.test.ts index fd72ff11..f87b97cb 100644 --- a/src/federation/post.test.ts +++ b/src/federation/post.test.ts @@ -1,6 +1,8 @@ import type { Context, InboxContext } from "@fedify/fedify"; import { Announce, + InteractionPolicy, + InteractionRule, Note, Person, PUBLIC_COLLECTION, @@ -681,4 +683,65 @@ describe("persistPost quotes", () => { expect(persisted?.quoteTargetIri).toBe(quotedPostIri); expect(persisted?.quoteState).toBe("accepted"); }); + + it("defaults quote approval to public when no interaction policy exists", async () => { + const author = await seedRemoteAccount("quote-author"); + + const persisted = await persistPost( + db, + new Note({ + id: new URL("https://remote.test/objects/default-quote-policy"), + attribution: createPerson(author), + to: PUBLIC_COLLECTION, + content: "

Default quote policy

", + }), + "https://hollo.test", + ); + + expect(persisted?.quoteApprovalPolicy).toBe("public"); + }); + + it("does not treat manual-only quote approval as public", async () => { + const author = await seedRemoteAccount("quote-author"); + + const persisted = await persistPost( + db, + new Note({ + id: new URL("https://remote.test/objects/manual-quote-policy"), + attribution: createPerson(author), + interactionPolicy: new InteractionPolicy({ + canQuote: new InteractionRule({ + manualApproval: PUBLIC_COLLECTION, + }), + }), + to: PUBLIC_COLLECTION, + content: "

Manual quote policy

", + }), + "https://hollo.test", + ); + + expect(persisted?.quoteApprovalPolicy).toBe("nobody"); + }); + + it("persists followers-only automatic quote approval", async () => { + const author = await seedRemoteAccount("quote-author"); + + const persisted = await persistPost( + db, + new Note({ + id: new URL("https://remote.test/objects/followers-quote-policy"), + attribution: createPerson(author), + interactionPolicy: new InteractionPolicy({ + canQuote: new InteractionRule({ + automaticApproval: new URL(author.followersUrl!), + }), + }), + to: PUBLIC_COLLECTION, + content: "

Followers quote policy

", + }), + "https://hollo.test", + ); + + expect(persisted?.quoteApprovalPolicy).toBe("followers"); + }); }); diff --git a/src/federation/post.ts b/src/federation/post.ts index 849a627a..17405caf 100644 --- a/src/federation/post.ts +++ b/src/federation/post.ts @@ -111,9 +111,10 @@ function getQuoteApprovalPolicy( object: ASPost, account: Account, ): QuoteApprovalPolicy { - const automaticApprovals = - object.interactionPolicy?.canQuote?.automaticApprovals ?? []; - if (automaticApprovals.length < 1) return "public"; + const canQuote = object.interactionPolicy?.canQuote; + if (canQuote == null) return "public"; + const automaticApprovals = canQuote.automaticApprovals; + if (automaticApprovals.length < 1) return "nobody"; if ( automaticApprovals.some((url) => url.href === vocab.PUBLIC_COLLECTION.href) ) { From cc06e9e29dd5502bebc918c59464dfe3cd12d2bc Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 17:38:34 +0900 Subject: [PATCH 09/30] Allow quoting private posts by followers Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3152615217 Assisted-by: Codex:gpt-5.5 --- src/api/v1/statuses.test.ts | 28 ++++++++++++++++++++++++++++ src/api/v1/statuses.ts | 6 ------ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/api/v1/statuses.test.ts b/src/api/v1/statuses.test.ts index ce35894a..eff33610 100644 --- a/src/api/v1/statuses.test.ts +++ b/src/api/v1/statuses.test.ts @@ -341,6 +341,34 @@ describe.sequential("/api/v1/statuses quotes", () => { expect(status.quote_approval.current_user).toBe("automatic"); }); + it("allows approved followers to quote private statuses", async () => { + expect.assertions(4); + + await db.insert(follows).values({ + iri: `https://hollo.test/follows/${crypto.randomUUID()}`, + followingId: author.id, + followerId: quoter.id, + approved: new Date(), + }); + + const quotedResponse = await createStatus(authorToken, { + status: "Followers can quote this private status", + visibility: "private", + }); + expect(quotedResponse.status).toBe(200); + const quoted = await quotedResponse.json(); + + const quoteResponse = await createStatus(quoterToken, { + status: "Quoting this private status", + quoted_status_id: quoted.id, + visibility: "public", + }); + expect(quoteResponse.status).toBe(200); + const quote = await quoteResponse.json(); + expect(quote.visibility).toBe("private"); + expect(quote.quote.state).toBe("accepted"); + }); + it("returns revoked quote state when a quote is revoked", async () => { expect.assertions(7); diff --git a/src/api/v1/statuses.ts b/src/api/v1/statuses.ts index 4665b127..4438dbac 100644 --- a/src/api/v1/statuses.ts +++ b/src/api/v1/statuses.ts @@ -149,9 +149,7 @@ function buildMuteAndBlockConditions(viewerAccountId: Uuid | null | undefined) { function normalizeQuoteApprovalPolicy( policy: QuoteApprovalPolicy | null | undefined, - visibility: "public" | "unlisted" | "private" | "direct", ): QuoteApprovalPolicy { - if (visibility === "private" || visibility === "direct") return "nobody"; return policy ?? "public"; } @@ -235,7 +233,6 @@ async function validateQuoteTarget( if (quoteTarget.accountId !== owner.id) { const policy = normalizeQuoteApprovalPolicy( quoteTarget.quoteApprovalPolicy, - quoteTarget.visibility, ); if (policy === "nobody") { return { ok: false, status: 422, error: "Quote target is not quotable" }; @@ -376,7 +373,6 @@ app.post("/", tokenRequired, scopeRequired(["write:statuses"]), async (c) => { } const quoteApprovalPolicy = normalizeQuoteApprovalPolicy( data.quote_approval_policy, - effectiveVisibility, ); let quoteState: "accepted" | "pending" | null = null; if (quoteTarget != null) { @@ -596,7 +592,6 @@ app.put("/:id", tokenRequired, scopeRequired(["write:statuses"]), async (c) => { } const quoteApprovalPolicy = normalizeQuoteApprovalPolicy( data.quote_approval_policy ?? existingPost.quoteApprovalPolicy, - existingPost.visibility, ); await db.transaction(async (tx) => { const result = await tx @@ -683,7 +678,6 @@ app.put( const quoteApprovalPolicy = normalizeQuoteApprovalPolicy( result.data.quote_approval_policy, - post.visibility, ); await db .update(posts) From 2fdd597fabe8cc664a0997d640e062c84dd13b51 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 17:41:55 +0900 Subject: [PATCH 10/30] Federate quote authorization revocations Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3152615222 Assisted-by: Codex:gpt-5.5 --- src/api/v1/statuses.test.ts | 101 +++++++++++++++++++++++++++++++++++- src/api/v1/statuses.ts | 34 ++++++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/src/api/v1/statuses.test.ts b/src/api/v1/statuses.test.ts index eff33610..c50a71fb 100644 --- a/src/api/v1/statuses.test.ts +++ b/src/api/v1/statuses.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { cleanDatabase } from "../../../tests/helpers"; import { @@ -10,7 +10,7 @@ import { } from "../../../tests/helpers/oauth"; import db from "../../db"; import app from "../../index"; -import { follows, posts } from "../../schema"; +import { accounts, follows, instances, posts } from "../../schema"; import { uuidv7 } from "../../uuid"; describe.sequential("/api/v1/accounts/verify_credentials", () => { @@ -411,6 +411,103 @@ describe.sequential("/api/v1/statuses quotes", () => { const quotedAgain = await quotedAgainResponse.json(); expect(quotedAgain.quotes_count).toBe(0); }); + + it("sends a QuoteAuthorization deletion when revoking a remote quote", async () => { + expect.assertions(7); + + const remoteAccountId = uuidv7(); + const quotedPostId = uuidv7(); + const quotingPostId = uuidv7(); + const quotedPostIri = `https://hollo.test/@quote-author/${quotedPostId}`; + const quotingPostIri = `https://remote.test/@quoter/${quotingPostId}`; + const quoteAuthorizationIri = `${quotedPostIri}/quote_authorizations/${quotingPostId}`; + + await db.insert(instances).values({ host: "remote.test" }); + await db.insert(accounts).values({ + id: remoteAccountId, + iri: "https://remote.test/@quoter", + type: "Person", + name: "Remote quoter", + handle: "@quoter@remote.test", + bioHtml: "", + protected: false, + inboxUrl: "https://remote.test/@quoter/inbox", + sharedInboxUrl: "https://remote.test/inbox", + instanceHost: "remote.test", + }); + await db.insert(posts).values([ + { + id: quotedPostId, + iri: quotedPostIri, + type: "Note", + accountId: author.id, + visibility: "public", + content: "Quoted post", + contentHtml: "

Quoted post

\n", + url: quotedPostIri, + quotesCount: 1, + published: new Date(), + }, + { + id: quotingPostId, + iri: quotingPostIri, + type: "Note", + accountId: remoteAccountId, + quoteTargetId: quotedPostId, + quoteTargetIri: quotedPostIri, + quoteState: "accepted", + quoteAuthorizationIri, + visibility: "public", + content: "Remote quote", + contentHtml: "

Remote quote

\n", + url: quotingPostIri, + published: new Date(), + }, + ]); + + const fetch = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response(null, { status: 202 })); + try { + const revokeResponse = await app.request( + `/api/v1/statuses/${quotedPostId}/quotes/${quotingPostId}/revoke`, + { + method: "POST", + headers: { + authorization: bearerAuthorization(authorToken), + }, + }, + ); + + expect(revokeResponse.status).toBe(200); + const revoked = await revokeResponse.json(); + expect(revoked.quote.state).toBe("revoked"); + + const isRemoteInboxCall = ([input]: [ + string | URL | Request, + RequestInit?, + ]) => { + const url = input instanceof Request ? input.url : input.toString(); + return url === "https://remote.test/inbox"; + }; + await vi.waitFor(() => { + if (!fetch.mock.calls.some(isRemoteInboxCall)) { + throw new Error("Quote authorization deletion was not sent"); + } + }); + const matchingCall = fetch.mock.calls.find(isRemoteInboxCall); + expect(matchingCall).toBeDefined(); + const request = matchingCall?.[0]; + expect(request).toBeInstanceOf(Request); + const activity = + request instanceof Request ? await request.clone().json() : null; + expect(activity.type).toBe("Delete"); + expect(activity.object.type).toBe("QuoteAuthorization"); + expect(activity.object.id).toBe(quoteAuthorizationIri); + } finally { + fetch.mockRestore(); + } + }); }); describe.sequential("/api/v1/statuses visibility", () => { diff --git a/src/api/v1/statuses.ts b/src/api/v1/statuses.ts index 4438dbac..9182f4f3 100644 --- a/src/api/v1/statuses.ts +++ b/src/api/v1/statuses.ts @@ -1743,10 +1743,12 @@ app.post( const quotingPost = await db.query.posts.findFirst({ where: and(eq(posts.id, quotingStatusId), eq(posts.quoteTargetId, id)), + with: { account: { with: { owner: true } } }, }); if (quotingPost == null) { return c.json({ error: "Record not found" }, 404); } + const quoteAuthorizationIri = quotingPost.quoteAuthorizationIri; await db.transaction(async (tx) => { await tx @@ -1771,6 +1773,38 @@ app.post( } }); + if (quotingPost.account.owner == null && quoteAuthorizationIri != null) { + const fedCtx = federation.createContext(c.req.raw, undefined); + await fedCtx.sendActivity( + { username: owner.handle }, + { + id: new URL(quotingPost.account.iri), + inboxId: new URL(quotingPost.account.inboxUrl), + endpoints: + quotingPost.account.sharedInboxUrl == null + ? null + : { + sharedInbox: new URL(quotingPost.account.sharedInboxUrl), + }, + }, + new vocab.Delete({ + id: new URL("#delete", quoteAuthorizationIri), + actor: new URL(owner.account.iri), + object: new vocab.QuoteAuthorization({ + id: new URL(quoteAuthorizationIri), + attribution: new URL(owner.account.iri), + interactingObject: new URL(quotingPost.iri), + interactionTarget: new URL(targetPost.iri), + }), + }), + { + orderingKey: getPostOrderingKey(quotingPost.iri), + preferSharedInbox: true, + excludeBaseUris: [new URL(c.req.url)], + }, + ); + } + const updatedPost = await db.query.posts.findFirst({ where: eq(posts.id, quotingStatusId), with: getPostRelations(owner.id), From 47105dbb56ec1c436aeac4f7e03687f5f6d6754e Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 18:01:49 +0900 Subject: [PATCH 11/30] Keep quote requests idempotent Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3152778249 Assisted-by: Codex:gpt-5.5 --- src/federation/inbox.test.ts | 59 ++++++++++++++++++++++++++++++++++++ src/federation/inbox.ts | 19 +++++++++--- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/federation/inbox.test.ts b/src/federation/inbox.test.ts index eece4564..39855b68 100644 --- a/src/federation/inbox.test.ts +++ b/src/federation/inbox.test.ts @@ -491,6 +491,65 @@ describe("quote request lifecycle", () => { expect(sendActivity).toHaveBeenCalledOnce(); }); + it("keeps repeated QuoteRequest deliveries idempotent for accepted quotes", async () => { + expect.assertions(4); + + const author = await createAccount({ username: "quote-author" }); + const quotedPostId = crypto.randomUUID() as Uuid; + const quotedPostIri = `https://hollo.test/@quote-author/${quotedPostId}`; + const quotePostIri = "https://remote.test/@quoter/quote-1"; + const sendActivity = vi.fn(async () => undefined); + const requestCtx = { + ...ctx, + sendActivity, + } as unknown as InboxContext; + + await db.insert(posts).values({ + id: quotedPostId, + iri: quotedPostIri, + type: "Note", + accountId: author.id as Uuid, + visibility: "public", + quoteApprovalPolicy: "public", + contentHtml: "

Quoted post

", + content: "Quoted post", + published: new Date(), + }); + + const request = new QuoteRequest({ + actor: new URL("https://remote.test/@quoter"), + object: new URL(quotedPostIri), + instrument: new Note({ + id: new URL(quotePostIri), + attribution: new Person({ + id: new URL("https://remote.test/@quoter"), + name: "quoter", + preferredUsername: "quoter", + inbox: new URL("https://remote.test/@quoter/inbox"), + }), + quote: new URL(quotedPostIri), + to: new URL("https://www.w3.org/ns/activitystreams#Public"), + content: "

Remote quote

", + }), + }); + + await onQuoteRequested(requestCtx, request); + await onQuoteRequested(requestCtx, request); + + const quote = await db.query.posts.findFirst({ + where: eq(posts.iri, quotePostIri), + }); + const quoted = await db.query.posts.findFirst({ + where: eq(posts.id, quotedPostId), + }); + expect(quote?.quoteState).toBe("accepted"); + expect(quote?.quoteAuthorizationIri).toBe( + `${quotedPostIri}/quote_authorizations/${quote?.id}`, + ); + expect(quoted?.quotesCount).toBe(1); + expect(sendActivity).toHaveBeenCalledTimes(2); + }); + it("ignores a QuoteRequest whose actor does not match the quote", async () => { expect.assertions(3); diff --git a/src/federation/inbox.ts b/src/federation/inbox.ts index 601b11f9..591ff82c 100644 --- a/src/federation/inbox.ts +++ b/src/federation/inbox.ts @@ -583,6 +583,12 @@ export async function onQuoteRequested( ) { return; } + const existingQuote = + instrument.id == null + ? null + : await db.query.posts.findFirst({ + where: eq(posts.iri, instrument.id.href), + }); const persistedQuote = await persistPost( db, instrument, @@ -592,10 +598,13 @@ export async function onQuoteRequested( if (persistedQuote == null) return; if (persistedQuote.quoteTargetIri !== target.iri) return; - const accepted = await canAutomaticallyAcceptQuoteRequest( - target, - persistedQuote, - ); + const wasAccepted = + existingQuote?.quoteState === "accepted" && + (existingQuote.quoteTargetId === target.id || + existingQuote.quoteTargetIri === target.iri); + const accepted = + wasAccepted || + (await canAutomaticallyAcceptQuoteRequest(target, persistedQuote)); const authorizationIri = getQuoteAuthorizationIri(target, persistedQuote); await db.transaction(async (tx) => { await tx @@ -608,7 +617,7 @@ export async function onQuoteRequested( updated: new Date(), }) .where(eq(posts.id, persistedQuote.id)); - if (accepted && persistedQuote.quoteState !== "accepted") { + if (accepted && !wasAccepted) { await tx .update(posts) .set({ quotesCount: sql`coalesce(${posts.quotesCount}, 0) + 1` }) From 217bcd918e9d91fbd9e937badb050471592951a6 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 18:36:55 +0900 Subject: [PATCH 12/30] Validate quote authorizations Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3152961987 Assisted-by: Codex:gpt-5.5 --- src/federation/post.test.ts | 45 ++++++++++++++++++++++++++++ src/federation/post.ts | 58 ++++++++++++++++++++++++++++++------- 2 files changed, 92 insertions(+), 11 deletions(-) diff --git a/src/federation/post.test.ts b/src/federation/post.test.ts index f87b97cb..f9a1de38 100644 --- a/src/federation/post.test.ts +++ b/src/federation/post.test.ts @@ -6,6 +6,7 @@ import { Note, Person, PUBLIC_COLLECTION, + QuoteAuthorization, type RemoteDocument, } from "@fedify/vocab"; import { and, eq } from "drizzle-orm"; @@ -684,6 +685,50 @@ describe("persistPost quotes", () => { expect(persisted?.quoteState).toBe("accepted"); }); + it("does not accept quotes with forged quote authorization", async () => { + expect.assertions(3); + + const author = await seedRemoteAccount("quote-author"); + const quoter = await seedRemoteAccount("quote-quoter"); + const quotedPostId = crypto.randomUUID() as Uuid; + const quotedPostIri = "https://remote.test/objects/quoted-forged-auth"; + const quotePostIri = "https://remote.test/objects/quote-forged-auth"; + const quoteAuthorizationIri = `${quotedPostIri}/quote_authorizations/forged`; + + await db.insert(posts).values({ + id: quotedPostId, + iri: quotedPostIri, + type: "Note", + accountId: author.id, + visibility: "public", + contentHtml: "

Quoted post

", + content: "Quoted post", + published: new Date(), + }); + + const persisted = await persistPost( + db, + new Note({ + id: new URL(quotePostIri), + attribution: createPerson(quoter), + quote: new URL(quotedPostIri), + quoteAuthorization: new QuoteAuthorization({ + id: new URL(quoteAuthorizationIri), + attribution: new URL(quoter.iri), + interactingObject: new URL(quotePostIri), + interactionTarget: new URL(quotedPostIri), + }), + to: PUBLIC_COLLECTION, + content: "

Quote post

", + }), + "https://hollo.test", + ); + + expect(persisted?.quoteTargetId).toBe(quotedPostId); + expect(persisted?.quoteState).toBe("unauthorized"); + expect(persisted?.quoteAuthorizationIri).toBeNull(); + }); + it("defaults quote approval to public when no interaction policy exists", async () => { const author = await seedRemoteAccount("quote-author"); diff --git a/src/federation/post.ts b/src/federation/post.ts index 17405caf..eabd9e4d 100644 --- a/src/federation/post.ts +++ b/src/federation/post.ts @@ -20,6 +20,7 @@ import { Note, OrderedCollection, Question, + QuoteAuthorization, type Recipient, Source, Tombstone, @@ -129,6 +130,34 @@ function getQuoteApprovalPolicy( return "nobody"; } +async function getVerifiedQuoteAuthorizationIri( + object: ASPost, + quoteTargetIri: string | null, + quoteTargetAccountIri: string | null, + options: PersistAccountOptions, +): Promise { + const authorizationId = object.quoteAuthorizationId; + if ( + authorizationId == null || + quoteTargetIri == null || + quoteTargetAccountIri == null || + object.id == null + ) { + return null; + } + const authorization = await object.getQuoteAuthorization({ + ...options, + crossOrigin: "trust", + suppressError: true, + }); + if (!(authorization instanceof QuoteAuthorization)) return null; + if (authorization.id?.href !== authorizationId.href) return null; + if (authorization.attributionId?.href !== quoteTargetAccountIri) return null; + if (authorization.interactingObjectId?.href !== object.id.href) return null; + if (authorization.interactionTargetId?.href !== quoteTargetIri) return null; + return authorizationId.href; +} + export async function persistPost( db: PgDatabase< PostgresJsQueryResultHKT, @@ -235,6 +264,7 @@ export async function persistPost( let quoteTargetId: Uuid | null = null; let quoteTargetIri: string | null = null; let quoteTargetAccountId: Uuid | null = null; + let quoteTargetAccountIri: string | null = null; if (objectLink == null && object.quoteId != null) { objectLink = object.quoteId; } @@ -243,14 +273,14 @@ export async function persistPost( } if (objectLink != null) { quoteTargetIri = objectLink.href; - const result = await db - .select({ id: posts.id, accountId: posts.accountId }) - .from(posts) - .where(eq(posts.iri, objectLink.href)) - .limit(1); - if (result != null && result.length > 0) { - quoteTargetId = result[0].id; - quoteTargetAccountId = result[0].accountId; + const found = await db.query.posts.findFirst({ + where: eq(posts.iri, objectLink.href), + with: { account: true }, + }); + if (found != null) { + quoteTargetId = found.id; + quoteTargetAccountId = found.accountId; + quoteTargetAccountIri = found.account.iri; logger.debug("The quote target is already persisted: {quoteTargetId}", { quoteTargetId, }); @@ -267,6 +297,7 @@ export async function persistPost( }); quoteTargetId = quoteTargetObj?.id ?? null; quoteTargetAccountId = quoteTargetObj?.accountId ?? null; + quoteTargetAccountIri = quoteTargetObj?.account.iri ?? null; } } } @@ -281,6 +312,12 @@ export async function persistPost( : await extractPreviewLink(object.content.toString()); const previewCard = previewLink == null ? null : await fetchPreviewCard(previewLink); + const quoteAuthorizationIri = await getVerifiedQuoteAuthorizationIri( + object, + quoteTargetIri, + quoteTargetAccountIri, + options, + ); const published = toDate(object.published); const updated = toDate(object.updated) ?? published ?? new Date(); const values = { @@ -299,11 +336,10 @@ export async function persistPost( quoteState: quoteTargetId == null ? null - : quoteTargetAccountId === account.id || - object.quoteAuthorizationId != null + : quoteTargetAccountId === account.id || quoteAuthorizationIri != null ? "accepted" : "unauthorized", - quoteAuthorizationIri: object.quoteAuthorizationId?.href, + quoteAuthorizationIri, visibility: to.has(vocab.PUBLIC_COLLECTION.href) ? "public" : cc.has(vocab.PUBLIC_COLLECTION.href) From 9f75b628da4b302bd29fd23699155546ab98f9b2 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 18:39:42 +0900 Subject: [PATCH 13/30] Reject blocked quote requests Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3152961995 Assisted-by: Codex:gpt-5.5 --- src/federation/inbox.test.ts | 79 +++++++++++++++++++++++++++++++++++- src/federation/inbox.ts | 13 ++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/src/federation/inbox.test.ts b/src/federation/inbox.test.ts index 39855b68..b96cdc8f 100644 --- a/src/federation/inbox.test.ts +++ b/src/federation/inbox.test.ts @@ -14,7 +14,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { cleanDatabase } from "../../tests/helpers"; import { createAccount } from "../../tests/helpers/oauth"; import db from "../db"; -import { accounts, follows, posts } from "../schema"; +import { accounts, blocks, follows, instances, posts } from "../schema"; import type { Uuid } from "../uuid"; import { onFollowAccepted, @@ -550,6 +550,83 @@ describe("quote request lifecycle", () => { expect(sendActivity).toHaveBeenCalledTimes(2); }); + it("rejects a QuoteRequest from a blocked account", async () => { + expect.assertions(4); + + const author = await createAccount({ username: "quote-author" }); + const blockedAccountId = crypto.randomUUID() as Uuid; + const blockedAccountIri = "https://remote.test/@blocked"; + const quotedPostId = crypto.randomUUID() as Uuid; + const quotedPostIri = `https://hollo.test/@quote-author/${quotedPostId}`; + const quotePostIri = "https://remote.test/@blocked/quote-1"; + const sendActivity = vi.fn(async () => undefined); + const requestCtx = { + ...ctx, + sendActivity, + } as unknown as InboxContext; + + await db + .insert(instances) + .values({ host: "remote.test" }) + .onConflictDoNothing(); + await db.insert(accounts).values({ + id: blockedAccountId, + iri: blockedAccountIri, + type: "Person", + name: "blocked", + handle: "@blocked@remote.test", + bioHtml: "", + protected: false, + inboxUrl: `${blockedAccountIri}/inbox`, + instanceHost: "remote.test", + }); + await db.insert(blocks).values({ + accountId: author.id as Uuid, + blockedAccountId, + }); + await db.insert(posts).values({ + id: quotedPostId, + iri: quotedPostIri, + type: "Note", + accountId: author.id as Uuid, + visibility: "public", + quoteApprovalPolicy: "public", + contentHtml: "

Quoted post

", + content: "Quoted post", + published: new Date(), + }); + + const request = new QuoteRequest({ + actor: new URL(blockedAccountIri), + object: new URL(quotedPostIri), + instrument: new Note({ + id: new URL(quotePostIri), + attribution: new Person({ + id: new URL(blockedAccountIri), + name: "blocked", + preferredUsername: "blocked", + inbox: new URL(`${blockedAccountIri}/inbox`), + }), + quote: new URL(quotedPostIri), + to: new URL("https://www.w3.org/ns/activitystreams#Public"), + content: "

Blocked quote

", + }), + }); + + await onQuoteRequested(requestCtx, request); + + const quote = await db.query.posts.findFirst({ + where: eq(posts.iri, quotePostIri), + }); + const quoted = await db.query.posts.findFirst({ + where: eq(posts.id, quotedPostId), + }); + expect(quote?.quoteState).toBe("rejected"); + expect(quote?.quoteAuthorizationIri).toBeNull(); + expect(quoted?.quotesCount).toBe(0); + expect(sendActivity).toHaveBeenCalledOnce(); + }); + it("ignores a QuoteRequest whose actor does not match the quote", async () => { expect.assertions(3); diff --git a/src/federation/inbox.ts b/src/federation/inbox.ts index 591ff82c..c077980f 100644 --- a/src/federation/inbox.ts +++ b/src/federation/inbox.ts @@ -551,6 +551,19 @@ async function canAutomaticallyAcceptQuoteRequest( ): Promise { if (target.accountId === quote.accountId) return true; if (target.visibility === "direct") return false; + const block = await db.query.blocks.findFirst({ + where: or( + and( + eq(blocks.accountId, target.accountId), + eq(blocks.blockedAccountId, quote.accountId), + ), + and( + eq(blocks.accountId, quote.accountId), + eq(blocks.blockedAccountId, target.accountId), + ), + ), + }); + if (block != null) return false; const policy = target.visibility === "private" ? "nobody" : target.quoteApprovalPolicy; if (policy === "public") return true; From 1add48b49b340871845056638a6b832389b777c2 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 18:50:38 +0900 Subject: [PATCH 14/30] Handle deleted quote authorization IRIs Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3153080461 Assisted-by: Codex:gpt-5.5 --- src/federation/inbox.test.ts | 36 ++++++++++++++++++++++++++++++++++++ src/federation/inbox.ts | 5 ++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/federation/inbox.test.ts b/src/federation/inbox.test.ts index b96cdc8f..d63969fb 100644 --- a/src/federation/inbox.test.ts +++ b/src/federation/inbox.test.ts @@ -393,6 +393,42 @@ describe("quote request lifecycle", () => { expect(quoted?.quotesCount).toBe(0); }); + it("marks an accepted quote revoked from a deleted authorization IRI", async () => { + expect.assertions(2); + + const seeded = await seedPendingQuote(); + const authorizationIri = `${seeded.quotedPostIri}/quote_authorizations/${seeded.quotePostId}`; + await db + .update(posts) + .set({ + quoteState: "accepted", + quoteAuthorizationIri: authorizationIri, + quotesCount: 1, + }) + .where(eq(posts.id, seeded.quotePostId)); + await db + .update(posts) + .set({ quotesCount: 1 }) + .where(eq(posts.id, seeded.quotedPostId)); + + await onQuoteAuthorizationDeleted( + ctx, + new Delete({ + actor: new URL("https://hollo.test/@quote-author"), + object: new URL(authorizationIri), + }), + ); + + const quote = await db.query.posts.findFirst({ + where: eq(posts.id, seeded.quotePostId), + }); + const quoted = await db.query.posts.findFirst({ + where: eq(posts.id, seeded.quotedPostId), + }); + expect(quote?.quoteState).toBe("revoked"); + expect(quoted?.quotesCount).toBe(0); + }); + it("ignores quote authorization deletion from another actor", async () => { expect.assertions(3); diff --git a/src/federation/inbox.ts b/src/federation/inbox.ts index c077980f..cda5b03d 100644 --- a/src/federation/inbox.ts +++ b/src/federation/inbox.ts @@ -497,7 +497,10 @@ export async function onQuoteAuthorizationDeleted( _ctx: InboxContext, del: Delete, ): Promise { - const object = await del.getObject({ crossOrigin: "trust" }); + const object = await del.getObject({ + crossOrigin: "trust", + suppressError: true, + }); const authorizationIri = object instanceof QuoteAuthorization ? object.id?.href : del.objectId?.href; const quoteIri = From 5b6b91920d2f5bdb4308df41795b211a6c9aee6a Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 19:28:00 +0900 Subject: [PATCH 15/30] Federate accepted quote updates Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3153249850 Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3153249858 Assisted-by: Codex:gpt-5.5 --- src/federation/inbox.test.ts | 74 +++++++++++++++++++++++++++++++++++- src/federation/inbox.ts | 56 +++++++++++++++++++++++++-- 2 files changed, 125 insertions(+), 5 deletions(-) diff --git a/src/federation/inbox.test.ts b/src/federation/inbox.test.ts index d63969fb..f5111041 100644 --- a/src/federation/inbox.test.ts +++ b/src/federation/inbox.test.ts @@ -7,6 +7,7 @@ import { QuoteAuthorization, QuoteRequest, Reject, + Update, } from "@fedify/vocab"; import { and, eq } from "drizzle-orm"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -229,6 +230,11 @@ describe("quote request lifecycle", () => { const seeded = await seedPendingQuote(); const authorizationIri = `${seeded.quotedPostIri}/quote_authorizations/${seeded.quotePostId}`; + const sendActivity = vi.fn(async () => undefined); + const requestCtx = { + ...ctx, + sendActivity, + } as unknown as InboxContext; const accept = new Accept({ actor: new URL("https://hollo.test/@quote-author"), object: new QuoteRequest({ @@ -238,7 +244,7 @@ describe("quote request lifecycle", () => { result: new URL(authorizationIri), }); - await onQuoteRequestAccepted(ctx, accept); + await onQuoteRequestAccepted(requestCtx, accept); const quote = await db.query.posts.findFirst({ where: eq(posts.id, seeded.quotePostId), @@ -251,18 +257,82 @@ describe("quote request lifecycle", () => { expect(quoted?.quotesCount).toBe(1); }); + it("ignores Accept without a QuoteAuthorization result", async () => { + expect.assertions(4); + + const seeded = await seedPendingQuote(); + const accept = new Accept({ + actor: new URL("https://hollo.test/@quote-author"), + object: new QuoteRequest({ + object: new URL(seeded.quotedPostIri), + instrument: new URL(seeded.quotePostIri), + }), + }); + + const accepted = await onQuoteRequestAccepted(ctx, accept); + + const quote = await db.query.posts.findFirst({ + where: eq(posts.id, seeded.quotePostId), + }); + const quoted = await db.query.posts.findFirst({ + where: eq(posts.id, seeded.quotedPostId), + }); + expect(accepted).toBe(false); + expect(quote?.quoteState).toBe("pending"); + expect(quote?.quoteAuthorizationIri).toBeNull(); + expect(quoted?.quotesCount).toBe(0); + }); + + it("federates the quote update after Accept", async () => { + expect.assertions(7); + + const seeded = await seedPendingQuote(); + const authorizationIri = `${seeded.quotedPostIri}/quote_authorizations/${seeded.quotePostId}`; + const sendActivity = vi.fn(async () => undefined); + const requestCtx = { + ...ctx, + sendActivity, + } as unknown as InboxContext; + const accept = new Accept({ + actor: new URL("https://hollo.test/@quote-author"), + object: new QuoteRequest({ + object: new URL(seeded.quotedPostIri), + instrument: new URL(seeded.quotePostIri), + }), + result: new URL(authorizationIri), + }); + + const accepted = await onQuoteRequestAccepted(requestCtx, accept); + + expect(accepted).toBe(true); + expect(sendActivity).toHaveBeenCalledOnce(); + const [sender, recipient, activity] = sendActivity.mock + .calls[0] as unknown as [unknown, unknown, unknown]; + expect(sender).toEqual({ username: "quote-quoter" }); + expect(recipient).toBe("followers"); + expect(activity).toBeInstanceOf(Update); + const object = await (activity as Update).getObject(); + expect(object).toBeInstanceOf(Note); + expect((object as Note).quoteAuthorizationId?.href).toBe(authorizationIri); + }); + it("marks a pending quote accepted from Accept", async () => { expect.assertions(3); const seeded = await seedPendingQuote(); const authorizationIri = `${seeded.quotedPostIri}/quote_authorizations/${seeded.quotePostId}`; + const sendActivity = vi.fn(async () => undefined); + const requestCtx = { + ...ctx, + sendActivity, + } as unknown as InboxContext; const accept = new Accept({ actor: new URL("https://hollo.test/@quote-author"), object: new URL(`${seeded.quotePostIri}#quote-request`), result: new URL(authorizationIri), }); - await onQuoteRequestAccepted(ctx, accept); + await onQuoteRequestAccepted(requestCtx, accept); const quote = await db.query.posts.findFirst({ where: eq(posts.id, seeded.quotePostId), diff --git a/src/federation/inbox.ts b/src/federation/inbox.ts index cda5b03d..c114e0b3 100644 --- a/src/federation/inbox.ts +++ b/src/federation/inbox.ts @@ -66,6 +66,7 @@ import { updateAccountStats, } from "./account"; import { + getRecipients, isPost, persistPollVote, persistPost, @@ -465,18 +466,67 @@ async function updateQuoteRequestState( return true; } +async function sendQuoteUpdate( + ctx: InboxContext, + quoteIri: string, +): Promise { + const quote = await db.query.posts.findFirst({ + where: eq(posts.iri, quoteIri), + with: { + account: { with: { owner: true } }, + replyTarget: true, + quoteTarget: true, + media: true, + poll: { with: { options: true } }, + mentions: { with: { account: true } }, + replies: true, + }, + }); + if (quote?.account.owner == null) return; + const activity = toUpdate(quote, ctx); + const orderingKey = `post:${quote.iri}`; + const recipients = getRecipients(quote); + if (recipients.length > 0) { + await ctx.sendActivity( + { username: quote.account.owner.handle }, + recipients, + activity, + { + orderingKey, + excludeBaseUris: [new URL(ctx.origin)], + }, + ); + } + if (quote.visibility !== "direct") { + await ctx.sendActivity( + { username: quote.account.owner.handle }, + "followers", + activity, + { + orderingKey, + preferSharedInbox: true, + excludeBaseUris: [new URL(ctx.origin)], + }, + ); + } +} + export async function onQuoteRequestAccepted( - _ctx: InboxContext, + ctx: InboxContext, accept: Accept, ): Promise { const request = await getQuoteRequestReferenceFromActivity(accept); if (request == null) return false; - return await updateQuoteRequestState( + const quoteAuthorizationIri = accept.resultId?.href; + if (quoteAuthorizationIri == null) return false; + const accepted = await updateQuoteRequestState( request, accept.actorId?.href ?? null, "accepted", - accept.resultId?.href ?? null, + quoteAuthorizationIri, ); + if (accepted) await sendQuoteUpdate(ctx, request.quoteIri); + return accepted; } export async function onQuoteRequestRejected( From 2ea266c3311f232794bb1ea52b5e03f3559c3343 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 19:52:31 +0900 Subject: [PATCH 16/30] Preserve accepted quote authorization Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3153437063 Assisted-by: Codex:gpt-5.5 --- src/federation/post.test.ts | 55 +++++++++++++++++++++++++++++++++++++ src/federation/post.ts | 11 ++++++-- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/federation/post.test.ts b/src/federation/post.test.ts index f9a1de38..b8876075 100644 --- a/src/federation/post.test.ts +++ b/src/federation/post.test.ts @@ -729,6 +729,61 @@ describe("persistPost quotes", () => { expect(persisted?.quoteAuthorizationIri).toBeNull(); }); + it("preserves accepted quote authorization during later updates", async () => { + expect.assertions(3); + + const author = await seedRemoteAccount("quote-author"); + const quoter = await seedRemoteAccount("quote-quoter"); + const quotedPostId = crypto.randomUUID() as Uuid; + const quotePostId = crypto.randomUUID() as Uuid; + const quotedPostIri = "https://remote.test/objects/quoted-later-update"; + const quotePostIri = "https://remote.test/objects/quote-later-update"; + const quoteAuthorizationIri = `${quotedPostIri}/quote_authorizations/${quotePostId}`; + + await db.insert(posts).values([ + { + id: quotedPostId, + iri: quotedPostIri, + type: "Note", + accountId: author.id, + visibility: "public", + contentHtml: "

Quoted post

", + content: "Quoted post", + published: new Date(), + }, + { + id: quotePostId, + iri: quotePostIri, + type: "Note", + accountId: quoter.id, + quoteTargetId: quotedPostId, + quoteTargetIri: quotedPostIri, + quoteState: "accepted", + quoteAuthorizationIri, + visibility: "public", + contentHtml: "

Original quote

", + content: "Original quote", + published: new Date(), + }, + ]); + + const persisted = await persistPost( + db, + new Note({ + id: new URL(quotePostIri), + attribution: createPerson(quoter), + quote: new URL(quotedPostIri), + to: PUBLIC_COLLECTION, + content: "

Updated quote

", + }), + "https://hollo.test", + ); + + expect(persisted?.quoteState).toBe("accepted"); + expect(persisted?.quoteAuthorizationIri).toBe(quoteAuthorizationIri); + expect(persisted?.contentHtml).toBe("

Updated quote

"); + }); + it("defaults quote approval to public when no interaction policy exists", async () => { const author = await seedRemoteAccount("quote-author"); diff --git a/src/federation/post.ts b/src/federation/post.ts index eabd9e4d..f218c194 100644 --- a/src/federation/post.ts +++ b/src/federation/post.ts @@ -318,6 +318,12 @@ export async function persistPost( quoteTargetAccountIri, options, ); + const preservedQuoteAuthorizationIri = + quoteAuthorizationIri ?? + (existingPost?.quoteState === "accepted" && + existingPost.quoteTargetIri === quoteTargetIri + ? existingPost.quoteAuthorizationIri + : null); const published = toDate(object.published); const updated = toDate(object.updated) ?? published ?? new Date(); const values = { @@ -336,10 +342,11 @@ export async function persistPost( quoteState: quoteTargetId == null ? null - : quoteTargetAccountId === account.id || quoteAuthorizationIri != null + : quoteTargetAccountId === account.id || + preservedQuoteAuthorizationIri != null ? "accepted" : "unauthorized", - quoteAuthorizationIri, + quoteAuthorizationIri: preservedQuoteAuthorizationIri, visibility: to.has(vocab.PUBLIC_COLLECTION.href) ? "public" : cc.has(vocab.PUBLIC_COLLECTION.href) From 06ec2539a1bff99472585810099623325256954b Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 20:03:31 +0900 Subject: [PATCH 17/30] Preserve accepted legacy quotes Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3153522264 Assisted-by: Codex:gpt-5.5 --- src/federation/post.test.ts | 54 +++++++++++++++++++++++++++++++++++++ src/federation/post.ts | 12 +++++---- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/federation/post.test.ts b/src/federation/post.test.ts index b8876075..5f2a3f3e 100644 --- a/src/federation/post.test.ts +++ b/src/federation/post.test.ts @@ -784,6 +784,60 @@ describe("persistPost quotes", () => { expect(persisted?.contentHtml).toBe("

Updated quote

"); }); + it("preserves accepted legacy quotes during later updates", async () => { + expect.assertions(3); + + const author = await seedRemoteAccount("quote-author"); + const quoter = await seedRemoteAccount("quote-quoter"); + const quotedPostId = crypto.randomUUID() as Uuid; + const quotePostId = crypto.randomUUID() as Uuid; + const quotedPostIri = "https://remote.test/objects/quoted-legacy-update"; + const quotePostIri = "https://remote.test/objects/quote-legacy-update"; + + await db.insert(posts).values([ + { + id: quotedPostId, + iri: quotedPostIri, + type: "Note", + accountId: author.id, + visibility: "public", + contentHtml: "

Quoted post

", + content: "Quoted post", + published: new Date(), + }, + { + id: quotePostId, + iri: quotePostIri, + type: "Note", + accountId: quoter.id, + quoteTargetId: quotedPostId, + quoteTargetIri: quotedPostIri, + quoteState: "accepted", + quoteAuthorizationIri: null, + visibility: "public", + contentHtml: "

Original quote

", + content: "Original quote", + published: new Date(), + }, + ]); + + const persisted = await persistPost( + db, + new Note({ + id: new URL(quotePostIri), + attribution: createPerson(quoter), + quote: new URL(quotedPostIri), + to: PUBLIC_COLLECTION, + content: "

Updated quote

", + }), + "https://hollo.test", + ); + + expect(persisted?.quoteState).toBe("accepted"); + expect(persisted?.quoteAuthorizationIri).toBeNull(); + expect(persisted?.contentHtml).toBe("

Updated quote

"); + }); + it("defaults quote approval to public when no interaction policy exists", async () => { const author = await seedRemoteAccount("quote-author"); diff --git a/src/federation/post.ts b/src/federation/post.ts index f218c194..f89b2836 100644 --- a/src/federation/post.ts +++ b/src/federation/post.ts @@ -318,12 +318,13 @@ export async function persistPost( quoteTargetAccountIri, options, ); + const preserveAcceptedQuote = + quoteTargetIri != null && + existingPost?.quoteState === "accepted" && + existingPost.quoteTargetIri === quoteTargetIri; const preservedQuoteAuthorizationIri = quoteAuthorizationIri ?? - (existingPost?.quoteState === "accepted" && - existingPost.quoteTargetIri === quoteTargetIri - ? existingPost.quoteAuthorizationIri - : null); + (preserveAcceptedQuote ? existingPost.quoteAuthorizationIri : null); const published = toDate(object.published); const updated = toDate(object.updated) ?? published ?? new Date(); const values = { @@ -343,7 +344,8 @@ export async function persistPost( quoteTargetId == null ? null : quoteTargetAccountId === account.id || - preservedQuoteAuthorizationIri != null + quoteAuthorizationIri != null || + preserveAcceptedQuote ? "accepted" : "unauthorized", quoteAuthorizationIri: preservedQuoteAuthorizationIri, From 1834502369be6ce53e4e81b931c936945d12ca75 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 20:07:17 +0900 Subject: [PATCH 18/30] Require approved object fetch followers Require signed key owners to have an approved follow before fetching private objects, including private QuoteAuthorization objects. Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3153522266 Assisted-by: Codex:gpt-5.5 --- src/federation/objects.test.ts | 76 ++++++++++++++++++++++++++++++++++ src/federation/objects.ts | 56 ++++++++++++------------- 2 files changed, 104 insertions(+), 28 deletions(-) create mode 100644 src/federation/objects.test.ts diff --git a/src/federation/objects.test.ts b/src/federation/objects.test.ts new file mode 100644 index 00000000..4b506545 --- /dev/null +++ b/src/federation/objects.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { cleanDatabase } from "../../tests/helpers"; +import { createAccount } from "../../tests/helpers/oauth"; +import db from "../db"; +import { accounts, follows, instances } from "../schema"; +import type { Uuid } from "../uuid"; +import { hasApprovedFollowFromKeyOwner } from "./objects"; + +async function createRemoteAccount(username: string) { + const id = crypto.randomUUID() as Uuid; + const iri = `https://remote.test/users/${username}`; + + await db + .insert(instances) + .values({ host: "remote.test" }) + .onConflictDoNothing(); + + await db.insert(accounts).values({ + id, + iri, + instanceHost: "remote.test", + type: "Person", + name: `Remote ${username}`, + emojis: {}, + handle: `@${username}@remote.test`, + bioHtml: "", + url: `https://remote.test/@${username}`, + protected: false, + inboxUrl: `${iri}/inbox`, + }); + + return { id, iri }; +} + +describe.sequential("object dispatchers", () => { + beforeEach(async () => { + await cleanDatabase(); + }); + + it("requires approved follows for private object fetches", async () => { + expect.assertions(2); + + const owner = await createAccount({ username: "quote-author" }); + const pendingFollower = await createRemoteAccount("pending-follower"); + const approvedFollower = await createRemoteAccount("approved-follower"); + + await db.insert(follows).values([ + { + iri: `https://remote.test/follows/${crypto.randomUUID()}`, + followingId: owner.id as Uuid, + followerId: pendingFollower.id, + approved: null, + }, + { + iri: `https://remote.test/follows/${crypto.randomUUID()}`, + followingId: owner.id as Uuid, + followerId: approvedFollower.id, + approved: new Date(), + }, + ]); + + await expect( + hasApprovedFollowFromKeyOwner( + new URL(pendingFollower.iri), + owner.id as Uuid, + ), + ).resolves.toBe(false); + await expect( + hasApprovedFollowFromKeyOwner( + new URL(approvedFollower.iri), + owner.id as Uuid, + ), + ).resolves.toBe(true); + }); +}); diff --git a/src/federation/objects.ts b/src/federation/objects.ts index 7d08364a..3f6b7c27 100644 --- a/src/federation/objects.ts +++ b/src/federation/objects.ts @@ -1,5 +1,5 @@ import { Emoji, Flag, Note, QuoteAuthorization } from "@fedify/vocab"; -import { and, eq, inArray, isNull, like, or } from "drizzle-orm"; +import { and, eq, inArray, isNotNull, isNull, like, or } from "drizzle-orm"; import { db } from "../db"; import { @@ -11,11 +11,31 @@ import { posts, reports, } from "../schema"; -import { isUuid } from "../uuid"; +import { isUuid, type Uuid } from "../uuid"; import { toEmoji } from "./emoji"; import { federation } from "./federation"; import { toObject } from "./post"; +export async function hasApprovedFollowFromKeyOwner( + keyOwnerId: URL, + followingId: Uuid, +): Promise { + const found = await db.query.follows.findFirst({ + where: and( + inArray( + follows.followerId, + db + .select({ id: accounts.id }) + .from(accounts) + .where(eq(accounts.iri, keyOwnerId.href)), + ), + eq(follows.followingId, followingId), + isNotNull(follows.approved), + ), + }); + return found != null; +} + federation.setObjectDispatcher( Note, "/@{username}/{id}", @@ -46,19 +66,9 @@ federation.setObjectDispatcher( if (post.visibility === "private") { const keyOwner = await ctx.getSignedKeyOwner(); if (keyOwner?.id == null) return null; - const found = await db.query.follows.findFirst({ - where: and( - inArray( - follows.followerId, - db - .select({ id: accounts.id }) - .from(accounts) - .where(eq(accounts.iri, keyOwner.id.href)), - ), - eq(follows.followingId, owner.id), - ), - }); - if (found == null) return null; + if (!(await hasApprovedFollowFromKeyOwner(keyOwner.id, owner.id))) { + return null; + } } else if (post.visibility === "direct") { const keyOwner = await ctx.getSignedKeyOwner(); const keyOwnerId = keyOwner?.id; @@ -110,19 +120,9 @@ federation.setObjectDispatcher( if (targetPost.visibility === "private") { const keyOwner = await ctx.getSignedKeyOwner(); if (keyOwner?.id == null) return null; - const found = await db.query.follows.findFirst({ - where: and( - inArray( - follows.followerId, - db - .select({ id: accounts.id }) - .from(accounts) - .where(eq(accounts.iri, keyOwner.id.href)), - ), - eq(follows.followingId, owner.id), - ), - }); - if (found == null) return null; + if (!(await hasApprovedFollowFromKeyOwner(keyOwner.id, owner.id))) { + return null; + } } else if (targetPost.visibility === "direct") { const keyOwner = await ctx.getSignedKeyOwner(); const keyOwnerId = keyOwner?.id; From 1e50d6761ec116ffbdcbdc0a30fe3bf6ed045f63 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 20:26:15 +0900 Subject: [PATCH 19/30] Honor private quote policies Preserve configured quote approval policies for private posts when serializing Mastodon API statuses and when processing inbound QuoteRequest activities from approved followers. Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3153672071 Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3153672076 Assisted-by: Codex:gpt-5.5 --- src/api/v1/statuses.test.ts | 29 ++++++++++ src/entities/status.ts | 5 +- src/federation/inbox.test.ts | 100 +++++++++++++++++++++++++++++++++++ src/federation/inbox.ts | 3 +- 4 files changed, 131 insertions(+), 6 deletions(-) diff --git a/src/api/v1/statuses.test.ts b/src/api/v1/statuses.test.ts index c50a71fb..2795e462 100644 --- a/src/api/v1/statuses.test.ts +++ b/src/api/v1/statuses.test.ts @@ -341,6 +341,35 @@ describe.sequential("/api/v1/statuses quotes", () => { expect(status.quote_approval.current_user).toBe("automatic"); }); + it("preserves private quote approval for approved followers", async () => { + expect.assertions(4); + + await db.insert(follows).values({ + iri: `https://hollo.test/follows/${crypto.randomUUID()}`, + followingId: author.id, + followerId: quoter.id, + approved: new Date(), + }); + + const quotedResponse = await createStatus(authorToken, { + status: "Approved followers can quote this private status", + visibility: "private", + quote_approval_policy: "followers", + }); + expect(quotedResponse.status).toBe(200); + const quoted = await quotedResponse.json(); + + const response = await app.request(`/api/v1/statuses/${quoted.id}`, { + headers: { + authorization: bearerAuthorization(quoterToken), + }, + }); + expect(response.status).toBe(200); + const status = await response.json(); + expect(status.quote_approval.automatic).toEqual(["followers"]); + expect(status.quote_approval.current_user).toBe("automatic"); + }); + it("allows approved followers to quote private statuses", async () => { expect.assertions(4); diff --git a/src/entities/status.ts b/src/entities/status.ts index 8236c3b2..c61b81d9 100644 --- a/src/entities/status.ts +++ b/src/entities/status.ts @@ -53,10 +53,7 @@ function serializeQuoteApproval( post: Pick, viewerIsApprovedFollower: boolean, ) { - const effectivePolicy = - post.visibility === "private" || post.visibility === "direct" - ? "nobody" - : policy; + const effectivePolicy = post.visibility === "direct" ? "nobody" : policy; const automatic = effectivePolicy === "public" ? ["public"] diff --git a/src/federation/inbox.test.ts b/src/federation/inbox.test.ts index f5111041..26e9f56e 100644 --- a/src/federation/inbox.test.ts +++ b/src/federation/inbox.test.ts @@ -188,6 +188,40 @@ describe("quote request lifecycle", () => { await cleanDatabase(); }); + async function seedRemoteAccount(username: string): Promise { + const id = crypto.randomUUID() as Uuid; + const iri = `https://remote.test/@${username}`; + + await db + .insert(instances) + .values({ + host: "remote.test", + software: "mastodon", + softwareVersion: null, + }) + .onConflictDoNothing(); + await db.insert(accounts).values({ + id, + iri, + type: "Person", + name: username, + handle: `@${username}@remote.test`, + bioHtml: "", + emojis: {}, + fieldHtmls: {}, + aliases: [], + protected: false, + inboxUrl: `${iri}/inbox`, + followersUrl: `${iri}/followers`, + sharedInboxUrl: "https://remote.test/inbox", + featuredUrl: `${iri}/featured`, + instanceHost: "remote.test", + published: new Date(), + }); + + return id; + } + async function seedPendingQuote() { const author = await createAccount({ username: "quote-author" }); const quoter = await createAccount({ username: "quote-quoter" }); @@ -656,6 +690,72 @@ describe("quote request lifecycle", () => { expect(sendActivity).toHaveBeenCalledTimes(2); }); + it("accepts a private QuoteRequest from an approved follower", async () => { + expect.assertions(4); + + const author = await createAccount({ username: "quote-author" }); + const quoterIri = "https://remote.test/@quoter"; + const quoterId = await seedRemoteAccount("quoter"); + const quotedPostId = crypto.randomUUID() as Uuid; + const quotedPostIri = `https://hollo.test/@quote-author/${quotedPostId}`; + const quotePostIri = "https://remote.test/@quoter/quote-private"; + const sendActivity = vi.fn(async () => undefined); + const requestCtx = { + ...ctx, + sendActivity, + } as unknown as InboxContext; + + await db.insert(follows).values({ + iri: `${quoterIri}#follows/${crypto.randomUUID()}`, + followingId: author.id as Uuid, + followerId: quoterId, + approved: new Date(), + }); + await db.insert(posts).values({ + id: quotedPostId, + iri: quotedPostIri, + type: "Note", + accountId: author.id as Uuid, + visibility: "private", + quoteApprovalPolicy: "followers", + contentHtml: "

Private quoted post

", + content: "Private quoted post", + published: new Date(), + }); + + const request = new QuoteRequest({ + actor: new URL(quoterIri), + object: new URL(quotedPostIri), + instrument: new Note({ + id: new URL(quotePostIri), + attribution: new Person({ + id: new URL(quoterIri), + name: "quoter", + preferredUsername: "quoter", + inbox: new URL(`${quoterIri}/inbox`), + }), + quote: new URL(quotedPostIri), + to: new URL("https://www.w3.org/ns/activitystreams#Public"), + content: "

Remote quote of a private post

", + }), + }); + + await onQuoteRequested(requestCtx, request); + + const quote = await db.query.posts.findFirst({ + where: eq(posts.iri, quotePostIri), + }); + const quoted = await db.query.posts.findFirst({ + where: eq(posts.id, quotedPostId), + }); + expect(quote?.quoteState).toBe("accepted"); + expect(quote?.quoteAuthorizationIri).toBe( + `${quotedPostIri}/quote_authorizations/${quote?.id}`, + ); + expect(quoted?.quotesCount).toBe(1); + expect(sendActivity).toHaveBeenCalledOnce(); + }); + it("rejects a QuoteRequest from a blocked account", async () => { expect.assertions(4); diff --git a/src/federation/inbox.ts b/src/federation/inbox.ts index c114e0b3..70ef5e91 100644 --- a/src/federation/inbox.ts +++ b/src/federation/inbox.ts @@ -617,8 +617,7 @@ async function canAutomaticallyAcceptQuoteRequest( ), }); if (block != null) return false; - const policy = - target.visibility === "private" ? "nobody" : target.quoteApprovalPolicy; + const policy = target.quoteApprovalPolicy; if (policy === "public") return true; if (policy === "nobody") return false; const follow = await db.query.follows.findFirst({ From b45ce2e9bd749b246ce96e9f2cd3b7107b261191 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 20:43:45 +0900 Subject: [PATCH 20/30] Honor private quote federation policies Advertise stored private quote policies in ActivityPub interaction policies and federate updated quote objects after quote authorization revocations. Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3153750701 Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3153750706 Assisted-by: Codex:gpt-5.5 --- src/federation/inbox.test.ts | 63 ++++++++++++++++++++++++++++++++++-- src/federation/inbox.ts | 3 +- src/federation/post.test.ts | 38 +++++++++++++++++++++- src/federation/post.ts | 4 +-- 4 files changed, 101 insertions(+), 7 deletions(-) diff --git a/src/federation/inbox.test.ts b/src/federation/inbox.test.ts index 26e9f56e..ef047d22 100644 --- a/src/federation/inbox.test.ts +++ b/src/federation/inbox.test.ts @@ -461,6 +461,10 @@ describe("quote request lifecycle", () => { const seeded = await seedPendingQuote(); const authorizationIri = `${seeded.quotedPostIri}/quote_authorizations/${seeded.quotePostId}`; + const requestCtx = { + ...ctx, + sendActivity: vi.fn(async () => undefined), + } as unknown as InboxContext; await db .update(posts) .set({ @@ -475,7 +479,7 @@ describe("quote request lifecycle", () => { .where(eq(posts.id, seeded.quotedPostId)); await onQuoteAuthorizationDeleted( - ctx, + requestCtx, new Delete({ actor: new URL("https://hollo.test/@quote-author"), object: new QuoteAuthorization({ @@ -497,11 +501,66 @@ describe("quote request lifecycle", () => { expect(quoted?.quotesCount).toBe(0); }); + it("federates the quote update after authorization deletion", async () => { + expect.assertions(7); + + const seeded = await seedPendingQuote(); + const authorizationIri = `${seeded.quotedPostIri}/quote_authorizations/${seeded.quotePostId}`; + const sendActivity = vi.fn(async () => undefined); + const requestCtx = { + ...ctx, + sendActivity, + } as unknown as InboxContext; + await db + .update(posts) + .set({ + quoteState: "accepted", + quoteAuthorizationIri: authorizationIri, + quotesCount: 1, + }) + .where(eq(posts.id, seeded.quotePostId)); + await db + .update(posts) + .set({ quotesCount: 1 }) + .where(eq(posts.id, seeded.quotedPostId)); + + await onQuoteAuthorizationDeleted( + requestCtx, + new Delete({ + actor: new URL("https://hollo.test/@quote-author"), + object: new QuoteAuthorization({ + id: new URL(authorizationIri), + attribution: new URL("https://hollo.test/@quote-author"), + interactingObject: new URL(seeded.quotePostIri), + interactionTarget: new URL(seeded.quotedPostIri), + }), + }), + ); + + const quote = await db.query.posts.findFirst({ + where: eq(posts.id, seeded.quotePostId), + }); + expect(quote?.quoteState).toBe("revoked"); + expect(sendActivity).toHaveBeenCalledOnce(); + const [sender, recipient, activity] = sendActivity.mock + .calls[0] as unknown as [unknown, unknown, unknown]; + expect(sender).toEqual({ username: "quote-quoter" }); + expect(recipient).toBe("followers"); + expect(activity).toBeInstanceOf(Update); + const object = await (activity as Update).getObject(); + expect(object).toBeInstanceOf(Note); + expect((object as Note).quoteAuthorizationId).toBeNull(); + }); + it("marks an accepted quote revoked from a deleted authorization IRI", async () => { expect.assertions(2); const seeded = await seedPendingQuote(); const authorizationIri = `${seeded.quotedPostIri}/quote_authorizations/${seeded.quotePostId}`; + const requestCtx = { + ...ctx, + sendActivity: vi.fn(async () => undefined), + } as unknown as InboxContext; await db .update(posts) .set({ @@ -516,7 +575,7 @@ describe("quote request lifecycle", () => { .where(eq(posts.id, seeded.quotedPostId)); await onQuoteAuthorizationDeleted( - ctx, + requestCtx, new Delete({ actor: new URL("https://hollo.test/@quote-author"), object: new URL(authorizationIri), diff --git a/src/federation/inbox.ts b/src/federation/inbox.ts index 70ef5e91..9b52e1de 100644 --- a/src/federation/inbox.ts +++ b/src/federation/inbox.ts @@ -544,7 +544,7 @@ export async function onQuoteRequestRejected( } export async function onQuoteAuthorizationDeleted( - _ctx: InboxContext, + ctx: InboxContext, del: Delete, ): Promise { const object = await del.getObject({ @@ -592,6 +592,7 @@ export async function onQuoteAuthorizationDeleted( .where(eq(posts.id, quote.quoteTargetId)); } }); + await sendQuoteUpdate(ctx, quote.iri); } function getQuoteAuthorizationIri(target: Post, quote: Post): string { diff --git a/src/federation/post.test.ts b/src/federation/post.test.ts index 5f2a3f3e..2415a506 100644 --- a/src/federation/post.test.ts +++ b/src/federation/post.test.ts @@ -389,6 +389,10 @@ describe("toObject", () => { }); async function getObjectJson(postId: Uuid) { + return await getObjectJsonWithContext(postId, {} as Context); + } + + async function getObjectJsonWithContext(postId: Uuid, ctx: Context) { const post = await db.query.posts.findFirst({ where: eq(posts.id, postId), with: { @@ -402,7 +406,7 @@ describe("toObject", () => { }, }); if (post == null) throw new Error("Failed to load post"); - return await toObject(post, {} as Context).toJsonLd(); + return await toObject(post, ctx).toJsonLd(); } it("adds a quote-inline fallback to explicit quote content", async () => { @@ -645,6 +649,38 @@ describe("toObject", () => { }, }); }); + + it("emits private follower quote policies", async () => { + expect.assertions(1); + + const account = await createAccount({ username: "quote-author" }); + const postId = crypto.randomUUID() as Uuid; + + await db.insert(posts).values({ + id: postId, + iri: `https://hollo.test/@quote-author/${postId}`, + type: "Note", + accountId: account.id as Uuid, + visibility: "private", + quoteApprovalPolicy: "followers", + contentHtml: "

Followers can quote this

", + content: "Followers can quote this", + published: new Date(), + }); + + const json = await getObjectJsonWithContext(postId, { + getFollowersUri: (handle: string) => + new URL(`https://hollo.test/@${handle}/followers`), + } as Context); + + expect(json).toMatchObject({ + interactionPolicy: { + canQuote: { + automaticApproval: "https://hollo.test/@quote-author/followers", + }, + }, + }); + }); }); describe("persistPost quotes", () => { diff --git a/src/federation/post.ts b/src/federation/post.ts index f89b2836..61efcb9f 100644 --- a/src/federation/post.ts +++ b/src/federation/post.ts @@ -1007,9 +1007,7 @@ function getCanQuoteRule( ctx: Context, ): InteractionRule { const policy = - post.visibility === "private" || post.visibility === "direct" - ? "nobody" - : post.quoteApprovalPolicy; + post.visibility === "direct" ? "nobody" : post.quoteApprovalPolicy; if (policy === "public") { return new InteractionRule({ automaticApproval: vocab.PUBLIC_COLLECTION, From f1d32cde7cb64623289783a893a26ab5a5ba7362 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 21:01:55 +0900 Subject: [PATCH 21/30] Harden inbound quote request handling Reject inbound QuoteRequest updates when the persisted quote row does not belong to the request instrument actor, including locally owned quotes that must not be mutated through this remote inbox path. Create quote notifications when a QuoteRequest is accepted so local authors are notified even when the request is the only received activity. Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3153898249 Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3153898257 Assisted-by: Codex:gpt-5.5 --- src/federation/inbox.test.ts | 147 ++++++++++++++++++++++++++++++++++- src/federation/inbox.ts | 16 +++- 2 files changed, 158 insertions(+), 5 deletions(-) diff --git a/src/federation/inbox.test.ts b/src/federation/inbox.test.ts index ef047d22..00a2db5d 100644 --- a/src/federation/inbox.test.ts +++ b/src/federation/inbox.test.ts @@ -15,7 +15,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { cleanDatabase } from "../../tests/helpers"; import { createAccount } from "../../tests/helpers/oauth"; import db from "../db"; -import { accounts, blocks, follows, instances, posts } from "../schema"; +import { + accounts, + blocks, + follows, + instances, + notifications, + posts, +} from "../schema"; import type { Uuid } from "../uuid"; import { onFollowAccepted, @@ -690,6 +697,144 @@ describe("quote request lifecycle", () => { expect(sendActivity).toHaveBeenCalledOnce(); }); + it("ignores a QuoteRequest for an existing quote owned by another actor", async () => { + expect.assertions(5); + + const author = await createAccount({ username: "quote-author" }); + const localQuoter = await createAccount({ username: "local-quoter" }); + const quotedPostId = crypto.randomUUID() as Uuid; + const localQuotePostId = crypto.randomUUID() as Uuid; + const quotedPostIri = `https://hollo.test/@quote-author/${quotedPostId}`; + const localQuotePostIri = `https://hollo.test/@local-quoter/${localQuotePostId}`; + const sendActivity = vi.fn(async () => undefined); + const requestCtx = { + ...ctx, + sendActivity, + } as unknown as InboxContext; + + await db.insert(posts).values([ + { + id: quotedPostId, + iri: quotedPostIri, + type: "Note", + accountId: author.id as Uuid, + visibility: "public", + quoteApprovalPolicy: "public", + contentHtml: "

Quoted post

", + content: "Quoted post", + published: new Date(), + }, + { + id: localQuotePostId, + iri: localQuotePostIri, + type: "Note", + accountId: localQuoter.id as Uuid, + quoteTargetIri: quotedPostIri, + quoteState: "unauthorized", + visibility: "public", + contentHtml: "

Local quote

", + content: "Local quote", + published: new Date(), + }, + ]); + + const request = new QuoteRequest({ + actor: new URL("https://remote.test/@attacker"), + object: new URL(quotedPostIri), + instrument: new Note({ + id: new URL(localQuotePostIri), + attribution: new Person({ + id: new URL("https://remote.test/@attacker"), + name: "attacker", + preferredUsername: "attacker", + inbox: new URL("https://remote.test/@attacker/inbox"), + }), + quote: new URL(quotedPostIri), + to: new URL("https://www.w3.org/ns/activitystreams#Public"), + content: "

Forged quote request

", + }), + }); + + await onQuoteRequested(requestCtx, request); + + const quote = await db.query.posts.findFirst({ + where: eq(posts.id, localQuotePostId), + }); + const quoted = await db.query.posts.findFirst({ + where: eq(posts.id, quotedPostId), + }); + expect(quote?.quoteState).toBe("unauthorized"); + expect(quote?.quoteAuthorizationIri).toBeNull(); + expect(quote?.accountId).toBe(localQuoter.id); + expect(quoted?.quotesCount).toBe(0); + expect(sendActivity).not.toHaveBeenCalled(); + }); + + it("creates a quote notification for accepted QuoteRequests", async () => { + expect.assertions(5); + + const author = await createAccount({ username: "quote-author" }); + const quotedPostId = crypto.randomUUID() as Uuid; + const quotedPostIri = `https://hollo.test/@quote-author/${quotedPostId}`; + const quotePostIri = "https://remote.test/@quoter/quote-notified"; + const sendActivity = vi.fn(async () => undefined); + const requestCtx = { + ...ctx, + sendActivity, + } as unknown as InboxContext; + + await db.insert(posts).values({ + id: quotedPostId, + iri: quotedPostIri, + type: "Note", + accountId: author.id as Uuid, + visibility: "public", + quoteApprovalPolicy: "public", + contentHtml: "

Quoted post

", + content: "Quoted post", + published: new Date(), + }); + + const request = new QuoteRequest({ + actor: new URL("https://remote.test/@quoter"), + object: new URL(quotedPostIri), + instrument: new Note({ + id: new URL(quotePostIri), + attribution: new Person({ + id: new URL("https://remote.test/@quoter"), + name: "quoter", + preferredUsername: "quoter", + inbox: new URL("https://remote.test/@quoter/inbox"), + }), + quote: new URL(quotedPostIri), + to: new URL("https://www.w3.org/ns/activitystreams#Public"), + content: "

Remote quote

", + }), + }); + + await onQuoteRequested(requestCtx, request); + + const quote = await db.query.posts.findFirst({ + where: eq(posts.iri, quotePostIri), + with: { account: true }, + }); + if (quote == null) throw new Error("Failed to persist quote"); + const notification = await db.query.notifications.findFirst({ + where: and( + eq(notifications.type, "quote"), + eq(notifications.accountOwnerId, author.id as Uuid), + eq(notifications.actorAccountId, quote.accountId), + eq(notifications.targetPostId, quote.id), + ), + }); + + expect(quote.quoteState).toBe("accepted"); + expect(quote.account.iri).toBe("https://remote.test/@quoter"); + expect(notification).toBeDefined(); + expect(notification?.targetPostId).toBe(quote.id); + expect(sendActivity).toHaveBeenCalledOnce(); + }); + it("keeps repeated QuoteRequest deliveries idempotent for accepted quotes", async () => { expect.assertions(4); diff --git a/src/federation/inbox.ts b/src/federation/inbox.ts index 9b52e1de..a74fbc5c 100644 --- a/src/federation/inbox.ts +++ b/src/federation/inbox.ts @@ -643,10 +643,9 @@ export async function onQuoteRequested( if (target?.account.owner == null) return; const instrument = await request.getInstrument({ crossOrigin: "trust" }); if (!isPost(instrument)) return; - if ( - request.actorId != null && - request.actorId.href !== instrument.attributionId?.href - ) { + const quoteActorIri = instrument.attributionId?.href; + if (quoteActorIri == null) return; + if (request.actorId != null && request.actorId.href !== quoteActorIri) { return; } const existingQuote = @@ -662,6 +661,8 @@ export async function onQuoteRequested( getPersistOptions(ctx), ); if (persistedQuote == null) return; + if (persistedQuote.account.owner != null) return; + if (persistedQuote.account.iri !== quoteActorIri) return; if (persistedQuote.quoteTargetIri !== target.iri) return; const wasAccepted = @@ -692,6 +693,13 @@ export async function onQuoteRequested( await updatePostStats(tx, { id: target.id }); } }); + if (accepted) { + await createQuoteNotification( + persistedQuote.account, + persistedQuote, + target, + ); + } const recipient = { id: new URL(persistedQuote.account.iri), From 1c108e42b70f3d4a8a0e675767dc063f069219a4 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 21:15:40 +0900 Subject: [PATCH 22/30] Gate outbound quote fields by state Only publish FEP-044f quote and quoteUrl fields for accepted quotes or legacy quotes without a stored quote state. This prevents pending, rejected, revoked, or unauthorized quote rows from federating as active quotes. Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3153972244 Assisted-by: Codex:gpt-5.5 --- src/federation/post.test.ts | 42 +++++++++++++++++++++++++++++++++++++ src/federation/post.ts | 5 ++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/federation/post.test.ts b/src/federation/post.test.ts index 2415a506..a18d0e5f 100644 --- a/src/federation/post.test.ts +++ b/src/federation/post.test.ts @@ -650,6 +650,48 @@ describe("toObject", () => { }); }); + it.each(["pending", "rejected", "revoked", "unauthorized"] as const)( + "omits quote fields for %s quotes", + async (quoteState) => { + expect.assertions(2); + + const account = await createAccount({ username: "quote-author" }); + const quotedPostId = crypto.randomUUID() as Uuid; + const quotePostId = crypto.randomUUID() as Uuid; + + await db.insert(posts).values([ + { + id: quotedPostId, + iri: "https://remote.test/objects/inactive-quote-target", + type: "Note", + accountId: account.id as Uuid, + visibility: "public", + contentHtml: "

Quoted post

", + content: "Quoted post", + published: new Date(), + }, + { + id: quotePostId, + iri: `https://hollo.test/@quote-author/${quotePostId}`, + type: "Note", + accountId: account.id as Uuid, + quoteTargetId: quotedPostId, + quoteTargetIri: "https://remote.test/objects/inactive-quote-target", + quoteState, + visibility: "public", + contentHtml: "

My inactive quote

", + content: "My inactive quote", + published: new Date(), + }, + ]); + + const json = await getObjectJson(quotePostId); + + expect(json).not.toHaveProperty("quote"); + expect(json).not.toHaveProperty("quoteUrl"); + }, + ); + it("emits private follower quote policies", async () => { expect.assertions(1); diff --git a/src/federation/post.ts b/src/federation/post.ts index 61efcb9f..912542ef 100644 --- a/src/federation/post.ts +++ b/src/federation/post.ts @@ -861,7 +861,10 @@ export function toObject( post.contentHtml, post.quoteTarget, ); - const quoteTargetIri = post.quoteTargetIri ?? post.quoteTarget?.iri; + const quoteTargetIri = + post.quoteState == null || post.quoteState === "accepted" + ? (post.quoteTargetIri ?? post.quoteTarget?.iri) + : null; return new cls({ id: new URL(post.iri), attribution: new URL(post.account.iri), From 39bb3d2235d0c08cbcbca244af5bc687579b0a47 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 21:27:36 +0900 Subject: [PATCH 23/30] Separate quote request serialization Hide quote fallback content and Link tags for inactive quote states during normal ActivityPub serialization, while still including the quote target in pending QuoteRequest instruments so receivers can process the request. Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3154062654 Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3154062662 Assisted-by: Codex:gpt-5.5 --- src/api/v1/statuses.test.ts | 71 +++++++++++++++++++++++++++++++++++++ src/api/v1/statuses.ts | 4 ++- src/federation/post.test.ts | 3 +- src/federation/post.ts | 30 ++++++++-------- 4 files changed, 92 insertions(+), 16 deletions(-) diff --git a/src/api/v1/statuses.test.ts b/src/api/v1/statuses.test.ts index 2795e462..25202eb7 100644 --- a/src/api/v1/statuses.test.ts +++ b/src/api/v1/statuses.test.ts @@ -537,6 +537,77 @@ describe.sequential("/api/v1/statuses quotes", () => { fetch.mockRestore(); } }); + + it("includes the quote target in pending QuoteRequest instruments", async () => { + expect.assertions(6); + + const remoteAccountId = uuidv7(); + const quotedPostId = uuidv7(); + const quotedPostIri = `https://remote.test/@author/${quotedPostId}`; + + await db.insert(instances).values({ host: "remote.test" }); + await db.insert(accounts).values({ + id: remoteAccountId, + iri: "https://remote.test/@author", + type: "Person", + name: "Remote author", + handle: "@author@remote.test", + bioHtml: "", + protected: false, + inboxUrl: "https://remote.test/@author/inbox", + sharedInboxUrl: "https://remote.test/inbox", + instanceHost: "remote.test", + }); + await db.insert(posts).values({ + id: quotedPostId, + iri: quotedPostIri, + type: "Note", + accountId: remoteAccountId, + visibility: "public", + quoteApprovalPolicy: "public", + content: "Remote quoted post", + contentHtml: "

Remote quoted post

\n", + url: quotedPostIri, + published: new Date(), + }); + + const fetch = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response(null, { status: 202 })); + try { + const quoteResponse = await createStatus(quoterToken, { + status: "Requesting quote authorization", + quoted_status_id: quotedPostId, + }); + expect(quoteResponse.status).toBe(200); + const quote = await quoteResponse.json(); + expect(quote.quote.state).toBe("pending"); + + let quoteRequest: unknown; + await vi.waitFor(async () => { + for (const [input] of fetch.mock.calls) { + const request = input instanceof Request ? input : null; + const activity = + request == null ? null : await request.clone().json(); + if (activity?.type === "QuoteRequest") { + quoteRequest = activity; + return; + } + } + throw new Error("QuoteRequest was not sent"); + }); + + expect(quoteRequest).toBeDefined(); + const instrument = ( + quoteRequest as { instrument?: Record } + ).instrument; + expect(instrument?.quote).toBe(quotedPostIri); + expect(instrument?.quoteUrl).toBe(quotedPostIri); + expect(JSON.stringify(instrument)).toContain(quotedPostIri); + } finally { + fetch.mockRestore(); + } + }); }); describe.sequential("/api/v1/statuses visibility", () => { diff --git a/src/api/v1/statuses.ts b/src/api/v1/statuses.ts index 9182f4f3..7e0654c8 100644 --- a/src/api/v1/statuses.ts +++ b/src/api/v1/statuses.ts @@ -522,7 +522,9 @@ app.post("/", tokenRequired, scopeRequired(["write:statuses"]), async (c) => { id: new URL("#quote-request", post.iri), actor: new URL(owner.account.iri), object: new URL(post.quoteTarget.iri), - instrument: toObject(post, fedCtx), + instrument: toObject(post, fedCtx, { + includeInactiveQuoteTarget: true, + }), }), { orderingKey, diff --git a/src/federation/post.test.ts b/src/federation/post.test.ts index a18d0e5f..c6107965 100644 --- a/src/federation/post.test.ts +++ b/src/federation/post.test.ts @@ -653,7 +653,7 @@ describe("toObject", () => { it.each(["pending", "rejected", "revoked", "unauthorized"] as const)( "omits quote fields for %s quotes", async (quoteState) => { - expect.assertions(2); + expect.assertions(3); const account = await createAccount({ username: "quote-author" }); const quotedPostId = crypto.randomUUID() as Uuid; @@ -689,6 +689,7 @@ describe("toObject", () => { expect(json).not.toHaveProperty("quote"); expect(json).not.toHaveProperty("quoteUrl"); + expect(JSON.stringify(json)).not.toContain("inactive-quote-target"); }, ); diff --git a/src/federation/post.ts b/src/federation/post.ts index 912542ef..d4766481 100644 --- a/src/federation/post.ts +++ b/src/federation/post.ts @@ -838,6 +838,7 @@ export function toObject( replies: Post[]; }, ctx: Context, + opts: { includeInactiveQuoteTarget?: boolean } = {}, ): ASPost { const cls = post.type === "Question" @@ -857,14 +858,15 @@ export function toObject( replies: new Collection({ totalItems: o.votesCount }), }), ); - const contentHtml = addQuoteInlineFallback( - post.contentHtml, - post.quoteTarget, - ); - const quoteTargetIri = - post.quoteState == null || post.quoteState === "accepted" - ? (post.quoteTargetIri ?? post.quoteTarget?.iri) - : null; + const shouldPublishQuoteTarget = + opts.includeInactiveQuoteTarget || + post.quoteState == null || + post.quoteState === "accepted"; + const quoteTarget = shouldPublishQuoteTarget ? post.quoteTarget : null; + const contentHtml = addQuoteInlineFallback(post.contentHtml, quoteTarget); + const quoteTargetIri = shouldPublishQuoteTarget + ? (post.quoteTargetIri ?? post.quoteTarget?.iri) + : null; return new cls({ id: new URL(post.iri), attribution: new URL(post.account.iri), @@ -921,18 +923,18 @@ export function toObject( ...Object.entries(post.emojis).map(([shortcode, url]) => toEmoji(ctx, { shortcode, url }), ), - ...(post.quoteTarget == null + ...(quoteTarget == null ? [] : [ new Link({ mediaType: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', - href: new URL(post.quoteTarget.iri), + href: new URL(quoteTarget.iri), name: - post.quoteTarget.url != null && - post.content?.includes(post.quoteTarget.url) - ? post.quoteTarget.url - : post.quoteTarget.iri, + quoteTarget.url != null && + post.content?.includes(quoteTarget.url) + ? quoteTarget.url + : quoteTarget.iri, }), ]), ], From 03101d0942a518fd4e8a52a60f83673a959f9c14 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 21:41:44 +0900 Subject: [PATCH 24/30] Guard direct policy update federation Avoid follower fan-out when the interaction policy endpoint updates a direct status, while still delivering the update to explicitly mentioned recipients. Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3154127933 Assisted-by: Codex:gpt-5.5 --- src/api/v1/statuses.test.ts | 100 +++++++++++++++++++++++++++++++++++- src/api/v1/statuses.ts | 22 ++++---- 2 files changed, 111 insertions(+), 11 deletions(-) diff --git a/src/api/v1/statuses.test.ts b/src/api/v1/statuses.test.ts index 25202eb7..8b7216ce 100644 --- a/src/api/v1/statuses.test.ts +++ b/src/api/v1/statuses.test.ts @@ -10,7 +10,7 @@ import { } from "../../../tests/helpers/oauth"; import db from "../../db"; import app from "../../index"; -import { accounts, follows, instances, posts } from "../../schema"; +import { accounts, follows, instances, mentions, posts } from "../../schema"; import { uuidv7 } from "../../uuid"; describe.sequential("/api/v1/accounts/verify_credentials", () => { @@ -313,6 +313,104 @@ describe.sequential("/api/v1/statuses quotes", () => { expect(updated.quote_approval.manual).toEqual([]); }); + it("does not fan out direct interaction policy updates to followers", async () => { + expect.assertions(3); + + const mentionedAccountId = uuidv7(); + const followerAccountId = uuidv7(); + const directPostId = uuidv7(); + const directPostIri = `https://hollo.test/@quote-author/${directPostId}`; + + await db.insert(instances).values({ host: "remote.test" }); + await db.insert(accounts).values([ + { + id: mentionedAccountId, + iri: "https://remote.test/@mentioned", + type: "Person", + name: "Mentioned", + handle: "@mentioned@remote.test", + bioHtml: "", + protected: false, + inboxUrl: "https://remote.test/@mentioned/inbox", + sharedInboxUrl: "https://remote.test/inbox", + instanceHost: "remote.test", + }, + { + id: followerAccountId, + iri: "https://remote.test/@follower", + type: "Person", + name: "Follower", + handle: "@follower@remote.test", + bioHtml: "", + protected: false, + inboxUrl: "https://remote.test/@follower/inbox", + sharedInboxUrl: "https://remote.test/followers-inbox", + instanceHost: "remote.test", + }, + ]); + await db.insert(follows).values({ + iri: `https://remote.test/@follower#follows/${crypto.randomUUID()}`, + followingId: author.id, + followerId: followerAccountId, + approved: new Date(), + }); + await db.insert(posts).values({ + id: directPostId, + iri: directPostIri, + type: "Note", + accountId: author.id, + visibility: "direct", + quoteApprovalPolicy: "public", + content: "@mentioned@remote.test Private quote policy update", + contentHtml: + "

@mentioned@remote.test Private quote policy update

\n", + url: directPostIri, + published: new Date(), + }); + await db.insert(mentions).values({ + postId: directPostId, + accountId: mentionedAccountId, + }); + + const fetch = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response(null, { status: 202 })); + try { + const response = await app.request( + `/api/v1/statuses/${directPostId}/interaction_policy`, + { + method: "PUT", + headers: { + authorization: bearerAuthorization(authorToken), + "Content-Type": "application/json", + }, + body: JSON.stringify({ quote_approval_policy: "nobody" }), + }, + ); + expect(response.status).toBe(200); + + await vi.waitFor(() => { + if ( + !fetch.mock.calls.some(([input]) => { + const url = input instanceof Request ? input.url : input.toString(); + return url === "https://remote.test/@mentioned/inbox"; + }) + ) { + throw new Error("Direct update was not sent to the mentioned actor"); + } + }); + expect( + fetch.mock.calls.some(([input]) => { + const url = input instanceof Request ? input.url : input.toString(); + return url === "https://remote.test/followers-inbox"; + }), + ).toBe(false); + expect(fetch).toHaveBeenCalledOnce(); + } finally { + fetch.mockRestore(); + } + }); + it("reports quote approval as automatic for approved followers", async () => { expect.assertions(4); diff --git a/src/api/v1/statuses.ts b/src/api/v1/statuses.ts index 7e0654c8..a002cfd3 100644 --- a/src/api/v1/statuses.ts +++ b/src/api/v1/statuses.ts @@ -704,16 +704,18 @@ app.put( excludeBaseUris: [new URL(c.req.url)], }, ); - await fedCtx.sendActivity( - { username: owner.handle }, - "followers", - activity, - { - orderingKey, - preferSharedInbox: true, - excludeBaseUris: [new URL(c.req.url)], - }, - ); + if (updatedPost.visibility !== "direct") { + await fedCtx.sendActivity( + { username: owner.handle }, + "followers", + activity, + { + orderingKey, + preferSharedInbox: true, + excludeBaseUris: [new URL(c.req.url)], + }, + ); + } return c.json(serializePost(updatedPost, owner, c.req.url)); }, ); From f6050532f01fe9eb5b2be5fcaf2b8aaf7ff13bf4 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 22:02:33 +0900 Subject: [PATCH 25/30] Honor private quote restrictions Treat private status quote policies as effectively nobody across the Mastodon API serializer, local quote validation, ActivityPub canQuote serialization, and inbound QuoteRequest auto-accept handling. Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3154226641 Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3154226644 Assisted-by: Codex:gpt-5.5 --- src/api/v1/statuses.test.ts | 26 ++++++++++++++++---------- src/api/v1/statuses.ts | 6 ++++++ src/entities/status.ts | 5 ++++- src/federation/inbox.test.ts | 10 ++++------ src/federation/inbox.ts | 4 +++- src/federation/post.test.ts | 8 ++++---- src/federation/post.ts | 4 +++- 7 files changed, 40 insertions(+), 23 deletions(-) diff --git a/src/api/v1/statuses.test.ts b/src/api/v1/statuses.test.ts index 8b7216ce..7358ecf8 100644 --- a/src/api/v1/statuses.test.ts +++ b/src/api/v1/statuses.test.ts @@ -439,7 +439,7 @@ describe.sequential("/api/v1/statuses quotes", () => { expect(status.quote_approval.current_user).toBe("automatic"); }); - it("preserves private quote approval for approved followers", async () => { + it("treats private quote approval as nobody for followers", async () => { expect.assertions(4); await db.insert(follows).values({ @@ -450,7 +450,7 @@ describe.sequential("/api/v1/statuses quotes", () => { }); const quotedResponse = await createStatus(authorToken, { - status: "Approved followers can quote this private status", + status: "Approved followers cannot quote this private status", visibility: "private", quote_approval_policy: "followers", }); @@ -464,11 +464,11 @@ describe.sequential("/api/v1/statuses quotes", () => { }); expect(response.status).toBe(200); const status = await response.json(); - expect(status.quote_approval.automatic).toEqual(["followers"]); - expect(status.quote_approval.current_user).toBe("automatic"); + expect(status.quote_approval.automatic).toEqual([]); + expect(status.quote_approval.current_user).toBe("denied"); }); - it("allows approved followers to quote private statuses", async () => { + it("denies followers quoting private statuses", async () => { expect.assertions(4); await db.insert(follows).values({ @@ -479,7 +479,7 @@ describe.sequential("/api/v1/statuses quotes", () => { }); const quotedResponse = await createStatus(authorToken, { - status: "Followers can quote this private status", + status: "Followers cannot quote this private status", visibility: "private", }); expect(quotedResponse.status).toBe(200); @@ -490,10 +490,16 @@ describe.sequential("/api/v1/statuses quotes", () => { quoted_status_id: quoted.id, visibility: "public", }); - expect(quoteResponse.status).toBe(200); - const quote = await quoteResponse.json(); - expect(quote.visibility).toBe("private"); - expect(quote.quote.state).toBe("accepted"); + expect(quoteResponse.status).toBe(422); + + const selfQuoteResponse = await createStatus(authorToken, { + status: "Self quoting this private status", + quoted_status_id: quoted.id, + visibility: "private", + }); + expect(selfQuoteResponse.status).toBe(200); + const selfQuote = await selfQuoteResponse.json(); + expect(selfQuote.quote.state).toBe("accepted"); }); it("returns revoked quote state when a quote is revoked", async () => { diff --git a/src/api/v1/statuses.ts b/src/api/v1/statuses.ts index a002cfd3..f95b652e 100644 --- a/src/api/v1/statuses.ts +++ b/src/api/v1/statuses.ts @@ -209,6 +209,12 @@ async function validateQuoteTarget( if (quoteTarget.visibility === "direct") { return { ok: false, status: 422, error: "Cannot quote a direct message" }; } + if ( + quoteTarget.visibility === "private" && + quoteTarget.accountId !== owner.id + ) { + return { ok: false, status: 422, error: "Quote target is not quotable" }; + } let visibility = requestedVisibility; if ( diff --git a/src/entities/status.ts b/src/entities/status.ts index c61b81d9..f7f04497 100644 --- a/src/entities/status.ts +++ b/src/entities/status.ts @@ -53,7 +53,10 @@ function serializeQuoteApproval( post: Pick, viewerIsApprovedFollower: boolean, ) { - const effectivePolicy = post.visibility === "direct" ? "nobody" : policy; + const effectivePolicy = + post.visibility === "direct" || post.visibility === "private" + ? "nobody" + : policy; const automatic = effectivePolicy === "public" ? ["public"] diff --git a/src/federation/inbox.test.ts b/src/federation/inbox.test.ts index 00a2db5d..4bbd0c2b 100644 --- a/src/federation/inbox.test.ts +++ b/src/federation/inbox.test.ts @@ -894,7 +894,7 @@ describe("quote request lifecycle", () => { expect(sendActivity).toHaveBeenCalledTimes(2); }); - it("accepts a private QuoteRequest from an approved follower", async () => { + it("rejects a private QuoteRequest from an approved follower", async () => { expect.assertions(4); const author = await createAccount({ username: "quote-author" }); @@ -952,11 +952,9 @@ describe("quote request lifecycle", () => { const quoted = await db.query.posts.findFirst({ where: eq(posts.id, quotedPostId), }); - expect(quote?.quoteState).toBe("accepted"); - expect(quote?.quoteAuthorizationIri).toBe( - `${quotedPostIri}/quote_authorizations/${quote?.id}`, - ); - expect(quoted?.quotesCount).toBe(1); + expect(quote?.quoteState).toBe("rejected"); + expect(quote?.quoteAuthorizationIri).toBeNull(); + expect(quoted?.quotesCount).toBe(0); expect(sendActivity).toHaveBeenCalledOnce(); }); diff --git a/src/federation/inbox.ts b/src/federation/inbox.ts index a74fbc5c..7366391a 100644 --- a/src/federation/inbox.ts +++ b/src/federation/inbox.ts @@ -604,7 +604,9 @@ async function canAutomaticallyAcceptQuoteRequest( quote: Post, ): Promise { if (target.accountId === quote.accountId) return true; - if (target.visibility === "direct") return false; + if (target.visibility === "direct" || target.visibility === "private") { + return false; + } const block = await db.query.blocks.findFirst({ where: or( and( diff --git a/src/federation/post.test.ts b/src/federation/post.test.ts index c6107965..dcbb72f2 100644 --- a/src/federation/post.test.ts +++ b/src/federation/post.test.ts @@ -693,7 +693,7 @@ describe("toObject", () => { }, ); - it("emits private follower quote policies", async () => { + it("emits author-only quote policy for private statuses", async () => { expect.assertions(1); const account = await createAccount({ username: "quote-author" }); @@ -706,8 +706,8 @@ describe("toObject", () => { accountId: account.id as Uuid, visibility: "private", quoteApprovalPolicy: "followers", - contentHtml: "

Followers can quote this

", - content: "Followers can quote this", + contentHtml: "

Followers cannot quote this

", + content: "Followers cannot quote this", published: new Date(), }); @@ -719,7 +719,7 @@ describe("toObject", () => { expect(json).toMatchObject({ interactionPolicy: { canQuote: { - automaticApproval: "https://hollo.test/@quote-author/followers", + automaticApproval: "https://hollo.test/@quote-author", }, }, }); diff --git a/src/federation/post.ts b/src/federation/post.ts index d4766481..1ded7731 100644 --- a/src/federation/post.ts +++ b/src/federation/post.ts @@ -1012,7 +1012,9 @@ function getCanQuoteRule( ctx: Context, ): InteractionRule { const policy = - post.visibility === "direct" ? "nobody" : post.quoteApprovalPolicy; + post.visibility === "direct" || post.visibility === "private" + ? "nobody" + : post.quoteApprovalPolicy; if (policy === "public") { return new InteractionRule({ automaticApproval: vocab.PUBLIC_COLLECTION, From f19bb250f1b511fd87d266cb7db5f86f6ac275b7 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 22:21:29 +0900 Subject: [PATCH 26/30] Recompute old retargeted quote counts Refresh the previous accepted quote target when an inbound QuoteRequest moves the same remote quote object to a different local target, so stale quotes_count values do not remain on the old target. Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3154400244 Assisted-by: Codex:gpt-5.5 --- src/federation/inbox.test.ts | 95 ++++++++++++++++++++++++++++++++++++ src/federation/inbox.ts | 10 ++++ 2 files changed, 105 insertions(+) diff --git a/src/federation/inbox.test.ts b/src/federation/inbox.test.ts index 4bbd0c2b..7516055d 100644 --- a/src/federation/inbox.test.ts +++ b/src/federation/inbox.test.ts @@ -894,6 +894,101 @@ describe("quote request lifecycle", () => { expect(sendActivity).toHaveBeenCalledTimes(2); }); + it("recomputes quote counts when accepted quotes are retargeted", async () => { + expect.assertions(6); + + const author = await createAccount({ username: "quote-author" }); + const oldPostId = crypto.randomUUID() as Uuid; + const newPostId = crypto.randomUUID() as Uuid; + const oldPostIri = `https://hollo.test/@quote-author/${oldPostId}`; + const newPostIri = `https://hollo.test/@quote-author/${newPostId}`; + const quotePostIri = "https://remote.test/@quoter/quote-retargeted"; + const sendActivity = vi.fn(async () => undefined); + const requestCtx = { + ...ctx, + sendActivity, + } as unknown as InboxContext; + + await db.insert(posts).values([ + { + id: oldPostId, + iri: oldPostIri, + type: "Note", + accountId: author.id as Uuid, + visibility: "public", + quoteApprovalPolicy: "public", + contentHtml: "

Old quoted post

", + content: "Old quoted post", + published: new Date(), + }, + { + id: newPostId, + iri: newPostIri, + type: "Note", + accountId: author.id as Uuid, + visibility: "public", + quoteApprovalPolicy: "public", + contentHtml: "

New quoted post

", + content: "New quoted post", + published: new Date(), + }, + ]); + + const oldRequest = new QuoteRequest({ + actor: new URL("https://remote.test/@quoter"), + object: new URL(oldPostIri), + instrument: new Note({ + id: new URL(quotePostIri), + attribution: new Person({ + id: new URL("https://remote.test/@quoter"), + name: "quoter", + preferredUsername: "quoter", + inbox: new URL("https://remote.test/@quoter/inbox"), + }), + quote: new URL(oldPostIri), + to: new URL("https://www.w3.org/ns/activitystreams#Public"), + content: "

Remote quote

", + }), + }); + const newRequest = new QuoteRequest({ + actor: new URL("https://remote.test/@quoter"), + object: new URL(newPostIri), + instrument: new Note({ + id: new URL(quotePostIri), + attribution: new Person({ + id: new URL("https://remote.test/@quoter"), + name: "quoter", + preferredUsername: "quoter", + inbox: new URL("https://remote.test/@quoter/inbox"), + }), + quote: new URL(newPostIri), + to: new URL("https://www.w3.org/ns/activitystreams#Public"), + content: "

Remote quote retargeted

", + }), + }); + + await onQuoteRequested(requestCtx, oldRequest); + await onQuoteRequested(requestCtx, newRequest); + + const quote = await db.query.posts.findFirst({ + where: eq(posts.iri, quotePostIri), + }); + const oldPost = await db.query.posts.findFirst({ + where: eq(posts.id, oldPostId), + }); + const newPost = await db.query.posts.findFirst({ + where: eq(posts.id, newPostId), + }); + expect(quote?.quoteTargetId).toBe(newPostId); + expect(quote?.quoteState).toBe("accepted"); + expect(quote?.quoteAuthorizationIri).toBe( + `${newPostIri}/quote_authorizations/${quote?.id}`, + ); + expect(oldPost?.quotesCount).toBe(0); + expect(newPost?.quotesCount).toBe(1); + expect(sendActivity).toHaveBeenCalledTimes(2); + }); + it("rejects a private QuoteRequest from an approved follower", async () => { expect.assertions(4); diff --git a/src/federation/inbox.ts b/src/federation/inbox.ts index 7366391a..93aa5986 100644 --- a/src/federation/inbox.ts +++ b/src/federation/inbox.ts @@ -671,6 +671,10 @@ export async function onQuoteRequested( existingQuote?.quoteState === "accepted" && (existingQuote.quoteTargetId === target.id || existingQuote.quoteTargetIri === target.iri); + const previousAcceptedTargetId = + existingQuote?.quoteState === "accepted" + ? existingQuote.quoteTargetId + : null; const accepted = wasAccepted || (await canAutomaticallyAcceptQuoteRequest(target, persistedQuote)); @@ -694,6 +698,12 @@ export async function onQuoteRequested( } else if (accepted) { await updatePostStats(tx, { id: target.id }); } + if ( + previousAcceptedTargetId != null && + previousAcceptedTargetId !== target.id + ) { + await updatePostStats(tx, { id: previousAcceptedTargetId }); + } }); if (accepted) { await createQuoteNotification( From c461e72197ec959d9f9d727acba184e458c9ba58 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 22:41:26 +0900 Subject: [PATCH 27/30] Preserve revoked quote requests Ignore retried inbound QuoteRequests for quote objects that were already revoked locally, so at-least-once delivery cannot silently reactivate a revoked quote authorization. Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3154492876 Assisted-by: Codex:gpt-5.5 --- src/federation/inbox.test.ts | 75 ++++++++++++++++++++++++++++++++++++ src/federation/inbox.ts | 1 + 2 files changed, 76 insertions(+) diff --git a/src/federation/inbox.test.ts b/src/federation/inbox.test.ts index 7516055d..0f968319 100644 --- a/src/federation/inbox.test.ts +++ b/src/federation/inbox.test.ts @@ -989,6 +989,81 @@ describe("quote request lifecycle", () => { expect(sendActivity).toHaveBeenCalledTimes(2); }); + it("preserves revoked quotes when QuoteRequests are retried", async () => { + expect.assertions(4); + + const author = await createAccount({ username: "quote-author" }); + const quotedPostId = crypto.randomUUID() as Uuid; + const quotedPostIri = `https://hollo.test/@quote-author/${quotedPostId}`; + const quotePostIri = "https://remote.test/@quoter/quote-retried"; + const sendActivity = vi.fn(async () => undefined); + const requestCtx = { + ...ctx, + sendActivity, + } as unknown as InboxContext; + + await db.insert(posts).values({ + id: quotedPostId, + iri: quotedPostIri, + type: "Note", + accountId: author.id as Uuid, + visibility: "public", + quoteApprovalPolicy: "public", + contentHtml: "

Quoted post

", + content: "Quoted post", + published: new Date(), + }); + + const request = new QuoteRequest({ + actor: new URL("https://remote.test/@quoter"), + object: new URL(quotedPostIri), + instrument: new Note({ + id: new URL(quotePostIri), + attribution: new Person({ + id: new URL("https://remote.test/@quoter"), + name: "quoter", + preferredUsername: "quoter", + inbox: new URL("https://remote.test/@quoter/inbox"), + }), + quote: new URL(quotedPostIri), + to: new URL("https://www.w3.org/ns/activitystreams#Public"), + content: "

Remote quote

", + }), + }); + + await onQuoteRequested(requestCtx, request); + const acceptedQuote = await db.query.posts.findFirst({ + where: eq(posts.iri, quotePostIri), + }); + if (acceptedQuote == null) throw new Error("Failed to persist quote"); + await db + .update(posts) + .set({ + quoteState: "revoked", + quoteAuthorizationIri: null, + updated: new Date(), + }) + .where(eq(posts.id, acceptedQuote.id)); + await db + .update(posts) + .set({ quotesCount: 0 }) + .where(eq(posts.id, quotedPostId)); + sendActivity.mockClear(); + + await onQuoteRequested(requestCtx, request); + + const quote = await db.query.posts.findFirst({ + where: eq(posts.iri, quotePostIri), + }); + const quoted = await db.query.posts.findFirst({ + where: eq(posts.id, quotedPostId), + }); + expect(quote?.quoteState).toBe("revoked"); + expect(quote?.quoteAuthorizationIri).toBeNull(); + expect(quoted?.quotesCount).toBe(0); + expect(sendActivity).not.toHaveBeenCalled(); + }); + it("rejects a private QuoteRequest from an approved follower", async () => { expect.assertions(4); diff --git a/src/federation/inbox.ts b/src/federation/inbox.ts index 93aa5986..75a05c6b 100644 --- a/src/federation/inbox.ts +++ b/src/federation/inbox.ts @@ -656,6 +656,7 @@ export async function onQuoteRequested( : await db.query.posts.findFirst({ where: eq(posts.iri, instrument.id.href), }); + if (existingQuote?.quoteState === "revoked") return; const persistedQuote = await persistPost( db, instrument, From 9dd007b5c3254fc53678f14f871b494e302d4ffc Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 22:57:49 +0900 Subject: [PATCH 28/30] Route embedded quote auth deletes Handle Delete activities whose object is an embedded QuoteAuthorization without an object id, so quote revocations can still locate the interacting object and clear accepted quote state. Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3154629637 Assisted-by: Codex:gpt-5.5 --- src/federation/index.test.ts | 84 ++++++++++++++++++++++++++++++++++-- src/federation/index.ts | 48 ++++++++++++++------- 2 files changed, 113 insertions(+), 19 deletions(-) diff --git a/src/federation/index.test.ts b/src/federation/index.test.ts index 2e61f545..79cc3bdc 100644 --- a/src/federation/index.test.ts +++ b/src/federation/index.test.ts @@ -1,5 +1,5 @@ -import type { UnverifiedActivityReason } from "@fedify/fedify"; -import { Delete, Follow } from "@fedify/vocab"; +import type { InboxContext, UnverifiedActivityReason } from "@fedify/fedify"; +import { Delete, Follow, QuoteAuthorization } from "@fedify/vocab"; import { eq } from "drizzle-orm"; import { beforeEach, describe, expect, it } from "vitest"; @@ -8,7 +8,11 @@ import { createAccount } from "../../tests/helpers/oauth"; import db from "../db"; import * as Schema from "../schema"; import type { Uuid } from "../uuid"; -import { onOutboxPermanentFailure, onUnverifiedActivity } from "./index"; +import { + onDeleted, + onOutboxPermanentFailure, + onUnverifiedActivity, +} from "./index"; async function createRemoteAccount( username: string, @@ -387,3 +391,77 @@ describe("onUnverifiedActivity", () => { expect(response).toBeUndefined(); }); }); + +describe("onDeleted", () => { + beforeEach(async () => { + await cleanDatabase(); + }); + + it("routes embedded QuoteAuthorization deletes to quote revocation", async () => { + expect.assertions(3); + + const author = await createAccount({ username: "quote-author" }); + const quoter = await createAccount({ username: "quote-quoter" }); + const quotedPostId = crypto.randomUUID() as Uuid; + const quotePostId = crypto.randomUUID() as Uuid; + const quotedPostIri = `https://hollo.test/@quote-author/${quotedPostId}`; + const quotePostIri = `https://hollo.test/@quote-quoter/${quotePostId}`; + const authorizationIri = `${quotedPostIri}/quote_authorizations/${quotePostId}`; + const sendActivity = async () => undefined; + const ctx = { + origin: "https://hollo.test", + recipient: "quote-quoter", + sendActivity, + } as unknown as InboxContext; + + await db.insert(Schema.posts).values([ + { + id: quotedPostId, + iri: quotedPostIri, + type: "Note", + accountId: author.id as Uuid, + visibility: "public", + contentHtml: "

Quoted post

", + content: "Quoted post", + quotesCount: 1, + published: new Date(), + }, + { + id: quotePostId, + iri: quotePostIri, + type: "Note", + accountId: quoter.id as Uuid, + quoteTargetId: quotedPostId, + quoteTargetIri: quotedPostIri, + quoteState: "accepted", + quoteAuthorizationIri: authorizationIri, + visibility: "public", + contentHtml: "

Quote post

", + content: "Quote post", + published: new Date(), + }, + ]); + + await onDeleted( + ctx, + new Delete({ + actor: new URL("https://hollo.test/@quote-author"), + object: new QuoteAuthorization({ + attribution: new URL("https://hollo.test/@quote-author"), + interactingObject: new URL(quotePostIri), + interactionTarget: new URL(quotedPostIri), + }), + }), + ); + + const quote = await db.query.posts.findFirst({ + where: eq(Schema.posts.id, quotePostId), + }); + const quoted = await db.query.posts.findFirst({ + where: eq(Schema.posts.id, quotedPostId), + }); + expect(quote?.quoteState).toBe("revoked"); + expect(quote?.quoteAuthorizationIri).toBeNull(); + expect(quoted?.quotesCount).toBe(0); + }); +}); diff --git a/src/federation/index.ts b/src/federation/index.ts index 8c2ee4eb..896964ce 100644 --- a/src/federation/index.ts +++ b/src/federation/index.ts @@ -13,6 +13,7 @@ import { Like, Move, Note, + QuoteAuthorization, QuoteRequest, Reject, Remove, @@ -63,6 +64,36 @@ import { isPost } from "./post"; const inboxLogger = getLogger(["hollo", "federation", "inbox"]); +export async function onDeleted( + ctx: Parameters[0], + del: Delete, +) { + const actorId = del.actorId; + const objectId = del.objectId; + if (actorId == null) return; + if (objectId == null) { + const object = await del.getObject({ + crossOrigin: "trust", + suppressError: true, + }); + if (object instanceof QuoteAuthorization) { + await onQuoteAuthorizationDeleted(ctx, del); + } + return; + } + if (objectId.href === actorId.href) { + await onAccountDeleted(ctx, del); + } else if ( + (await db.query.posts.findFirst({ + where: eq(posts.quoteAuthorizationIri, objectId.href), + })) != null + ) { + await onQuoteAuthorizationDeleted(ctx, del); + } else { + await onPostDeleted(ctx, del); + } +} + export const onUnverifiedActivity: UnverifiedActivityHandler = ( _ctx, activity, @@ -130,22 +161,7 @@ federation inboxLogger.debug("Unsupported object on Update: {object}", { object }); } }) - .on(Delete, async (ctx, del) => { - const actorId = del.actorId; - const objectId = del.objectId; - if (actorId == null || objectId == null) return; - if (objectId.href === actorId.href) { - await onAccountDeleted(ctx, del); - } else if ( - (await db.query.posts.findFirst({ - where: eq(posts.quoteAuthorizationIri, objectId.href), - })) != null - ) { - await onQuoteAuthorizationDeleted(ctx, del); - } else { - await onPostDeleted(ctx, del); - } - }) + .on(Delete, onDeleted) .on(Add, onPostPinned) .on(Remove, onPostUnpinned) .on(Block, onBlocked) From 1f97d34960a3a9b885b9fe3a153bda3d41e84497 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 23:11:50 +0900 Subject: [PATCH 29/30] Fallback to quote request ids Use the Accept or Reject object id when an embedded QuoteRequest omits its instrument, so quote request responses can still resolve the local quote and update pending quote state. Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3154749704 Assisted-by: Codex:gpt-5.5 --- src/federation/inbox.test.ts | 56 ++++++++++++++++++++++++++++++++++++ src/federation/inbox.ts | 6 +++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/federation/inbox.test.ts b/src/federation/inbox.test.ts index 0f968319..55850a6a 100644 --- a/src/federation/inbox.test.ts +++ b/src/federation/inbox.test.ts @@ -386,6 +386,38 @@ describe("quote request lifecycle", () => { expect(quoted?.quotesCount).toBe(1); }); + it("marks a pending quote accepted from Accept", async () => { + expect.assertions(3); + + const seeded = await seedPendingQuote(); + const authorizationIri = `${seeded.quotedPostIri}/quote_authorizations/${seeded.quotePostId}`; + const sendActivity = vi.fn(async () => undefined); + const requestCtx = { + ...ctx, + sendActivity, + } as unknown as InboxContext; + const accept = new Accept({ + actor: new URL("https://hollo.test/@quote-author"), + object: new QuoteRequest({ + id: new URL(`${seeded.quotePostIri}#quote-request`), + object: new URL(seeded.quotedPostIri), + }), + result: new URL(authorizationIri), + }); + + await onQuoteRequestAccepted(requestCtx, accept); + + const quote = await db.query.posts.findFirst({ + where: eq(posts.id, seeded.quotePostId), + }); + const quoted = await db.query.posts.findFirst({ + where: eq(posts.id, seeded.quotedPostId), + }); + expect(quote?.quoteState).toBe("accepted"); + expect(quote?.quoteAuthorizationIri).toBe(authorizationIri); + expect(quoted?.quotesCount).toBe(1); + }); + it("ignores quote request responses from another actor", async () => { expect.assertions(3); @@ -463,6 +495,30 @@ describe("quote request lifecycle", () => { expect(quoted?.quotesCount).toBe(0); }); + it("marks a pending quote rejected from Reject", async () => { + expect.assertions(2); + + const seeded = await seedPendingQuote(); + const reject = new Reject({ + actor: new URL("https://hollo.test/@quote-author"), + object: new QuoteRequest({ + id: new URL(`${seeded.quotePostIri}#quote-request`), + object: new URL(seeded.quotedPostIri), + }), + }); + + await onQuoteRequestRejected(ctx, reject); + + const quote = await db.query.posts.findFirst({ + where: eq(posts.id, seeded.quotePostId), + }); + const quoted = await db.query.posts.findFirst({ + where: eq(posts.id, seeded.quotedPostId), + }); + expect(quote?.quoteState).toBe("rejected"); + expect(quoted?.quotesCount).toBe(0); + }); + it("marks an accepted quote revoked when its authorization is deleted", async () => { expect.assertions(2); diff --git a/src/federation/inbox.ts b/src/federation/inbox.ts index 75a05c6b..9e8e7ecc 100644 --- a/src/federation/inbox.ts +++ b/src/federation/inbox.ts @@ -400,7 +400,11 @@ async function getQuoteRequestReferenceFromActivity( suppressError: true, }); if (object instanceof QuoteRequest) { - const quoteIri = object.instrumentId?.href; + const quoteIri = + object.instrumentId?.href ?? + (activity.objectId == null + ? null + : getQuoteIriFromQuoteRequestId(activity.objectId)); if (quoteIri == null) return null; return { quoteIri, From fe5fa380950e0322e996f20dad9e8238a85abb06 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 28 Apr 2026 23:26:19 +0900 Subject: [PATCH 30/30] Allow direct self-quotes Exempt self-quotes from the direct quote mention requirement so users can quote their own statuses as direct posts without mentioning themselves. Fixes https://github.com/fedify-dev/hollo/pull/457#discussion_r3154840732 Assisted-by: Codex:gpt-5.5 --- src/api/v1/statuses.test.ts | 19 +++++++++++++++++++ src/api/v1/statuses.ts | 1 + 2 files changed, 20 insertions(+) diff --git a/src/api/v1/statuses.test.ts b/src/api/v1/statuses.test.ts index 7358ecf8..8c410292 100644 --- a/src/api/v1/statuses.test.ts +++ b/src/api/v1/statuses.test.ts @@ -502,6 +502,25 @@ describe.sequential("/api/v1/statuses quotes", () => { expect(selfQuote.quote.state).toBe("accepted"); }); + it("allows direct self-quotes without self-mentions", async () => { + expect.assertions(3); + + const quotedResponse = await createStatus(authorToken, { + status: "Self-quotable post", + }); + expect(quotedResponse.status).toBe(200); + const quoted = await quotedResponse.json(); + + const selfQuoteResponse = await createStatus(authorToken, { + status: "Direct self-quote", + quoted_status_id: quoted.id, + visibility: "direct", + }); + expect(selfQuoteResponse.status).toBe(200); + const selfQuote = await selfQuoteResponse.json(); + expect(selfQuote.quote.state).toBe("accepted"); + }); + it("returns revoked quote state when a quote is revoked", async () => { expect.assertions(7); diff --git a/src/api/v1/statuses.ts b/src/api/v1/statuses.ts index f95b652e..4d89a384 100644 --- a/src/api/v1/statuses.ts +++ b/src/api/v1/statuses.ts @@ -225,6 +225,7 @@ async function validateQuoteTarget( } if ( visibility === "direct" && + quoteTarget.accountId !== owner.id && !mentionedIds.includes(quoteTarget.accountId) ) { return {