diff --git a/CHANGES.md b/CHANGES.md index e8539f6a..b1c1db0a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -144,6 +144,11 @@ To be released. editor and is now returned from `GET /api/v1/preferences`, which helps clients like Phanpy honor each account's preferred CW behavior. [[#425]] + - Fixed Mastodon API compatibility for clients such as the official Mastodon + iOS app by returning empty arrays for unimplemented trends and suggestions + endpoints instead of `404 Not Found` responses. The suggestions endpoints + still require an authenticated user token. [[#421], [#427] by Vignesh] + - Added a new dashboard page for thumbnail cleanup at `/thumbnail_cleanup`. Thumbnails from remote posts that have not been bookmarked, liked, reacted to, shared nor quoted by a local account before a given cut-off data can @@ -159,8 +164,10 @@ To be released. [#357]: https://github.com/fedify-dev/hollo/issues/357 [#409]: https://github.com/fedify-dev/hollo/issues/409 [#420]: https://github.com/fedify-dev/hollo/issues/420 +[#421]: https://github.com/fedify-dev/hollo/issues/421 [#424]: https://github.com/fedify-dev/hollo/issues/424 [#425]: https://github.com/fedify-dev/hollo/issues/425 +[#427]: https://github.com/fedify-dev/hollo/pull/427 [#435]: https://github.com/fedify-dev/hollo/issues/435 [#436]: https://github.com/fedify-dev/hollo/pull/436 [#445]: https://github.com/fedify-dev/hollo/issues/445 diff --git a/src/api/stub-endpoints.test.ts b/src/api/stub-endpoints.test.ts new file mode 100644 index 00000000..7d46a797 --- /dev/null +++ b/src/api/stub-endpoints.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { cleanDatabase } from "../../tests/helpers"; +import { + bearerAuthorization, + createAccount, + createOAuthApplication, + getAccessToken, + type Token, +} from "../../tests/helpers/oauth"; +import app from "../index"; + +describe.sequential("Mastodon compatibility stub endpoints", () => { + let readAccountsToken: Token; + let readSearchToken: Token; + + beforeEach(async () => { + await cleanDatabase(); + + const account = await createAccount(); + const client = await createOAuthApplication({ + scopes: ["read:accounts", "read:search"], + }); + readAccountsToken = await getAccessToken(client, account, [ + "read:accounts", + ]); + readSearchToken = await getAccessToken(client, account, ["read:search"]); + }); + + it.each([ + "/api/v1/trends", + "/api/v1/trends/tags", + "/api/v1/trends/statuses?offset=0", + "/api/v1/trends/links", + ])("returns an empty array for GET %s", async (path) => { + expect.assertions(3); + + const response = await app.request(path); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + expect(await response.json()).toEqual([]); + }); + + it.each(["/api/v1/suggestions", "/api/v2/suggestions"])( + "requires authentication for GET %s", + async (path) => { + expect.assertions(2); + + const response = await app.request(path); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "unauthorized" }); + }, + ); + + it.each(["/api/v1/suggestions", "/api/v2/suggestions"])( + "rejects insufficient scope for GET %s", + async (path) => { + expect.assertions(2); + + const response = await app.request(path, { + headers: { + authorization: bearerAuthorization(readSearchToken), + }, + }); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: "insufficient_scope" }); + }, + ); + + it.each(["/api/v1/suggestions", "/api/v2/suggestions"])( + "returns an empty array for GET %s", + async (path) => { + expect.assertions(3); + + const response = await app.request(path, { + headers: { + authorization: bearerAuthorization(readAccountsToken), + }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + expect(await response.json()).toEqual([]); + }, + ); +}); diff --git a/src/api/v1/index.ts b/src/api/v1/index.ts index d2d6db4b..87045bc9 100644 --- a/src/api/v1/index.ts +++ b/src/api/v1/index.ts @@ -92,6 +92,33 @@ app.get("/announcements", (c) => { return c.json([]); }); +app.get("/trends/tags", (c) => { + return c.json([]); +}); + +app.get("/trends/statuses", (c) => { + return c.json([]); +}); + +app.get("/trends/links", (c) => { + return c.json([]); +}); + +// Mastodon clients also request /trends without a subpath, +// which is equivalent to /trends/tags: +app.get("/trends", (c) => { + return c.json([]); +}); + +app.get( + "/suggestions", + tokenRequired, + scopeRequired(["read:accounts"]), + (c) => { + return c.json([]); + }, +); + app.get( "/favourites", tokenRequired, diff --git a/src/api/v1/timelines.test.ts b/src/api/v1/timelines.test.ts index 04fe105a..57b379c5 100644 --- a/src/api/v1/timelines.test.ts +++ b/src/api/v1/timelines.test.ts @@ -142,7 +142,6 @@ describe.sequential("/api/v1/timelines/list/:list_id", () => { expect(json[0].media_attachments[0].type).toBe("unknown"); }); }); - describe.sequential("/api/v1/timelines/home", () => { let owner: Awaited>; let approvedAuthor: Awaited>; @@ -222,3 +221,95 @@ describe.sequential("/api/v1/timelines/home", () => { expect(ids).toEqual([approvedPostId]); }); }); + +describe.sequential("/api/v1/timelines/home", () => { + let owner: Awaited>; + let client: Awaited>; + let accessToken: Awaited>; + + beforeEach(async () => { + await cleanDatabase(); + + owner = await createAccount(); + client = await createOAuthApplication({ + scopes: ["read:statuses"], + }); + accessToken = await getAccessToken(client, owner, ["read:statuses"]); + }); + + it("serializes quotes using the Mastodon Quote entity format", async () => { + expect.assertions(7); + + const authorId = crypto.randomUUID() as Uuid; + const quotedPostId = uuidv7(); + const quotePostId = uuidv7(); + + await db + .insert(instances) + .values({ host: "remote.test" }) + .onConflictDoNothing(); + + await db.insert(accounts).values({ + id: authorId, + iri: "https://remote.test/users/author", + instanceHost: "remote.test", + type: "Person", + name: "Remote author", + emojis: {}, + handle: "@author@remote.test", + bioHtml: "", + url: "https://remote.test/@author", + protected: false, + inboxUrl: "https://remote.test/users/author/inbox", + }); + + await db.insert(follows).values({ + iri: "https://hollo.test/follows/author", + followingId: authorId, + followerId: owner.id, + approved: new Date(), + }); + + await db.insert(posts).values([ + { + id: quotedPostId, + iri: `https://remote.test/notes/${quotedPostId}`, + type: "Note", + accountId: authorId, + visibility: "public", + content: "Quoted post", + contentHtml: "

