mirror of
https://github.com/versia-pub/server.git
synced 2026-03-13 05:49:16 +01:00
refactor(api): ♻️ More OpenAPI refactoring work
This commit is contained in:
parent
6d9e385a04
commit
5aa1c4e625
35 changed files with 4883 additions and 1815 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { apiRoute, applyConfig, debugRequest, handleZodError } from "@/api";
|
||||
import { apiRoute, applyConfig, debugRequest } from "@/api";
|
||||
import { sentry } from "@/sentry";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { createRoute } from "@hono/zod-openapi";
|
||||
import { getLogger } from "@logtape/logtape";
|
||||
import {
|
||||
EntityValidator,
|
||||
|
|
@ -8,7 +8,6 @@ import {
|
|||
SignatureValidator,
|
||||
} from "@versia/federation";
|
||||
import type { Entity } from "@versia/federation/types";
|
||||
import type { SocketAddress } from "bun";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { matches } from "ip-matching";
|
||||
import { z } from "zod";
|
||||
|
|
@ -20,6 +19,7 @@ import { config } from "~/packages/config-manager";
|
|||
import { Note } from "~/packages/database-interface/note";
|
||||
import { Relationship } from "~/packages/database-interface/relationship";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
import { ErrorSchema } from "~/types/api";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
|
|
@ -46,388 +46,413 @@ export const schemas = {
|
|||
body: z.any(),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
zValidator("header", schemas.header, handleZodError),
|
||||
zValidator("json", schemas.body, handleZodError),
|
||||
async (context) => {
|
||||
const { uuid } = context.req.valid("param");
|
||||
const {
|
||||
"x-signature": signature,
|
||||
"x-nonce": nonce,
|
||||
"x-signed-by": signedBy,
|
||||
authorization,
|
||||
} = context.req.valid("header");
|
||||
const logger = getLogger(["federation", "inbox"]);
|
||||
|
||||
const body: Entity = await context.req.valid("json");
|
||||
|
||||
if (config.debug.federation) {
|
||||
// Debug request
|
||||
await debugRequest(
|
||||
new Request(context.req.url, {
|
||||
method: context.req.method,
|
||||
headers: context.req.raw.headers,
|
||||
body: await context.req.text(),
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/users/{uuid}/inbox",
|
||||
summary: "Receive federation inbox",
|
||||
request: {
|
||||
params: schemas.param,
|
||||
headers: schemas.header,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: schemas.body,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Request processed",
|
||||
},
|
||||
201: {
|
||||
description: "Request accepted",
|
||||
},
|
||||
400: {
|
||||
description: "Bad request",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "Signature could not be verified",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description: "Cannot view users from remote instances",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
500: {
|
||||
description: "Internal server error",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
error: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const user = await User.fromId(uuid);
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { uuid } = context.req.valid("param");
|
||||
const {
|
||||
"x-signature": signature,
|
||||
"x-nonce": nonce,
|
||||
"x-signed-by": signedBy,
|
||||
authorization,
|
||||
} = context.req.valid("header");
|
||||
const logger = getLogger(["federation", "inbox"]);
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "User not found" }, 404);
|
||||
}
|
||||
const body: Entity = await context.req.valid("json");
|
||||
|
||||
if (user.isRemote()) {
|
||||
return context.json(
|
||||
{ error: "Cannot view users from remote instances" },
|
||||
403,
|
||||
);
|
||||
}
|
||||
if (config.debug.federation) {
|
||||
// Debug request
|
||||
await debugRequest(
|
||||
new Request(context.req.url, {
|
||||
method: context.req.method,
|
||||
headers: context.req.raw.headers,
|
||||
body: await context.req.text(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// @ts-expect-error IP attribute is not in types
|
||||
const requestIp = context.env?.ip as
|
||||
| SocketAddress
|
||||
| undefined
|
||||
| null;
|
||||
const user = await User.fromId(uuid);
|
||||
|
||||
let checkSignature = true;
|
||||
if (!user) {
|
||||
return context.json({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
if (config.federation.bridge.enabled) {
|
||||
const token = authorization?.split("Bearer ")[1];
|
||||
if (token) {
|
||||
// Request is bridge request
|
||||
if (token !== config.federation.bridge.token) {
|
||||
return context.json(
|
||||
{
|
||||
error: "An invalid token was passed in the Authorization header. Please use the correct token, or remove the Authorization header.",
|
||||
},
|
||||
401,
|
||||
);
|
||||
}
|
||||
if (user.isRemote()) {
|
||||
return context.json(
|
||||
{ error: "Cannot view users from remote instances" },
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
if (requestIp?.address) {
|
||||
if (config.federation.bridge.allowed_ips.length > 0) {
|
||||
checkSignature = false;
|
||||
}
|
||||
const requestIp = context.env?.ip;
|
||||
|
||||
for (const ip of config.federation.bridge.allowed_ips) {
|
||||
if (matches(ip, requestIp?.address)) {
|
||||
checkSignature = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return context.json(
|
||||
{
|
||||
error: "Request IP address is not available",
|
||||
},
|
||||
500,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let checkSignature = true;
|
||||
|
||||
const sender = await User.resolve(signedBy);
|
||||
|
||||
if (sender?.isLocal()) {
|
||||
return context.json(
|
||||
{ error: "Cannot send federation requests to local users" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const hostname = sender?.data.instance?.baseUrl ?? "";
|
||||
|
||||
// Check if Origin is defederated
|
||||
if (
|
||||
config.federation.blocked.find(
|
||||
(blocked) =>
|
||||
blocked.includes(hostname) ||
|
||||
hostname.includes(blocked),
|
||||
)
|
||||
) {
|
||||
// Pretend to accept request
|
||||
return context.newResponse(null, 201);
|
||||
}
|
||||
|
||||
// Verify request signature
|
||||
if (checkSignature) {
|
||||
if (!sender) {
|
||||
if (config.federation.bridge.enabled) {
|
||||
const token = authorization?.split("Bearer ")[1];
|
||||
if (token) {
|
||||
// Request is bridge request
|
||||
if (token !== config.federation.bridge.token) {
|
||||
return context.json(
|
||||
{ error: "Could not resolve sender" },
|
||||
400,
|
||||
{
|
||||
error: "An invalid token was passed in the Authorization header. Please use the correct token, or remove the Authorization header.",
|
||||
},
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.debug.federation) {
|
||||
// Log public key
|
||||
logger.debug`Sender public key: ${sender.data.publicKey}`;
|
||||
}
|
||||
if (requestIp?.address) {
|
||||
if (config.federation.bridge.allowed_ips.length > 0) {
|
||||
checkSignature = false;
|
||||
}
|
||||
|
||||
const validator = await SignatureValidator.fromStringKey(
|
||||
sender.data.publicKey,
|
||||
);
|
||||
|
||||
const isValid = await validator
|
||||
.validate(
|
||||
new Request(context.req.url, {
|
||||
method: context.req.method,
|
||||
headers: {
|
||||
"X-Signature": signature,
|
||||
"X-Nonce": nonce,
|
||||
},
|
||||
body: await context.req.text(),
|
||||
}),
|
||||
)
|
||||
.catch((e) => {
|
||||
logger.error`${e}`;
|
||||
sentry?.captureException(e);
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
return context.json({ error: "Invalid signature" }, 401);
|
||||
for (const ip of config.federation.bridge.allowed_ips) {
|
||||
if (matches(ip, requestIp?.address)) {
|
||||
checkSignature = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return context.json(
|
||||
{
|
||||
error: "Request IP address is not available",
|
||||
},
|
||||
500,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const validator = new EntityValidator();
|
||||
const handler = new RequestParserHandler(body, validator);
|
||||
const sender = await User.resolve(signedBy);
|
||||
|
||||
try {
|
||||
return await handler.parseBody<Response>({
|
||||
note: async (note) => {
|
||||
const account = await User.resolve(note.author);
|
||||
if (sender?.isLocal()) {
|
||||
return context.json(
|
||||
{ error: "Cannot send federation requests to local users" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
return context.json(
|
||||
{ error: "Author not found" },
|
||||
404,
|
||||
);
|
||||
}
|
||||
const hostname = sender?.data.instance?.baseUrl ?? "";
|
||||
|
||||
const newStatus = await Note.fromVersia(
|
||||
note,
|
||||
account,
|
||||
).catch((e) => {
|
||||
logger.error`${e}`;
|
||||
sentry?.captureException(e);
|
||||
return null;
|
||||
});
|
||||
// Check if Origin is defederated
|
||||
if (
|
||||
config.federation.blocked.find(
|
||||
(blocked) =>
|
||||
blocked.includes(hostname) || hostname.includes(blocked),
|
||||
)
|
||||
) {
|
||||
// Pretend to accept request
|
||||
return context.newResponse(null, 201);
|
||||
}
|
||||
|
||||
if (!newStatus) {
|
||||
return context.json(
|
||||
{ error: "Failed to add status" },
|
||||
500,
|
||||
);
|
||||
}
|
||||
// Verify request signature
|
||||
if (checkSignature) {
|
||||
if (!sender) {
|
||||
return context.json({ error: "Could not resolve sender" }, 400);
|
||||
}
|
||||
|
||||
return context.text("Note created", 201);
|
||||
},
|
||||
follow: async (follow) => {
|
||||
const account = await User.resolve(follow.author);
|
||||
if (config.debug.federation) {
|
||||
// Log public key
|
||||
logger.debug`Sender public key: ${sender.data.publicKey}`;
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
return context.json(
|
||||
{ error: "Author not found" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
const validator = await SignatureValidator.fromStringKey(
|
||||
sender.data.publicKey,
|
||||
);
|
||||
|
||||
const foundRelationship =
|
||||
await Relationship.fromOwnerAndSubject(
|
||||
account,
|
||||
user,
|
||||
const isValid = await validator
|
||||
.validate(
|
||||
new Request(context.req.url, {
|
||||
method: context.req.method,
|
||||
headers: {
|
||||
"X-Signature": signature,
|
||||
"X-Nonce": nonce,
|
||||
},
|
||||
body: await context.req.text(),
|
||||
}),
|
||||
)
|
||||
.catch((e) => {
|
||||
logger.error`${e}`;
|
||||
sentry?.captureException(e);
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
return context.json(
|
||||
{ error: "Signature could not be verified" },
|
||||
401,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const validator = new EntityValidator();
|
||||
const handler = new RequestParserHandler(body, validator);
|
||||
|
||||
try {
|
||||
return await handler.parseBody<Response>({
|
||||
note: async (note) => {
|
||||
const account = await User.resolve(note.author);
|
||||
|
||||
if (!account) {
|
||||
return context.json({ error: "Author not found" }, 404);
|
||||
}
|
||||
|
||||
const newStatus = await Note.fromVersia(
|
||||
note,
|
||||
account,
|
||||
).catch((e) => {
|
||||
logger.error`${e}`;
|
||||
sentry?.captureException(e);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!newStatus) {
|
||||
return context.json(
|
||||
{ error: "Failed to add status" },
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
return context.text("Note created", 201);
|
||||
},
|
||||
follow: async (follow) => {
|
||||
const account = await User.resolve(follow.author);
|
||||
|
||||
if (!account) {
|
||||
return context.json({ error: "Author not found" }, 400);
|
||||
}
|
||||
|
||||
const foundRelationship =
|
||||
await Relationship.fromOwnerAndSubject(account, user);
|
||||
|
||||
if (foundRelationship.data.following) {
|
||||
return context.text("Already following", 200);
|
||||
}
|
||||
|
||||
await foundRelationship.update({
|
||||
following: !user.data.isLocked,
|
||||
requested: user.data.isLocked,
|
||||
showingReblogs: true,
|
||||
notifying: true,
|
||||
languages: [],
|
||||
});
|
||||
|
||||
await db.insert(Notifications).values({
|
||||
accountId: account.id,
|
||||
type: user.data.isLocked ? "follow_request" : "follow",
|
||||
notifiedId: user.id,
|
||||
});
|
||||
|
||||
if (!user.data.isLocked) {
|
||||
await sendFollowAccept(account, user);
|
||||
}
|
||||
|
||||
return context.text("Follow request sent", 200);
|
||||
},
|
||||
followAccept: async (followAccept) => {
|
||||
const account = await User.resolve(followAccept.author);
|
||||
|
||||
if (!account) {
|
||||
return context.json({ error: "Author not found" }, 400);
|
||||
}
|
||||
|
||||
const foundRelationship =
|
||||
await Relationship.fromOwnerAndSubject(user, account);
|
||||
|
||||
if (!foundRelationship.data.requested) {
|
||||
return context.text(
|
||||
"There is no follow request to accept",
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
await foundRelationship.update({
|
||||
requested: false,
|
||||
following: true,
|
||||
});
|
||||
|
||||
return context.text("Follow request accepted", 200);
|
||||
},
|
||||
followReject: async (followReject) => {
|
||||
const account = await User.resolve(followReject.author);
|
||||
|
||||
if (!account) {
|
||||
return context.json({ error: "Author not found" }, 400);
|
||||
}
|
||||
|
||||
const foundRelationship =
|
||||
await Relationship.fromOwnerAndSubject(user, account);
|
||||
|
||||
if (!foundRelationship.data.requested) {
|
||||
return context.text(
|
||||
"There is no follow request to reject",
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
await foundRelationship.update({
|
||||
requested: false,
|
||||
following: false,
|
||||
});
|
||||
|
||||
return context.text("Follow request rejected", 200);
|
||||
},
|
||||
// "delete" is a reserved keyword in JS
|
||||
delete: async (delete_) => {
|
||||
// Delete the specified object from database, if it exists and belongs to the user
|
||||
const toDelete = delete_.target;
|
||||
|
||||
switch (delete_.deleted_type) {
|
||||
case "Note": {
|
||||
const note = await Note.fromSql(
|
||||
eq(Notes.uri, toDelete),
|
||||
eq(Notes.authorId, user.id),
|
||||
);
|
||||
|
||||
if (foundRelationship.data.following) {
|
||||
return context.text("Already following", 200);
|
||||
}
|
||||
|
||||
await foundRelationship.update({
|
||||
following: !user.data.isLocked,
|
||||
requested: user.data.isLocked,
|
||||
showingReblogs: true,
|
||||
notifying: true,
|
||||
languages: [],
|
||||
});
|
||||
|
||||
await db.insert(Notifications).values({
|
||||
accountId: account.id,
|
||||
type: user.data.isLocked
|
||||
? "follow_request"
|
||||
: "follow",
|
||||
notifiedId: user.id,
|
||||
});
|
||||
|
||||
if (!user.data.isLocked) {
|
||||
await sendFollowAccept(account, user);
|
||||
}
|
||||
|
||||
return context.text("Follow request sent", 200);
|
||||
},
|
||||
followAccept: async (followAccept) => {
|
||||
const account = await User.resolve(followAccept.author);
|
||||
|
||||
if (!account) {
|
||||
return context.json(
|
||||
{ error: "Author not found" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const foundRelationship =
|
||||
await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
account,
|
||||
);
|
||||
|
||||
if (!foundRelationship.data.requested) {
|
||||
return context.text(
|
||||
"There is no follow request to accept",
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
await foundRelationship.update({
|
||||
requested: false,
|
||||
following: true,
|
||||
});
|
||||
|
||||
return context.text("Follow request accepted", 200);
|
||||
},
|
||||
followReject: async (followReject) => {
|
||||
const account = await User.resolve(followReject.author);
|
||||
|
||||
if (!account) {
|
||||
return context.json(
|
||||
{ error: "Author not found" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const foundRelationship =
|
||||
await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
account,
|
||||
);
|
||||
|
||||
if (!foundRelationship.data.requested) {
|
||||
return context.text(
|
||||
"There is no follow request to reject",
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
await foundRelationship.update({
|
||||
requested: false,
|
||||
following: false,
|
||||
});
|
||||
|
||||
return context.text("Follow request rejected", 200);
|
||||
},
|
||||
// "delete" is a reserved keyword in JS
|
||||
delete: async (delete_) => {
|
||||
// Delete the specified object from database, if it exists and belongs to the user
|
||||
const toDelete = delete_.target;
|
||||
|
||||
switch (delete_.deleted_type) {
|
||||
case "Note": {
|
||||
const note = await Note.fromSql(
|
||||
eq(Notes.uri, toDelete),
|
||||
eq(Notes.authorId, user.id),
|
||||
);
|
||||
|
||||
if (note) {
|
||||
await note.delete();
|
||||
return context.text("Note deleted", 200);
|
||||
}
|
||||
|
||||
break;
|
||||
if (note) {
|
||||
await note.delete();
|
||||
return context.text("Note deleted", 200);
|
||||
}
|
||||
case "User": {
|
||||
const otherUser = await User.resolve(toDelete);
|
||||
|
||||
if (otherUser) {
|
||||
if (otherUser.id === user.id) {
|
||||
// Delete own account
|
||||
await user.delete();
|
||||
return context.text(
|
||||
"Account deleted",
|
||||
200,
|
||||
);
|
||||
}
|
||||
return context.json(
|
||||
{
|
||||
error: "Cannot delete other users than self",
|
||||
},
|
||||
400,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "User": {
|
||||
const otherUser = await User.resolve(toDelete);
|
||||
|
||||
if (otherUser) {
|
||||
if (otherUser.id === user.id) {
|
||||
// Delete own account
|
||||
await user.delete();
|
||||
return context.text("Account deleted", 200);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
return context.json(
|
||||
{
|
||||
error: `Deletetion of object ${toDelete} not implemented`,
|
||||
error: "Cannot delete other users than self",
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return context.json(
|
||||
{ error: "Object not found or not owned by user" },
|
||||
404,
|
||||
);
|
||||
},
|
||||
user: async (user) => {
|
||||
// Refetch user to ensure we have the latest data
|
||||
const updatedAccount = await User.saveFromRemote(
|
||||
user.uri,
|
||||
);
|
||||
|
||||
if (!updatedAccount) {
|
||||
default: {
|
||||
return context.json(
|
||||
{ error: "Failed to update user" },
|
||||
500,
|
||||
{
|
||||
error: `Deletetion of object ${toDelete} not implemented`,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return context.text("User refreshed", 200);
|
||||
},
|
||||
unknown: () => {
|
||||
return context.json(
|
||||
{ error: "Unknown entity type" },
|
||||
400,
|
||||
);
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
if (isValidationError(e)) {
|
||||
return context.json(
|
||||
{
|
||||
error: "Failed to process request",
|
||||
error_description: (e as ValidationError).message,
|
||||
},
|
||||
400,
|
||||
{ error: "Object not found or not owned by user" },
|
||||
404,
|
||||
);
|
||||
}
|
||||
logger.error`${e}`;
|
||||
sentry?.captureException(e);
|
||||
},
|
||||
user: async (user) => {
|
||||
// Refetch user to ensure we have the latest data
|
||||
const updatedAccount = await User.saveFromRemote(user.uri);
|
||||
|
||||
if (!updatedAccount) {
|
||||
return context.json(
|
||||
{ error: "Failed to update user" },
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
return context.text("User refreshed", 200);
|
||||
},
|
||||
unknown: () => {
|
||||
return context.json({ error: "Unknown entity type" }, 400);
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
if (isValidationError(e)) {
|
||||
return context.json(
|
||||
{
|
||||
error: "Failed to process request",
|
||||
message: (e as Error).message,
|
||||
error_description: (e as ValidationError).message,
|
||||
},
|
||||
500,
|
||||
400,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
logger.error`${e}`;
|
||||
sentry?.captureException(e);
|
||||
return context.json(
|
||||
{
|
||||
error: "Failed to process request",
|
||||
message: (e as Error).message,
|
||||
},
|
||||
500,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { apiRoute, applyConfig, handleZodError } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { apiRoute, applyConfig } from "@/api";
|
||||
import { createRoute } from "@hono/zod-openapi";
|
||||
import { User as UserSchema } from "@versia/federation/schemas";
|
||||
import { z } from "zod";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
import { ErrorSchema } from "~/types/api";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
|
|
@ -21,44 +23,71 @@ export const schemas = {
|
|||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
async (context) => {
|
||||
const { uuid } = context.req.valid("param");
|
||||
|
||||
const user = await User.fromId(uuid);
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
if (user.isRemote()) {
|
||||
return context.json(
|
||||
{ error: "Cannot view users from remote instances" },
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
// Try to detect a web browser and redirect to the user's profile page
|
||||
if (
|
||||
context.req.header("user-agent")?.includes("Mozilla") &&
|
||||
uuid !== "actor"
|
||||
) {
|
||||
return context.redirect(user.toApi().url);
|
||||
}
|
||||
|
||||
const userJson = user.toVersia();
|
||||
|
||||
const { headers } = await user.sign(
|
||||
userJson,
|
||||
context.req.url,
|
||||
"GET",
|
||||
);
|
||||
|
||||
return context.json(userJson, 200, headers.toJSON());
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/users/{uuid}",
|
||||
summary: "Get user data",
|
||||
request: {
|
||||
params: schemas.param,
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "User data",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: UserSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
301: {
|
||||
description:
|
||||
"Redirect to user profile (for web browsers). Uses user-agent for detection.",
|
||||
},
|
||||
404: {
|
||||
description: "User not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description: "Cannot view users from remote instances",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { uuid } = context.req.valid("param");
|
||||
|
||||
const user = await User.fromId(uuid);
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
if (user.isRemote()) {
|
||||
return context.json(
|
||||
{ error: "Cannot view users from remote instances" },
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
// Try to detect a web browser and redirect to the user's profile page
|
||||
if (context.req.header("user-agent")?.includes("Mozilla")) {
|
||||
return context.redirect(user.toApi().url);
|
||||
}
|
||||
|
||||
const userJson = user.toVersia();
|
||||
|
||||
const { headers } = await user.sign(userJson, context.req.url, "GET");
|
||||
|
||||
return context.json(userJson, 200, headers.toJSON());
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { apiRoute, applyConfig, handleZodError } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import type { Collection } from "@versia/federation/types";
|
||||
import { apiRoute, applyConfig } from "@/api";
|
||||
import { createRoute } from "@hono/zod-openapi";
|
||||
import {
|
||||
Collection as CollectionSchema,
|
||||
Note as NoteSchema,
|
||||
} from "@versia/federation/schemas";
|
||||
import { and, count, eq, inArray } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "~/drizzle/db";
|
||||
|
|
@ -8,6 +11,7 @@ import { Notes } from "~/drizzle/schema";
|
|||
import { config } from "~/packages/config-manager";
|
||||
import { Note } from "~/packages/database-interface/note";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
import { ErrorSchema } from "~/types/api";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
|
|
@ -30,89 +34,121 @@ export const schemas = {
|
|||
}),
|
||||
};
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/users/{uuid}/outbox",
|
||||
summary: "Get user outbox",
|
||||
request: {
|
||||
params: schemas.param,
|
||||
query: schemas.query,
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "User outbox",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: CollectionSchema.extend({
|
||||
items: z.array(NoteSchema),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "User not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description: "Cannot view users from remote instances",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const NOTES_PER_PAGE = 20;
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
zValidator("query", schemas.query, handleZodError),
|
||||
async (context) => {
|
||||
const { uuid } = context.req.valid("param");
|
||||
app.openapi(route, async (context) => {
|
||||
const { uuid } = context.req.valid("param");
|
||||
|
||||
const author = await User.fromId(uuid);
|
||||
const author = await User.fromId(uuid);
|
||||
|
||||
if (!author) {
|
||||
return context.json({ error: "User not found" }, 404);
|
||||
}
|
||||
if (!author) {
|
||||
return context.json({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
if (author.isRemote()) {
|
||||
return context.json(
|
||||
{ error: "Cannot view users from remote instances" },
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
const pageNumber = Number(context.req.valid("query").page) || 1;
|
||||
|
||||
const notes = await Note.manyFromSql(
|
||||
and(
|
||||
eq(Notes.authorId, uuid),
|
||||
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||
),
|
||||
undefined,
|
||||
NOTES_PER_PAGE,
|
||||
NOTES_PER_PAGE * (pageNumber - 1),
|
||||
if (author.isRemote()) {
|
||||
return context.json(
|
||||
{ error: "Cannot view users from remote instances" },
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
const totalNotes = (
|
||||
await db
|
||||
.select({
|
||||
count: count(),
|
||||
})
|
||||
.from(Notes)
|
||||
.where(
|
||||
and(
|
||||
eq(Notes.authorId, uuid),
|
||||
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||
),
|
||||
)
|
||||
)[0].count;
|
||||
const pageNumber = Number(context.req.valid("query").page) || 1;
|
||||
|
||||
const json = {
|
||||
first: new URL(
|
||||
`/users/${uuid}/outbox?page=1`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
last: new URL(
|
||||
`/users/${uuid}/outbox?page=${Math.ceil(
|
||||
totalNotes / NOTES_PER_PAGE,
|
||||
)}`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
total: totalNotes,
|
||||
author: author.getUri(),
|
||||
next:
|
||||
notes.length === NOTES_PER_PAGE
|
||||
? new URL(
|
||||
`/users/${uuid}/outbox?page=${pageNumber + 1}`,
|
||||
config.http.base_url,
|
||||
).toString()
|
||||
: null,
|
||||
previous:
|
||||
pageNumber > 1
|
||||
? new URL(
|
||||
`/users/${uuid}/outbox?page=${pageNumber - 1}`,
|
||||
config.http.base_url,
|
||||
).toString()
|
||||
: null,
|
||||
items: notes.map((note) => note.toVersia()),
|
||||
} satisfies Collection;
|
||||
const notes = await Note.manyFromSql(
|
||||
and(
|
||||
eq(Notes.authorId, uuid),
|
||||
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||
),
|
||||
undefined,
|
||||
NOTES_PER_PAGE,
|
||||
NOTES_PER_PAGE * (pageNumber - 1),
|
||||
);
|
||||
|
||||
const { headers } = await author.sign(json, context.req.url, "GET");
|
||||
const totalNotes = (
|
||||
await db
|
||||
.select({
|
||||
count: count(),
|
||||
})
|
||||
.from(Notes)
|
||||
.where(
|
||||
and(
|
||||
eq(Notes.authorId, uuid),
|
||||
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||
),
|
||||
)
|
||||
)[0].count;
|
||||
|
||||
return context.json(json, 200, headers.toJSON());
|
||||
},
|
||||
),
|
||||
const json = {
|
||||
first: new URL(
|
||||
`/users/${uuid}/outbox?page=1`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
last: new URL(
|
||||
`/users/${uuid}/outbox?page=${Math.ceil(
|
||||
totalNotes / NOTES_PER_PAGE,
|
||||
)}`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
total: totalNotes,
|
||||
author: author.getUri(),
|
||||
next:
|
||||
notes.length === NOTES_PER_PAGE
|
||||
? new URL(
|
||||
`/users/${uuid}/outbox?page=${pageNumber + 1}`,
|
||||
config.http.base_url,
|
||||
).toString()
|
||||
: null,
|
||||
previous:
|
||||
pageNumber > 1
|
||||
? new URL(
|
||||
`/users/${uuid}/outbox?page=${pageNumber - 1}`,
|
||||
config.http.base_url,
|
||||
).toString()
|
||||
: null,
|
||||
items: notes.map((note) => note.toVersia()),
|
||||
};
|
||||
|
||||
const { headers } = await author.sign(json, context.req.url, "GET");
|
||||
|
||||
return context.json(json, 200, headers.toJSON());
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue