diff --git a/README.md b/README.md index 7ecce7f8..b1349172 100644 --- a/README.md +++ b/README.md @@ -86,12 +86,16 @@ Working endpoints are: - `/api/v1/accounts/update_credentials` - `/api/v1/accounts/verify_credentials` - `/api/v1/accounts/familiar_followers` +- `/api/v1/profile/avatar` (`DELETE`) +- `/api/v1/profile/header` (`DELETE`) - `/api/v1/statuses/:id` (`GET`, `DELETE`) - `/api/v1/statuses/:id/context` - `/api/v1/statuses/:id/favourite` - `/api/v1/statuses/:id/unfavourite` - `/api/v1/statuses/:id/favourited_by` - `/api/v1/statuses/:id/reblogged_by` +- `/api/v1/statuses/:id/reblog` +- `/api/v1/statuses/:id/unreblog` - `/api/v1/statuses` - `/api/v1/timelines/public` - `/api/v1/timelines/home` @@ -104,7 +108,6 @@ Working endpoints are: Endpoints left: -- `/api/v1/search` - `/api/v2/media` - `/api/v1/media/:id` - `/api/v1/reports` @@ -135,11 +138,7 @@ Endpoints left: - `/api/v1/tags/:id` - `/api/v1/tags/:id/follow` - `/api/v1/tags/:id/unfollow` -- `/api/v1/profile/avatar` (`DELETE`) -- `/api/v1/profile/header` (`DELETE`) - `/api/v1/statuses/:id/translate` -- `/api/v1/statuses/:id/reblog` -- `/api/v1/statuses/:id/unreblog` - `/api/v1/statuses/:id/bookmark` - `/api/v1/statuses/:id/unbookmark` - `/api/v1/statuses/:id/mute` diff --git a/database/entities/Status.ts b/database/entities/Status.ts index 1c6adc48..9ea2192c 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -61,6 +61,7 @@ export const statusAndUserRelations = { select: { replies: true, likes: true, + reblogs: true, }, }, reblog: { @@ -138,6 +139,7 @@ export type StatusWithRelations = Status & { _count: { replies: number; likes: number; + reblogs: number; }; reblog: | (Status & { @@ -458,8 +460,12 @@ export const statusToAPI = async ( emojis: await Promise.all( status.emojis.map(emoji => emojiToAPI(emoji)) ), - favourited: !!status.likes.find(like => like.likerId === user?.id), - favourites_count: status.likes.length, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + favourited: !!(status.likes ?? []).find( + like => like.likerId === user?.id + ), + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + favourites_count: (status.likes ?? []).length, media_attachments: [], mentions: [], language: null, diff --git a/server/api/api/v1/profile/avatar.ts b/server/api/api/v1/profile/avatar.ts new file mode 100644 index 00000000..70f3caa2 --- /dev/null +++ b/server/api/api/v1/profile/avatar.ts @@ -0,0 +1,43 @@ +import { applyConfig } from "@api"; +import { errorResponse, jsonResponse } from "@response"; +import { client } from "~database/datasource"; +import { + getFromRequest, + userRelations, + userToAPI, +} from "~database/entities/User"; +import { APIRouteMeta } from "~types/api"; + +export const meta: APIRouteMeta = applyConfig({ + allowedMethods: ["DELETE"], + ratelimits: { + max: 10, + duration: 60, + }, + route: "/api/v1/profile/avatar", + auth: { + required: true, + }, +}); + +/** + * Deletes a user avatar + */ +export default async (req: Request): Promise => { + const { user } = await getFromRequest(req); + + if (!user) return errorResponse("Unauthorized", 401); + + // Delete user avatar + const newUser = await client.user.update({ + where: { + id: user.id, + }, + data: { + avatar: "", + }, + include: userRelations, + }); + + return jsonResponse(await userToAPI(newUser)); +}; diff --git a/server/api/api/v1/profile/header.ts b/server/api/api/v1/profile/header.ts new file mode 100644 index 00000000..b6105315 --- /dev/null +++ b/server/api/api/v1/profile/header.ts @@ -0,0 +1,43 @@ +import { applyConfig } from "@api"; +import { errorResponse, jsonResponse } from "@response"; +import { client } from "~database/datasource"; +import { + getFromRequest, + userRelations, + userToAPI, +} from "~database/entities/User"; +import { APIRouteMeta } from "~types/api"; + +export const meta: APIRouteMeta = applyConfig({ + allowedMethods: ["DELETE"], + ratelimits: { + max: 10, + duration: 60, + }, + route: "/api/v1/profile/header", + auth: { + required: true, + }, +}); + +/** + * Deletes a user header + */ +export default async (req: Request): Promise => { + const { user } = await getFromRequest(req); + + if (!user) return errorResponse("Unauthorized", 401); + + // Delete user header + const newUser = await client.user.update({ + where: { + id: user.id, + }, + data: { + header: "", + }, + include: userRelations, + }); + + return jsonResponse(await userToAPI(newUser)); +}; diff --git a/server/api/api/v1/statuses/[id]/reblog.ts b/server/api/api/v1/statuses/[id]/reblog.ts new file mode 100644 index 00000000..6c14d8e1 --- /dev/null +++ b/server/api/api/v1/statuses/[id]/reblog.ts @@ -0,0 +1,95 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { applyConfig } from "@api"; +import { getConfig } from "@config"; +import { parseRequest } from "@request"; +import { errorResponse, jsonResponse } from "@response"; +import { MatchedRoute } from "bun"; +import { client } from "~database/datasource"; +import { + isViewableByUser, + statusAndUserRelations, + statusToAPI, +} from "~database/entities/Status"; +import { getFromRequest } from "~database/entities/User"; +import { APIRouteMeta } from "~types/api"; + +export const meta: APIRouteMeta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id/reblog", + auth: { + required: true, + }, +}); + +/** + * Reblogs a post + */ +export default async ( + req: Request, + matchedRoute: MatchedRoute +): Promise => { + const id = matchedRoute.params.id; + const config = getConfig(); + + const { user } = await getFromRequest(req); + + if (!user) return errorResponse("Unauthorized", 401); + + const { visibility = "public" } = await parseRequest<{ + visibility: "public" | "unlisted" | "private"; + }>(req); + + const status = await client.status.findUnique({ + where: { id }, + include: statusAndUserRelations, + }); + + // Check if user is authorized to view this status (if it's private) + if (!status || !isViewableByUser(status, user)) + return errorResponse("Record not found", 404); + + const existingReblog = await client.status.findFirst({ + where: { + authorId: user.id, + reblogId: status.id, + }, + }); + + if (existingReblog) { + return errorResponse("Already reblogged", 422); + } + + const newReblog = await client.status.create({ + data: { + authorId: user.id, + reblogId: status.id, + isReblog: true, + uri: `${config.http.base_url}/statuses/FAKE-${crypto.randomUUID()}`, + visibility, + sensitive: false, + }, + include: statusAndUserRelations, + }); + + await client.status.update({ + where: { id: newReblog.id }, + data: { + uri: `${config.http.base_url}/statuses/${newReblog.id}`, + }, + include: statusAndUserRelations, + }); + + return jsonResponse( + await statusToAPI( + { + ...newReblog, + uri: `${config.http.base_url}/statuses/${newReblog.id}`, + }, + user + ) + ); +}; diff --git a/server/api/api/v1/statuses/[id]/unreblog.ts b/server/api/api/v1/statuses/[id]/unreblog.ts new file mode 100644 index 00000000..29c6d2b0 --- /dev/null +++ b/server/api/api/v1/statuses/[id]/unreblog.ts @@ -0,0 +1,69 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { applyConfig } from "@api"; +import { errorResponse, jsonResponse } from "@response"; +import { MatchedRoute } from "bun"; +import { client } from "~database/datasource"; +import { + isViewableByUser, + statusAndUserRelations, + statusToAPI, +} from "~database/entities/Status"; +import { getFromRequest } from "~database/entities/User"; +import { APIRouteMeta } from "~types/api"; +import { APIStatus } from "~types/entities/status"; + +export const meta: APIRouteMeta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id/unreblog", + auth: { + required: true, + }, +}); + +/** + * Unreblogs a post + */ +export default async ( + req: Request, + matchedRoute: MatchedRoute +): Promise => { + const id = matchedRoute.params.id; + + const { user } = await getFromRequest(req); + + if (!user) return errorResponse("Unauthorized", 401); + + const status = await client.status.findUnique({ + where: { id }, + include: statusAndUserRelations, + }); + + // Check if user is authorized to view this status (if it's private) + if (!status || !isViewableByUser(status, user)) + return errorResponse("Record not found", 404); + + const existingReblog = await client.status.findFirst({ + where: { + authorId: user.id, + reblogId: status.id, + }, + }); + + if (!existingReblog) { + return errorResponse("Not already reblogged", 422); + } + + await client.status.delete({ + where: { id: existingReblog.id }, + }); + + return jsonResponse({ + ...(await statusToAPI(status, user)), + reblogged: false, + reblogs_count: status._count.reblogs - 1, + } as APIStatus); +}; diff --git a/tests/api/accounts.test.ts b/tests/api/accounts.test.ts index eb14a273..2040b38d 100644 --- a/tests/api/accounts.test.ts +++ b/tests/api/accounts.test.ts @@ -503,6 +503,56 @@ describe("API Tests", () => { }); }); + describe("DELETE /api/v1/profile/avatar", () => { + test("should delete the avatar of the authenticated user and return the updated account object", async () => { + const response = await fetch( + `${config.http.base_url}/api/v1/profile/avatar`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + const account = (await response.json()) as APIAccount; + + expect(account.id).toBeDefined(); + expect(account.avatar).toBe(""); + }); + }); + + describe("DELETE /api/v1/profile/header", () => { + test("should delete the header of the authenticated user and return the updated account object", async () => { + const response = await fetch( + `${config.http.base_url}/api/v1/profile/header`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + const account = (await response.json()) as APIAccount; + + expect(account.id).toBeDefined(); + expect(account.header).toBe(""); + }); + }); + describe("GET /api/v1/accounts/familiar_followers", () => { test("should follow the user", async () => { const response = await fetch( diff --git a/tests/api/statuses.test.ts b/tests/api/statuses.test.ts index 3c82a160..ef1e221c 100644 --- a/tests/api/statuses.test.ts +++ b/tests/api/statuses.test.ts @@ -200,6 +200,57 @@ describe("API Tests", () => { }); }); + describe("POST /api/v1/statuses/:id/reblog", () => { + test("should reblog the specified status and return the reblogged status object", async () => { + const response = await fetch( + `${config.http.base_url}/api/v1/statuses/${status?.id}/reblog`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + const rebloggedStatus = (await response.json()) as APIStatus; + + expect(rebloggedStatus.id).toBeDefined(); + expect(rebloggedStatus.reblog?.id).toEqual(status?.id ?? ""); + expect(rebloggedStatus.reblog?.reblogged).toBe(true); + }); + }); + + describe("POST /api/v1/statuses/:id/unreblog", () => { + test("should unreblog the specified status and return the original status object", async () => { + const response = await fetch( + `${config.http.base_url}/api/v1/statuses/${status?.id}/unreblog`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + const unrebloggedStatus = (await response.json()) as APIStatus; + + expect(unrebloggedStatus.id).toBeDefined(); + expect(unrebloggedStatus.reblogged).toBe(false); + }); + }); + describe("GET /api/v1/statuses/:id/context", () => { test("should return the context of the specified status", async () => { const response = await fetch( diff --git a/utils/request.ts b/utils/request.ts index a7e379a8..af5eae2f 100644 --- a/utils/request.ts +++ b/utils/request.ts @@ -17,6 +17,11 @@ export async function parseRequest(request: Request): Promise> { query.append(key, JSON.stringify(value)); } + // If body is empty + if (request.body === null) { + return {}; + } + // if request contains a JSON body if (request.headers.get("Content-Type")?.includes("application/json")) { return (await request.json()) as T;