Quoted post

", + published: new Date(), + }, + { + id: quotePostId, + iri: `https://remote.test/notes/${quotePostId}`, + type: "Note", + accountId: authorId, + quoteTargetId: quotedPostId, + visibility: "public", + content: "Quote post", + contentHtml: "

Quote post

", + published: new Date(), + }, + ]); + + const response = await app.request("/api/v1/timelines/home", { + method: "GET", + headers: { + authorization: bearerAuthorization(accessToken), + }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + + const json = await response.json(); + + expect(Array.isArray(json)).toBe(true); + expect(json[0].id).toBe(quotePostId); + expect(json[0].quote_id).toBe(quotedPostId); + expect(json[0].quote.state).toBe("accepted"); + expect(json[0].quote.quoted_status.id).toBe(quotedPostId); + }); +}); diff --git a/src/api/v2/index.ts b/src/api/v2/index.ts index d1016d2b..a0557040 100644 --- a/src/api/v2/index.ts +++ b/src/api/v2/index.ts @@ -47,6 +47,15 @@ app.route("/notifications", notificationsRoutes); app.post("/media", tokenRequired, scopeRequired(["write:media"]), postMedia); +app.get( + "/suggestions", + tokenRequired, + scopeRequired(["read:accounts"]), + (c) => { + return c.json([]); + }, +); + app.get( "/search", tokenRequired,