mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 16:38:19 +01:00
Add reblog and unreblog endpoints
This commit is contained in:
parent
5bba96435c
commit
ca94c35bc4
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
43
server/api/api/v1/profile/avatar.ts
Normal file
43
server/api/api/v1/profile/avatar.ts
Normal 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));
|
||||
};
|
||||
43
server/api/api/v1/profile/header.ts
Normal file
43
server/api/api/v1/profile/header.ts
Normal 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));
|
||||
};
|
||||
95
server/api/api/v1/statuses/[id]/reblog.ts
Normal file
95
server/api/api/v1/statuses/[id]/reblog.ts
Normal 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
|
||||
)
|
||||
);
|
||||
};
|
||||
69
server/api/api/v1/statuses/[id]/unreblog.ts
Normal file
69
server/api/api/v1/statuses/[id]/unreblog.ts
Normal 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);
|
||||
};
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue