server/api/api/v1/statuses/:id/index.ts

277 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
apiRoute,
auth,
jsonOrForm,
noteNotFound,
reusedResponses,
withNoteParam,
} from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { Media } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables";
import { ApiError } from "~/classes/errors/api-error";
import { Attachment as AttachmentSchema } from "~/classes/schemas/attachment";
import { zBoolean } from "~/classes/schemas/common.ts";
import { PollOption } from "~/classes/schemas/poll";
import {
Status as StatusSchema,
StatusSource as StatusSourceSchema,
} from "~/classes/schemas/status";
import { config } from "~/config.ts";
const schema = z
.object({
status: StatusSourceSchema.shape.text.optional().openapi({
description:
"The text content of the status. If media_ids is provided, this becomes optional. Attaching a poll is optional while status is provided.",
}),
/* Versia Server API Extension */
content_type: z
.enum(["text/plain", "text/html", "text/markdown"])
.default("text/plain")
.openapi({
description: "Content-Type of the status text.",
example: "text/markdown",
}),
media_ids: z
.array(AttachmentSchema.shape.id)
.max(config.validation.notes.max_attachments)
.default([])
.openapi({
description:
"Include Attachment IDs to be attached as media. If provided, status becomes optional, and poll cannot be used.",
}),
spoiler_text: StatusSourceSchema.shape.spoiler_text.optional().openapi({
description:
"Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field.",
}),
sensitive: zBoolean.default(false).openapi({
description: "Mark status and attached media as sensitive?",
}),
language: StatusSchema.shape.language.optional(),
"poll[options]": z
.array(PollOption.shape.title)
.max(config.validation.polls.max_options)
.optional()
.openapi({
description:
"Possible answers to the poll. If provided, media_ids cannot be used, and poll[expires_in] must be provided.",
}),
"poll[expires_in]": z.coerce
.number()
.int()
.min(config.validation.polls.min_duration_seconds)
.max(config.validation.polls.max_duration_seconds)
.optional()
.openapi({
description:
"Duration that the poll should be open, in seconds. If provided, media_ids cannot be used, and poll[options] must be provided.",
}),
"poll[multiple]": zBoolean.optional().openapi({
description: "Allow multiple choices?",
}),
"poll[hide_totals]": zBoolean.optional().openapi({
description: "Hide vote counts until the poll ends?",
}),
})
.refine(
(obj) => !(obj.media_ids.length > 0 && obj["poll[options]"]),
"Cannot attach poll to media",
);
const routeGet = createRoute({
method: "get",
path: "/api/v1/statuses/{id}",
summary: "View a single status",
description: "Obtain information about a status.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/statuses/#get",
},
tags: ["Statuses"],
middleware: [
auth({
auth: false,
permissions: [RolePermissions.ViewNotes],
}),
withNoteParam,
] as const,
request: {
params: z.object({
id: StatusSchema.shape.id,
}),
},
responses: {
200: {
description: "Status",
content: {
"application/json": {
schema: StatusSchema,
},
},
},
404: noteNotFound,
},
});
const routeDelete = createRoute({
method: "delete",
path: "/api/v1/statuses/{id}",
summary: "Delete a status",
description: "Delete one of your own statuses.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/statuses/#delete",
},
tags: ["Statuses"],
middleware: [
auth({
auth: true,
permissions: [
RolePermissions.ManageOwnNotes,
RolePermissions.ViewNotes,
],
}),
withNoteParam,
] as const,
request: {
params: z.object({
id: StatusSchema.shape.id,
}),
},
responses: {
200: {
description:
"Note the special properties text and poll or media_attachments which may be used to repost the status, e.g. in case of delete-and-redraft functionality.",
content: {
"application/json": {
schema: StatusSchema,
},
},
},
404: noteNotFound,
401: reusedResponses[401],
},
});
const routePut = createRoute({
method: "put",
path: "/api/v1/statuses/{id}",
summary: "Edit a status",
description:
"Edit a given status to change its text, sensitivity, media attachments, or poll. Note that editing a polls options will reset the votes.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/statuses/#edit",
},
tags: ["Statuses"],
middleware: [
auth({
auth: true,
permissions: [
RolePermissions.ManageOwnNotes,
RolePermissions.ViewNotes,
],
}),
jsonOrForm(),
withNoteParam,
] as const,
request: {
params: z.object({
id: StatusSchema.shape.id,
}),
body: {
content: {
"application/json": {
schema,
},
"application/x-www-form-urlencoded": {
schema,
},
"multipart/form-data": {
schema,
},
},
},
},
responses: {
200: {
description: "Status has been successfully edited.",
content: {
"application/json": {
schema: StatusSchema,
},
},
},
404: noteNotFound,
...reusedResponses,
},
});
export default apiRoute((app) => {
app.openapi(routeGet, async (context) => {
const { user } = context.get("auth");
const note = context.get("note");
return context.json(await note.toApi(user), 200);
});
app.openapi(routeDelete, async (context) => {
const { user } = context.get("auth");
const note = context.get("note");
if (note.author.id !== user.id) {
throw new ApiError(401, "Unauthorized");
}
// TODO: Delete and redraft
await note.delete();
await user.federateToFollowers(note.deleteToVersia());
return context.json(await note.toApi(user), 200);
});
app.openapi(routePut, async (context) => {
const { user } = context.get("auth");
const note = context.get("note");
if (note.author.id !== user.id) {
throw new ApiError(401, "Unauthorized");
}
// TODO: Polls
const {
status: statusText,
content_type,
media_ids,
spoiler_text,
sensitive,
} = context.req.valid("json");
const foundAttachments =
media_ids.length > 0 ? await Media.fromIds(media_ids) : [];
if (foundAttachments.length !== media_ids.length) {
throw new ApiError(
422,
"Some attachments referenced by media_ids not found",
);
}
const newNote = await note.updateFromData({
author: user,
content: statusText
? {
[content_type]: {
content: statusText,
remote: false,
},
}
: undefined,
isSensitive: sensitive,
spoilerText: spoiler_text,
mediaAttachments: foundAttachments,
});
return context.json(await newNote.toApi(user), 200);
});
});