diff --git a/CHANGES.md b/CHANGES.md index fc695d36..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 @@ -16,6 +49,10 @@ 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. + +[#457]: https://github.com/fedify-dev/hollo/pull/457 + Version 0.8.1 ------------- 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/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 diff --git a/src/api/v1/statuses.test.ts b/src/api/v1/statuses.test.ts index 18b099a2..8c410292 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, mentions, posts } from "../../schema"; import { uuidv7 } from "../../uuid"; describe.sequential("/api/v1/accounts/verify_credentials", () => { @@ -214,6 +214,525 @@ 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("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); + + 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("treats private quote approval as nobody for 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 cannot 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([]); + expect(status.quote_approval.current_user).toBe("denied"); + }); + + it("denies followers quoting 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 cannot 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(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("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); + + 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); + }); + + 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(); + } + }); + + 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", () => { let viewer: Awaited>; let approvedAuthor: Awaited>; diff --git a/src/api/v1/statuses.ts b/src/api/v1/statuses.ts index e26de422..4d89a384 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,113 @@ function buildMuteAndBlockConditions(viewerAccountId: Uuid | null | undefined) { ); } +function normalizeQuoteApprovalPolicy( + policy: QuoteApprovalPolicy | null | undefined, +): QuoteApprovalPolicy { + 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" }; + } + if ( + quoteTarget.visibility === "private" && + quoteTarget.accountId !== owner.id + ) { + return { ok: false, status: 422, error: "Quote target is not quotable" }; + } + + let visibility = requestedVisibility; + if ( + quoteTarget.visibility === "private" && + (visibility === "public" || visibility === "unlisted") + ) { + visibility = "private"; + } + if ( + visibility === "direct" && + quoteTarget.accountId !== owner.id && + !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, + ); + 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 +275,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 +352,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 +366,30 @@ 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, + ); + 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 +424,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 +468,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 +512,34 @@ 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, { + includeInactiveQuoteTarget: true, + }), + }), + { + orderingKey, + preferSharedInbox: true, + excludeBaseUris: [new URL(c.req.url)], + }, + ); + } return c.json(serializePost(post, owner, c.req.url)); }); @@ -433,6 +593,15 @@ 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, + ); await db.transaction(async (tx) => { const result = await tx .update(posts) @@ -445,9 +614,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 +654,79 @@ 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, + ); + 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)], + }, + ); + 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)); + }, +); + app.get("/:id", async (c) => { const token = await getAccessToken(c); const owner = @@ -528,7 +771,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 +1701,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,37 +1742,80 @@ 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)), + with: { account: { with: { owner: true } } }, }); if (quotingPost == null) { return c.json({ error: "Record not found" }, 404); } + const quoteAuthorizationIri = quotingPost.quoteAuthorizationIri; - // 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 + 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), 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..f7f04497 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, @@ -17,6 +19,8 @@ import { type PollOption, type PollVote, type Post, + type QuoteApprovalPolicy, + type QuoteState, pollOptions, pollVotes, posts, @@ -29,19 +33,90 @@ 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 { + 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, + viewerIsApprovedFollower: boolean, +) { + const effectivePolicy = + post.visibility === "direct" || post.visibility === "private" + ? "nobody" + : policy; + const automatic = + effectivePolicy === "public" + ? ["public"] + : effectivePolicy === "followers" + ? ["followers"] + : []; + return { + automatic, + manual: [], + ...(currentAccountOwner == null + ? {} + : { + current_user: + currentAccountOwner.id === post.accountId || + 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, @@ -108,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, @@ -169,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[]; @@ -220,7 +300,7 @@ export function serializePost( | null; quoteTarget: | (Post & { - account: Account & { successor: Account | null }; + account: StatusAccount; application: Application | null; replyTarget: Post | null; media: Medium[]; @@ -260,6 +340,14 @@ 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; + const viewerIsApprovedFollower = + currentAccountOwner != null && + post.account.followers?.some( + (follow) => follow.followerId === currentAccountOwner.id, + ) === true; return { id: post.id, created_at: post.published ?? post.updated, @@ -297,7 +385,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 +399,24 @@ 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, + viewerIsApprovedFollower, + ), application: post.application == null ? null diff --git a/src/federation/inbox.test.ts b/src/federation/inbox.test.ts index c6431ed8..55850a6a 100644 --- a/src/federation/inbox.test.ts +++ b/src/federation/inbox.test.ts @@ -1,14 +1,37 @@ import type { InboxContext } from "@fedify/fedify"; -import { Accept, Reject } from "@fedify/vocab"; +import { + Accept, + Delete, + Note, + Person, + QuoteAuthorization, + QuoteRequest, + Reject, + Update, +} 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, + blocks, + follows, + instances, + notifications, + 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 +189,1201 @@ describe("onFollowRejected", () => { expect(follow).toBeUndefined(); }); }); + +describe("quote request lifecycle", () => { + beforeEach(async () => { + 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" }); + 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 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), + }); + + 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 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(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("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); + + 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); + + 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 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 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); + + 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({ + 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), + }); + const quoted = await db.query.posts.findFirst({ + where: eq(posts.id, seeded.quotedPostId), + }); + expect(quote?.quoteState).toBe("revoked"); + 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({ + 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 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); + + 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); + + 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(); + }); + + 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); + + 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("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("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); + + 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("rejected"); + expect(quote?.quoteAuthorizationIri).toBeNull(); + expect(quoted?.quotesCount).toBe(0); + expect(sendActivity).toHaveBeenCalledOnce(); + }); + + 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); + + 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(); + }); + + 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 6981c21d..9e8e7ecc 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 { @@ -64,6 +66,7 @@ import { updateAccountStats, } from "./account"; import { + getRecipients, isPost, persistPollVote, persistPost, @@ -378,6 +381,375 @@ export async function onFollowRejected( } } +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", + suppressError: true, + }); + if (object instanceof QuoteRequest) { + const quoteIri = + object.instrumentId?.href ?? + (activity.objectId == null + ? null + : getQuoteIriFromQuoteRequestId(activity.objectId)); + 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: QuoteRequestReference, + responderIri: string | null, + state: "accepted" | "rejected", + quoteAuthorizationIri: string | null, +): Promise { + const quote = await db.query.posts.findFirst({ + where: eq(posts.iri, request.quoteIri), + with: { quoteTarget: { with: { account: true } } }, + }); + if (quote == null) return false; + if (quote.quoteState !== "pending") return false; + const target = + request.targetIri == null + ? quote.quoteTarget + : await db.query.posts.findFirst({ + 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, + quoteTargetIri: request.targetIri ?? quote.quoteTargetIri ?? target.iri, + 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)); + } + }); + 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, + accept: Accept, +): Promise { + const request = await getQuoteRequestReferenceFromActivity(accept); + if (request == null) return false; + const quoteAuthorizationIri = accept.resultId?.href; + if (quoteAuthorizationIri == null) return false; + const accepted = await updateQuoteRequestState( + request, + accept.actorId?.href ?? null, + "accepted", + quoteAuthorizationIri, + ); + if (accepted) await sendQuoteUpdate(ctx, request.quoteIri); + return accepted; +} + +export async function onQuoteRequestRejected( + _ctx: InboxContext, + reject: Reject, +): 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( + ctx: InboxContext, + del: Delete, +): Promise { + const object = await del.getObject({ + crossOrigin: "trust", + suppressError: true, + }); + 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), + 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) + .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)); + } + }); + await sendQuoteUpdate(ctx, quote.iri); +} + +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" || target.visibility === "private") { + 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.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 quoteActorIri = instrument.attributionId?.href; + if (quoteActorIri == null) return; + if (request.actorId != null && request.actorId.href !== quoteActorIri) { + return; + } + const existingQuote = + instrument.id == null + ? null + : await db.query.posts.findFirst({ + where: eq(posts.iri, instrument.id.href), + }); + if (existingQuote?.quoteState === "revoked") return; + const persistedQuote = await persistPost( + db, + instrument, + ctx.origin, + 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 = + 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)); + 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 && !wasAccepted) { + await tx + .update(posts) + .set({ quotesCount: sql`coalesce(${posts.quotesCount}, 0) + 1` }) + .where(eq(posts.id, target.id)); + } else if (accepted) { + await updatePostStats(tx, { id: target.id }); + } + if ( + previousAcceptedTargetId != null && + previousAcceptedTargetId !== target.id + ) { + await updatePostStats(tx, { id: previousAcceptedTargetId }); + } + }); + if (accepted) { + await createQuoteNotification( + persistedQuote.account, + persistedQuote, + target, + ); + } + + 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 +848,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 +902,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 +980,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.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 5039a3ff..896964ce 100644 --- a/src/federation/index.ts +++ b/src/federation/index.ts @@ -13,6 +13,8 @@ import { Like, Move, Note, + QuoteAuthorization, + QuoteRequest, Reject, Remove, Undo, @@ -22,7 +24,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 +49,10 @@ import { onPostUnpinned, onPostUnshared, onPostUpdated, + onQuoteAuthorizationDeleted, + onQuoteRequestAccepted, + onQuoteRequestRejected, + onQuoteRequested, onUnblocked, onUnfollowed, onUnliked, @@ -58,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, @@ -81,8 +117,15 @@ federation return anyOwner == null ? null : { username: anyOwner.handle }; }) .on(Follow, onFollowed) - .on(Accept, onFollowAccepted) - .on(Reject, onFollowRejected) + .on(Accept, async (ctx, accept) => { + if (await onQuoteRequestAccepted(ctx, accept)) return; + await onFollowAccepted(ctx, accept); + }) + .on(Reject, async (ctx, reject) => { + if (await onQuoteRequestRejected(ctx, reject)) return; + await onFollowRejected(ctx, reject); + }) + .on(QuoteRequest, onQuoteRequested) .on(Create, async (ctx, create) => { const object = await create.getObject(); if ( @@ -118,16 +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 { - await onPostDeleted(ctx, del); - } - }) + .on(Delete, onDeleted) .on(Add, onPostPinned) .on(Remove, onPostUnpinned) .on(Block, onBlocked) 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 9215f118..3f6b7c27 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, 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; @@ -84,6 +94,61 @@ 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; + if (!(await hasApprovedFollowFromKeyOwner(keyOwner.id, owner.id))) { + 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..dcbb72f2 100644 --- a/src/federation/post.test.ts +++ b/src/federation/post.test.ts @@ -1,9 +1,12 @@ import type { Context, InboxContext } from "@fedify/fedify"; import { Announce, + InteractionPolicy, + InteractionRule, Note, Person, PUBLIC_COLLECTION, + QuoteAuthorization, type RemoteDocument, } from "@fedify/vocab"; import { and, eq } from "drizzle-orm"; @@ -386,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: { @@ -399,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 () => { @@ -598,4 +605,376 @@ 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", + }, + }, + }); + }); + + it.each(["pending", "rejected", "revoked", "unauthorized"] as const)( + "omits quote fields for %s quotes", + async (quoteState) => { + expect.assertions(3); + + 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"); + expect(JSON.stringify(json)).not.toContain("inactive-quote-target"); + }, + ); + + it("emits author-only quote policy for private statuses", 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 cannot quote this

", + content: "Followers cannot 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", + }, + }, + }); + }); +}); + +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"); + }); + + 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("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("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"); + + 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 2ceceeac..1ded7731 100644 --- a/src/federation/post.ts +++ b/src/federation/post.ts @@ -11,6 +11,8 @@ import { Emoji, Hashtag, Image, + InteractionPolicy, + InteractionRule, isActor, LanguageString, Link, @@ -18,6 +20,7 @@ import { Note, OrderedCollection, Question, + QuoteAuthorization, type Recipient, Source, Tombstone, @@ -33,6 +36,8 @@ import { gte, inArray, isNotNull, + isNull, + or, sql, } from "drizzle-orm"; import type { PgDatabase } from "drizzle-orm/pg-core"; @@ -60,6 +65,7 @@ import { type PollOption, type PollVote, type Post, + type QuoteApprovalPolicy, pollOptions, polls, pollVotes, @@ -102,6 +108,56 @@ export function isPost(object?: vocab.Object | Link | null): object is ASPost { ); } +function getQuoteApprovalPolicy( + object: ASPost, + account: Account, +): QuoteApprovalPolicy { + 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) + ) { + return "public"; + } + if ( + account.followersUrl != null && + automaticApprovals.some((url) => url.href === account.followersUrl) + ) { + return "followers"; + } + 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, @@ -206,17 +262,25 @@ 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; + } if (objectLink == null && object.quoteUrl != null) { objectLink = object.quoteUrl; } if (objectLink != null) { - const result = await db - .select({ id: posts.id }) - .from(posts) - .where(eq(posts.iri, objectLink.href)) - .limit(1); - if (result != null && result.length > 0) { - quoteTargetId = result[0].id; + quoteTargetIri = objectLink.href; + 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, }); @@ -232,6 +296,8 @@ export async function persistPost( quoteTarget: quoteTargetObj, }); quoteTargetId = quoteTargetObj?.id ?? null; + quoteTargetAccountId = quoteTargetObj?.accountId ?? null; + quoteTargetAccountIri = quoteTargetObj?.account.iri ?? null; } } } @@ -246,6 +312,19 @@ 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 preserveAcceptedQuote = + quoteTargetIri != null && + existingPost?.quoteState === "accepted" && + existingPost.quoteTargetIri === quoteTargetIri; + const preservedQuoteAuthorizationIri = + quoteAuthorizationIri ?? + (preserveAcceptedQuote ? existingPost.quoteAuthorizationIri : null); const published = toDate(object.published); const updated = toDate(object.updated) ?? published ?? new Date(); const values = { @@ -260,6 +339,16 @@ export async function persistPost( replyTargetId, sharingId: null, quoteTargetId, + quoteTargetIri, + quoteState: + quoteTargetId == null + ? null + : quoteTargetAccountId === account.id || + quoteAuthorizationIri != null || + preserveAcceptedQuote + ? "accepted" + : "unauthorized", + quoteAuthorizationIri: preservedQuoteAuthorizationIri, visibility: to.has(vocab.PUBLIC_COLLECTION.href) ? "public" : cc.has(vocab.PUBLIC_COLLECTION.href) @@ -279,6 +368,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 +802,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({ @@ -743,6 +838,7 @@ export function toObject( replies: Post[]; }, ctx: Context, + opts: { includeInactiveQuoteTarget?: boolean } = {}, ): ASPost { const cls = post.type === "Question" @@ -762,10 +858,15 @@ export function toObject( replies: new Collection({ totalItems: o.votesCount }), }), ); - const contentHtml = addQuoteInlineFallback( - post.contentHtml, - post.quoteTarget, - ); + 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), @@ -822,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, }), ]), ], @@ -877,7 +978,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 +1007,29 @@ export function toObject( }); } +function getCanQuoteRule( + post: Post & { account: Account & { owner: AccountOwner | null } }, + ctx: Context, +): InteractionRule { + const policy = + post.visibility === "direct" || post.visibility === "private" + ? "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"),