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/update_credentials`
|
||||||
- `/api/v1/accounts/verify_credentials`
|
- `/api/v1/accounts/verify_credentials`
|
||||||
- `/api/v1/accounts/familiar_followers`
|
- `/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` (`GET`, `DELETE`)
|
||||||
- `/api/v1/statuses/:id/context`
|
- `/api/v1/statuses/:id/context`
|
||||||
- `/api/v1/statuses/:id/favourite`
|
- `/api/v1/statuses/:id/favourite`
|
||||||
- `/api/v1/statuses/:id/unfavourite`
|
- `/api/v1/statuses/:id/unfavourite`
|
||||||
- `/api/v1/statuses/:id/favourited_by`
|
- `/api/v1/statuses/:id/favourited_by`
|
||||||
- `/api/v1/statuses/:id/reblogged_by`
|
- `/api/v1/statuses/:id/reblogged_by`
|
||||||
|
- `/api/v1/statuses/:id/reblog`
|
||||||
|
- `/api/v1/statuses/:id/unreblog`
|
||||||
- `/api/v1/statuses`
|
- `/api/v1/statuses`
|
||||||
- `/api/v1/timelines/public`
|
- `/api/v1/timelines/public`
|
||||||
- `/api/v1/timelines/home`
|
- `/api/v1/timelines/home`
|
||||||
|
|
@ -104,7 +108,6 @@ Working endpoints are:
|
||||||
|
|
||||||
Endpoints left:
|
Endpoints left:
|
||||||
|
|
||||||
- `/api/v1/search`
|
|
||||||
- `/api/v2/media`
|
- `/api/v2/media`
|
||||||
- `/api/v1/media/:id`
|
- `/api/v1/media/:id`
|
||||||
- `/api/v1/reports`
|
- `/api/v1/reports`
|
||||||
|
|
@ -135,11 +138,7 @@ Endpoints left:
|
||||||
- `/api/v1/tags/:id`
|
- `/api/v1/tags/:id`
|
||||||
- `/api/v1/tags/:id/follow`
|
- `/api/v1/tags/:id/follow`
|
||||||
- `/api/v1/tags/:id/unfollow`
|
- `/api/v1/tags/:id/unfollow`
|
||||||
- `/api/v1/profile/avatar` (`DELETE`)
|
|
||||||
- `/api/v1/profile/header` (`DELETE`)
|
|
||||||
- `/api/v1/statuses/:id/translate`
|
- `/api/v1/statuses/:id/translate`
|
||||||
- `/api/v1/statuses/:id/reblog`
|
|
||||||
- `/api/v1/statuses/:id/unreblog`
|
|
||||||
- `/api/v1/statuses/:id/bookmark`
|
- `/api/v1/statuses/:id/bookmark`
|
||||||
- `/api/v1/statuses/:id/unbookmark`
|
- `/api/v1/statuses/:id/unbookmark`
|
||||||
- `/api/v1/statuses/:id/mute`
|
- `/api/v1/statuses/:id/mute`
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ export const statusAndUserRelations = {
|
||||||
select: {
|
select: {
|
||||||
replies: true,
|
replies: true,
|
||||||
likes: true,
|
likes: true,
|
||||||
|
reblogs: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
reblog: {
|
reblog: {
|
||||||
|
|
@ -138,6 +139,7 @@ export type StatusWithRelations = Status & {
|
||||||
_count: {
|
_count: {
|
||||||
replies: number;
|
replies: number;
|
||||||
likes: number;
|
likes: number;
|
||||||
|
reblogs: number;
|
||||||
};
|
};
|
||||||
reblog:
|
reblog:
|
||||||
| (Status & {
|
| (Status & {
|
||||||
|
|
@ -458,8 +460,12 @@ export const statusToAPI = async (
|
||||||
emojis: await Promise.all(
|
emojis: await Promise.all(
|
||||||
status.emojis.map(emoji => emojiToAPI(emoji))
|
status.emojis.map(emoji => emojiToAPI(emoji))
|
||||||
),
|
),
|
||||||
favourited: !!status.likes.find(like => like.likerId === user?.id),
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
favourites_count: status.likes.length,
|
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: [],
|
media_attachments: [],
|
||||||
mentions: [],
|
mentions: [],
|
||||||
language: null,
|
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", () => {
|
describe("GET /api/v1/accounts/familiar_followers", () => {
|
||||||
test("should follow the user", async () => {
|
test("should follow the user", async () => {
|
||||||
const response = await fetch(
|
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", () => {
|
describe("GET /api/v1/statuses/:id/context", () => {
|
||||||
test("should return the context of the specified status", async () => {
|
test("should return the context of the specified status", async () => {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ export async function parseRequest<T>(request: Request): Promise<Partial<T>> {
|
||||||
query.append(key, JSON.stringify(value));
|
query.append(key, JSON.stringify(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If body is empty
|
||||||
|
if (request.body === null) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
// if request contains a JSON body
|
// if request contains a JSON body
|
||||||
if (request.headers.get("Content-Type")?.includes("application/json")) {
|
if (request.headers.get("Content-Type")?.includes("application/json")) {
|
||||||
return (await request.json()) as T;
|
return (await request.json()) as T;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue