Add reblog and unreblog endpoints

This commit is contained in:
Jesse Wierzbinski 2023-11-11 22:28:06 -10:00
parent 5bba96435c
commit ca94c35bc4
No known key found for this signature in database
GPG key ID: F9A1E418934E40B0
9 changed files with 368 additions and 7 deletions

View file

@ -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`

View file

@ -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,

View file

@ -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<Response> => {
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));
};

View file

@ -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<Response> => {
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));
};

View file

@ -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<Response> => {
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
)
);
};

View file

@ -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<Response> => {
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);
};

View file

@ -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(

View file

@ -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(

View file

@ -17,6 +17,11 @@ export async function parseRequest<T>(request: Request): Promise<Partial<T>> {
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;