Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
89 changes: 89 additions & 0 deletions src/api/stub-endpoints.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
},
);
});
27 changes: 27 additions & 0 deletions src/api/v1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
93 changes: 92 additions & 1 deletion src/api/v1/timelines.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof createAccount>>;
let approvedAuthor: Awaited<ReturnType<typeof createAccount>>;
Expand Down Expand Up @@ -222,3 +221,95 @@ describe.sequential("/api/v1/timelines/home", () => {
expect(ids).toEqual([approvedPostId]);
});
});

describe.sequential("/api/v1/timelines/home", () => {
let owner: Awaited<ReturnType<typeof createAccount>>;
let client: Awaited<ReturnType<typeof createOAuthApplication>>;
let accessToken: Awaited<ReturnType<typeof getAccessToken>>;

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: "<p>Quoted post</p>",
published: new Date(),
},
{
id: quotePostId,
iri: `https://remote.test/notes/${quotePostId}`,
type: "Note",
accountId: authorId,
quoteTargetId: quotedPostId,
visibility: "public",
content: "Quote post",
contentHtml: "<p>Quote post</p>",
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);
});
});
9 changes: 9 additions & 0 deletions src/api/v2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading