mirror of
https://github.com/versia-pub/server.git
synced 2026-04-27 20:59:15 +02:00
feat(federation): ✨ Port to Versia 0.6
This commit is contained in:
parent
de69f27877
commit
fca30b4dad
62 changed files with 1614 additions and 2008 deletions
|
|
@ -50,7 +50,10 @@ export default apiRoute((app) =>
|
|||
throw new ApiError(400, "Cannot refetch a local user");
|
||||
}
|
||||
|
||||
const newUser = await User.fromVersia(otherUser.uri);
|
||||
const newUser = await User.fromVersia(
|
||||
otherUser.reference,
|
||||
otherUser.reference.domain as string,
|
||||
);
|
||||
|
||||
return context.json(newUser.toApi(false), 200);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
Account as AccountSchema,
|
||||
RolePermission,
|
||||
} from "@versia/client/schemas";
|
||||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import { config } from "@versia-server/config";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import { apiRoute, auth, handleZodError } from "@versia-server/kit/api";
|
||||
|
|
@ -73,7 +74,7 @@ export default apiRoute((app) =>
|
|||
|
||||
// User is remote
|
||||
// Try to fetch it from database
|
||||
const instance = await Instance.resolveFromHost(domain);
|
||||
const instance = await Instance.resolve(domain);
|
||||
|
||||
if (!instance) {
|
||||
return context.json(
|
||||
|
|
@ -100,13 +101,17 @@ export default apiRoute((app) =>
|
|||
throw ApiError.accountNotFound();
|
||||
}
|
||||
|
||||
const foundAccount = await User.resolve(uri);
|
||||
const accountData = await Instance.federationRequester.fetchSigned(
|
||||
uri,
|
||||
VersiaEntities.User,
|
||||
);
|
||||
|
||||
if (foundAccount) {
|
||||
return context.json(foundAccount.toApi(), 200);
|
||||
}
|
||||
const foundAccount = await User.fromVersia(
|
||||
accountData,
|
||||
instance.data.baseUrl,
|
||||
);
|
||||
|
||||
throw ApiError.accountNotFound();
|
||||
return context.json(foundAccount.toApi(), 200);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@ import {
|
|||
RolePermission,
|
||||
zBoolean,
|
||||
} from "@versia/client/schemas";
|
||||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import { apiRoute, auth, handleZodError } from "@versia-server/kit/api";
|
||||
import { User } from "@versia-server/kit/db";
|
||||
import { Instance, User } from "@versia-server/kit/db";
|
||||
import { parseUserAddress } from "@versia-server/kit/parsers";
|
||||
import { Users } from "@versia-server/kit/tables";
|
||||
import { eq, ilike, not, or, sql } from "drizzle-orm";
|
||||
|
|
@ -88,14 +89,22 @@ export default apiRoute((app) =>
|
|||
const accounts: User[] = [];
|
||||
|
||||
if (resolve && domain) {
|
||||
const instance = await Instance.resolve(domain);
|
||||
const uri = await User.webFinger(username, domain);
|
||||
|
||||
if (uri) {
|
||||
const resolvedUser = await User.resolve(uri);
|
||||
const accountData =
|
||||
await Instance.federationRequester.fetchSigned(
|
||||
uri,
|
||||
VersiaEntities.User,
|
||||
);
|
||||
|
||||
if (resolvedUser) {
|
||||
accounts.push(resolvedUser);
|
||||
}
|
||||
const foundAccount = await User.fromVersia(
|
||||
accountData,
|
||||
instance.data.baseUrl,
|
||||
);
|
||||
|
||||
accounts.push(foundAccount);
|
||||
}
|
||||
} else {
|
||||
accounts.push(
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@ import {
|
|||
userAddressRegex,
|
||||
zBoolean,
|
||||
} from "@versia/client/schemas";
|
||||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import { config } from "@versia-server/config";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import { apiRoute, auth, handleZodError } from "@versia-server/kit/api";
|
||||
import { db, Note, User } from "@versia-server/kit/db";
|
||||
import { db, Instance, Note, User } from "@versia-server/kit/db";
|
||||
import { parseUserAddress } from "@versia-server/kit/parsers";
|
||||
import { searchManager } from "@versia-server/kit/search";
|
||||
import { Instances, Notes, Users } from "@versia-server/kit/tables";
|
||||
|
|
@ -187,21 +188,29 @@ export default apiRoute((app) =>
|
|||
}
|
||||
|
||||
if (resolve && domain) {
|
||||
const instance = await Instance.resolve(domain);
|
||||
const uri = await User.webFinger(username, domain);
|
||||
|
||||
if (uri) {
|
||||
const newUser = await User.resolve(uri);
|
||||
|
||||
if (newUser) {
|
||||
return context.json(
|
||||
{
|
||||
accounts: [newUser.toApi()],
|
||||
statuses: [],
|
||||
hashtags: [],
|
||||
},
|
||||
200,
|
||||
const accountData =
|
||||
await Instance.federationRequester.fetchSigned(
|
||||
uri,
|
||||
VersiaEntities.User,
|
||||
);
|
||||
}
|
||||
|
||||
const newUser = await User.fromVersia(
|
||||
accountData,
|
||||
instance.data.baseUrl,
|
||||
);
|
||||
|
||||
return context.json(
|
||||
{
|
||||
accounts: [newUser.toApi()],
|
||||
statuses: [],
|
||||
hashtags: [],
|
||||
},
|
||||
200,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,84 +0,0 @@
|
|||
import { Status as StatusSchema } from "@versia/client/schemas";
|
||||
import { LikeSchema } from "@versia/sdk/schemas";
|
||||
import { config } from "@versia-server/config";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import { apiRoute, handleZodError } from "@versia-server/kit/api";
|
||||
import { Like, User } from "@versia-server/kit/db";
|
||||
import { Likes } from "@versia-server/kit/tables";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||
import { z } from "zod";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/likes/:id",
|
||||
describeRoute({
|
||||
summary: "Retrieve the Versia representation of a like.",
|
||||
tags: ["Federation"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Like",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(LikeSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description:
|
||||
"Entity not found, is remote, or the requester is not allowed to view it.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ApiError.zodSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({ id: StatusSchema.shape.id }),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
|
||||
// Don't fetch a like of a note that is not public or unlisted
|
||||
// prevents leaking the existence of a private note
|
||||
const like = await Like.fromSql(
|
||||
and(
|
||||
eq(Likes.id, id),
|
||||
sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."id" = ${Likes.likedId} AND "Notes"."visibility" IN ('public', 'unlisted'))`,
|
||||
),
|
||||
);
|
||||
|
||||
if (!like) {
|
||||
throw ApiError.likeNotFound();
|
||||
}
|
||||
|
||||
const liker = await User.fromId(like.data.likerId);
|
||||
|
||||
if (!liker || liker.remote) {
|
||||
throw ApiError.accountNotFound();
|
||||
}
|
||||
|
||||
// If base_url uses https and request uses http, rewrite request to use https
|
||||
// This fixes reverse proxy errors
|
||||
const reqUrl = new URL(context.req.url);
|
||||
if (
|
||||
config.http.base_url.protocol === "https:" &&
|
||||
reqUrl.protocol === "http:"
|
||||
) {
|
||||
reqUrl.protocol = "https:";
|
||||
}
|
||||
|
||||
const { headers } = await liker.sign(
|
||||
like.toVersia(),
|
||||
reqUrl,
|
||||
"GET",
|
||||
);
|
||||
|
||||
return context.json(like.toVersia(), 200, headers.toJSON());
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import { Status as StatusSchema } from "@versia/client/schemas";
|
||||
import { NoteSchema } from "@versia/sdk/schemas";
|
||||
import { config } from "@versia-server/config";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import { apiRoute, handleZodError } from "@versia-server/kit/api";
|
||||
import { Note } from "@versia-server/kit/db";
|
||||
import { Notes } from "@versia-server/kit/tables";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||
import { z } from "zod";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/notes/:id",
|
||||
describeRoute({
|
||||
summary: "Retrieve the Versia representation of a note.",
|
||||
tags: ["Federation"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Note",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(NoteSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description:
|
||||
"Entity not found, is remote, or the requester is not allowed to view it.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ApiError.zodSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: StatusSchema.shape.id,
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
|
||||
const note = await Note.fromSql(
|
||||
and(
|
||||
eq(Notes.id, id),
|
||||
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||
),
|
||||
);
|
||||
|
||||
if (!(note && (await note.isViewableByUser(null))) || note.remote) {
|
||||
throw ApiError.noteNotFound();
|
||||
}
|
||||
|
||||
// If base_url uses https and request uses http, rewrite request to use https
|
||||
// This fixes reverse proxy errors
|
||||
const reqUrl = new URL(context.req.url);
|
||||
if (
|
||||
config.http.base_url.protocol === "https:" &&
|
||||
reqUrl.protocol === "http:"
|
||||
) {
|
||||
reqUrl.protocol = "https:";
|
||||
}
|
||||
|
||||
const { headers } = await note.author.sign(
|
||||
note.toVersia(),
|
||||
reqUrl,
|
||||
"GET",
|
||||
);
|
||||
|
||||
return context.json(note.toVersia(), 200, headers.toJSON());
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
import { Status as StatusSchema } from "@versia/client/schemas";
|
||||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import { URICollectionSchema } from "@versia/sdk/schemas";
|
||||
import { config } from "@versia-server/config";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import { apiRoute, handleZodError } from "@versia-server/kit/api";
|
||||
import { db, Note } from "@versia-server/kit/db";
|
||||
import { Notes } from "@versia-server/kit/tables";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||
import { z } from "zod";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/notes/:id/quotes",
|
||||
describeRoute({
|
||||
summary: "Retrieve all quotes of a Versia Note.",
|
||||
tags: ["Federation"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Note quotes",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(URICollectionSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description:
|
||||
"Entity not found, is remote, or the requester is not allowed to view it.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ApiError.zodSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: StatusSchema.shape.id,
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(40),
|
||||
offset: z.coerce.number().int().nonnegative().default(0),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
const { limit, offset } = context.req.valid("query");
|
||||
|
||||
const note = await Note.fromSql(
|
||||
and(
|
||||
eq(Notes.id, id),
|
||||
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||
),
|
||||
);
|
||||
|
||||
if (!(note && (await note.isViewableByUser(null))) || note.remote) {
|
||||
throw ApiError.noteNotFound();
|
||||
}
|
||||
|
||||
const quotes = await Note.manyFromSql(
|
||||
and(
|
||||
eq(Notes.quotingId, note.id),
|
||||
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||
),
|
||||
undefined,
|
||||
limit,
|
||||
offset,
|
||||
);
|
||||
|
||||
const quoteCount = await db.$count(
|
||||
Notes,
|
||||
and(
|
||||
eq(Notes.quotingId, note.id),
|
||||
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||
),
|
||||
);
|
||||
|
||||
const uriCollection = new VersiaEntities.URICollection({
|
||||
author: note.author.uri.href,
|
||||
first: new URL(
|
||||
`/notes/${note.id}/quotes?offset=0`,
|
||||
config.http.base_url,
|
||||
).href,
|
||||
last:
|
||||
quoteCount > limit
|
||||
? new URL(
|
||||
`/notes/${note.id}/quotes?offset=${
|
||||
quoteCount - limit
|
||||
}`,
|
||||
config.http.base_url,
|
||||
).href
|
||||
: new URL(
|
||||
`/notes/${note.id}/quotes`,
|
||||
config.http.base_url,
|
||||
).href,
|
||||
next:
|
||||
offset + limit < quoteCount
|
||||
? new URL(
|
||||
`/notes/${note.id}/quotes?offset=${
|
||||
offset + limit
|
||||
}`,
|
||||
config.http.base_url,
|
||||
).href
|
||||
: null,
|
||||
previous:
|
||||
offset - limit >= 0
|
||||
? new URL(
|
||||
`/notes/${note.id}/quotes?offset=${
|
||||
offset - limit
|
||||
}`,
|
||||
config.http.base_url,
|
||||
).href
|
||||
: null,
|
||||
total: quoteCount,
|
||||
items: quotes.map((reply) => reply.getUri().href),
|
||||
});
|
||||
|
||||
// If base_url uses https and request uses http, rewrite request to use https
|
||||
// This fixes reverse proxy errors
|
||||
const reqUrl = new URL(context.req.url);
|
||||
if (
|
||||
config.http.base_url.protocol === "https:" &&
|
||||
reqUrl.protocol === "http:"
|
||||
) {
|
||||
reqUrl.protocol = "https:";
|
||||
}
|
||||
|
||||
const { headers } = await note.author.sign(
|
||||
uriCollection,
|
||||
reqUrl,
|
||||
"GET",
|
||||
);
|
||||
|
||||
return context.json(uriCollection, 200, headers.toJSON());
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
import { Status as StatusSchema } from "@versia/client/schemas";
|
||||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import { URICollectionSchema } from "@versia/sdk/schemas";
|
||||
import { config } from "@versia-server/config";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import { apiRoute, handleZodError } from "@versia-server/kit/api";
|
||||
import { db, Note } from "@versia-server/kit/db";
|
||||
import { Notes } from "@versia-server/kit/tables";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||
import { z } from "zod";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/notes/:id/replies",
|
||||
describeRoute({
|
||||
summary: "Retrieve all replies to a Versia Note.",
|
||||
tags: ["Federation"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Note replies",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(URICollectionSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description:
|
||||
"Entity not found, is remote, or the requester is not allowed to view it.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ApiError.zodSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({ id: StatusSchema.shape.id }),
|
||||
handleZodError,
|
||||
),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(40),
|
||||
offset: z.coerce.number().int().nonnegative().default(0),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
const { limit, offset } = context.req.valid("query");
|
||||
|
||||
const note = await Note.fromSql(
|
||||
and(
|
||||
eq(Notes.id, id),
|
||||
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||
),
|
||||
);
|
||||
|
||||
if (!(note && (await note.isViewableByUser(null))) || note.remote) {
|
||||
throw ApiError.noteNotFound();
|
||||
}
|
||||
|
||||
const replies = await Note.manyFromSql(
|
||||
and(
|
||||
eq(Notes.replyId, note.id),
|
||||
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||
),
|
||||
undefined,
|
||||
limit,
|
||||
offset,
|
||||
);
|
||||
|
||||
const replyCount = await db.$count(
|
||||
Notes,
|
||||
and(
|
||||
eq(Notes.replyId, note.id),
|
||||
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||
),
|
||||
);
|
||||
|
||||
const uriCollection = new VersiaEntities.URICollection({
|
||||
author: note.author.uri.href,
|
||||
first: new URL(
|
||||
`/notes/${note.id}/replies?offset=0`,
|
||||
config.http.base_url,
|
||||
).href,
|
||||
last:
|
||||
replyCount > limit
|
||||
? new URL(
|
||||
`/notes/${note.id}/replies?offset=${
|
||||
replyCount - limit
|
||||
}`,
|
||||
config.http.base_url,
|
||||
).href
|
||||
: new URL(
|
||||
`/notes/${note.id}/replies`,
|
||||
config.http.base_url,
|
||||
).href,
|
||||
next:
|
||||
offset + limit < replyCount
|
||||
? new URL(
|
||||
`/notes/${note.id}/replies?offset=${
|
||||
offset + limit
|
||||
}`,
|
||||
config.http.base_url,
|
||||
).href
|
||||
: null,
|
||||
previous:
|
||||
offset - limit >= 0
|
||||
? new URL(
|
||||
`/notes/${note.id}/replies?offset=${
|
||||
offset - limit
|
||||
}`,
|
||||
config.http.base_url,
|
||||
).href
|
||||
: null,
|
||||
total: replyCount,
|
||||
items: replies.map((reply) => reply.getUri().href),
|
||||
});
|
||||
|
||||
// If base_url uses https and request uses http, rewrite request to use https
|
||||
// This fixes reverse proxy errors
|
||||
const reqUrl = new URL(context.req.url);
|
||||
if (
|
||||
config.http.base_url.protocol === "https:" &&
|
||||
reqUrl.protocol === "http:"
|
||||
) {
|
||||
reqUrl.protocol = "https:";
|
||||
}
|
||||
|
||||
const { headers } = await note.author.sign(
|
||||
uriCollection,
|
||||
reqUrl,
|
||||
"GET",
|
||||
);
|
||||
|
||||
return context.json(uriCollection, 200, headers.toJSON());
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
import { Status as StatusSchema } from "@versia/client/schemas";
|
||||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import { URICollectionSchema } from "@versia/sdk/schemas";
|
||||
import { config } from "@versia-server/config";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import { apiRoute, handleZodError } from "@versia-server/kit/api";
|
||||
import { db, Note } from "@versia-server/kit/db";
|
||||
import { Notes } from "@versia-server/kit/tables";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||
import { z } from "zod";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/notes/:id/shares",
|
||||
describeRoute({
|
||||
summary: "Retrieve all shares of a Versia Note.",
|
||||
tags: ["Federation"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Note shares",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(URICollectionSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description:
|
||||
"Entity not found, is remote, or the requester is not allowed to view it.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ApiError.zodSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: StatusSchema.shape.id,
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(40),
|
||||
offset: z.coerce.number().int().nonnegative().default(0),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
const { limit, offset } = context.req.valid("query");
|
||||
|
||||
const note = await Note.fromSql(
|
||||
and(
|
||||
eq(Notes.id, id),
|
||||
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||
),
|
||||
);
|
||||
|
||||
if (!(note && (await note.isViewableByUser(null))) || note.remote) {
|
||||
throw ApiError.noteNotFound();
|
||||
}
|
||||
|
||||
const shares = await Note.manyFromSql(
|
||||
and(
|
||||
eq(Notes.reblogId, note.id),
|
||||
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||
),
|
||||
undefined,
|
||||
limit,
|
||||
offset,
|
||||
);
|
||||
|
||||
const shareCount = await db.$count(
|
||||
Notes,
|
||||
and(
|
||||
eq(Notes.reblogId, note.id),
|
||||
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||
),
|
||||
);
|
||||
|
||||
const uriCollection = new VersiaEntities.URICollection({
|
||||
author: note.author.uri.href,
|
||||
first: new URL(
|
||||
`/notes/${note.id}/shares?offset=0`,
|
||||
config.http.base_url,
|
||||
).href,
|
||||
last:
|
||||
shareCount > limit
|
||||
? new URL(
|
||||
`/notes/${note.id}/shares?offset=${
|
||||
shareCount - limit
|
||||
}`,
|
||||
config.http.base_url,
|
||||
).href
|
||||
: new URL(
|
||||
`/notes/${note.id}/shares`,
|
||||
config.http.base_url,
|
||||
).href,
|
||||
next:
|
||||
offset + limit < shareCount
|
||||
? new URL(
|
||||
`/notes/${note.id}/shares?offset=${
|
||||
offset + limit
|
||||
}`,
|
||||
config.http.base_url,
|
||||
).href
|
||||
: null,
|
||||
previous:
|
||||
offset - limit >= 0
|
||||
? new URL(
|
||||
`/notes/${note.id}/shares?offset=${
|
||||
offset - limit
|
||||
}`,
|
||||
config.http.base_url,
|
||||
).href
|
||||
: null,
|
||||
total: shareCount,
|
||||
items: shares.map(
|
||||
(share) =>
|
||||
new URL(`/shares/${share.id}`, config.http.base_url)
|
||||
.href,
|
||||
),
|
||||
});
|
||||
|
||||
// If base_url uses https and request uses http, rewrite request to use https
|
||||
// This fixes reverse proxy errors
|
||||
const reqUrl = new URL(context.req.url);
|
||||
if (
|
||||
config.http.base_url.protocol === "https:" &&
|
||||
reqUrl.protocol === "http:"
|
||||
) {
|
||||
reqUrl.protocol = "https:";
|
||||
}
|
||||
|
||||
const { headers } = await note.author.sign(
|
||||
uriCollection,
|
||||
reqUrl,
|
||||
"GET",
|
||||
);
|
||||
|
||||
return context.json(uriCollection, 200, headers.toJSON());
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import { Status as StatusSchema } from "@versia/client/schemas";
|
||||
import { ShareSchema } from "@versia/sdk/schemas";
|
||||
import { config } from "@versia-server/config";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import { apiRoute, handleZodError } from "@versia-server/kit/api";
|
||||
import { Note } from "@versia-server/kit/db";
|
||||
import { Notes } from "@versia-server/kit/tables";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||
import { z } from "zod";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/shares/:id",
|
||||
describeRoute({
|
||||
summary: "Retrieve the Versia representation of a share.",
|
||||
tags: ["Federation"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Share",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ShareSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description:
|
||||
"Entity not found, is remote, or the requester is not allowed to view it.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ApiError.zodSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: StatusSchema.shape.id,
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
|
||||
const note = await Note.fromSql(
|
||||
and(
|
||||
eq(Notes.id, id),
|
||||
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||
),
|
||||
);
|
||||
|
||||
if (!(note && (await note.isViewableByUser(null))) || note.remote) {
|
||||
throw ApiError.noteNotFound();
|
||||
}
|
||||
|
||||
// If base_url uses https and request uses http, rewrite request to use https
|
||||
// This fixes reverse proxy errors
|
||||
const reqUrl = new URL(context.req.url);
|
||||
if (
|
||||
config.http.base_url.protocol === "https:" &&
|
||||
reqUrl.protocol === "http:"
|
||||
) {
|
||||
reqUrl.protocol = "https:";
|
||||
}
|
||||
|
||||
const { headers } = await note.author.sign(
|
||||
note.toVersiaShare(),
|
||||
reqUrl,
|
||||
"GET",
|
||||
);
|
||||
|
||||
return context.json(note.toVersiaShare(), 200, headers.toJSON());
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
import { ApiError } from "@versia-server/kit";
|
||||
import { apiRoute, handleZodError } from "@versia-server/kit/api";
|
||||
import { InboxJobType, inboxQueue } from "@versia-server/kit/queues/inbox";
|
||||
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||
import { z } from "zod";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
"/users/:uuid/inbox",
|
||||
describeRoute({
|
||||
summary: "Receive federation inbox",
|
||||
tags: ["Federation"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Request processed",
|
||||
},
|
||||
201: {
|
||||
description: "Request accepted",
|
||||
},
|
||||
400: {
|
||||
description: "Bad request",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ApiError.zodSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "Signature could not be verified",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ApiError.zodSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description: "Cannot view users from remote instances",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ApiError.zodSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ApiError.zodSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
500: {
|
||||
description: "Internal server error",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
error: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
uuid: z.uuid(),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
validator(
|
||||
"header",
|
||||
z.object({
|
||||
"versia-signature": z.string().optional(),
|
||||
"versia-signed-at": z.coerce.number().optional(),
|
||||
"versia-signed-by": z
|
||||
.url()
|
||||
.or(z.string().startsWith("instance "))
|
||||
.optional(),
|
||||
authorization: z.string().optional(),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const body = await context.req.json();
|
||||
const {
|
||||
"versia-signature": signature,
|
||||
"versia-signed-at": signedAt,
|
||||
"versia-signed-by": signedBy,
|
||||
authorization,
|
||||
} = context.req.valid("header");
|
||||
|
||||
await inboxQueue.add(InboxJobType.ProcessEntity, {
|
||||
data: body,
|
||||
headers: {
|
||||
"versia-signature": signature,
|
||||
"versia-signed-at": signedAt,
|
||||
"versia-signed-by": signedBy,
|
||||
authorization,
|
||||
},
|
||||
request: {
|
||||
body: await context.req.text(),
|
||||
method: context.req.method,
|
||||
url: context.req.url,
|
||||
},
|
||||
ip: context.env.ip ?? null,
|
||||
});
|
||||
|
||||
return context.body(
|
||||
"Request processing initiated.\nImplement the Instance Messaging Extension to receive any eventual feedback (errors, etc.)",
|
||||
200,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import { UserSchema } from "@versia/sdk/schemas";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import { apiRoute, handleZodError } from "@versia-server/kit/api";
|
||||
import { User } from "@versia-server/kit/db";
|
||||
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||
import { z } from "zod";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/users/:uuid",
|
||||
describeRoute({
|
||||
summary: "Get user data",
|
||||
tags: ["Federation"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "User data",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(UserSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
301: {
|
||||
description:
|
||||
"Redirect to user profile (for web browsers). Uses user-agent for detection.",
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
403: {
|
||||
description: "Cannot view users from remote instances",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ApiError.zodSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
uuid: z.uuid(),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
// @ts-expect-error idk why this is happening and I don't care
|
||||
async (context) => {
|
||||
const { uuid } = context.req.valid("param");
|
||||
|
||||
const user = await User.fromId(uuid);
|
||||
|
||||
if (!user) {
|
||||
throw ApiError.accountNotFound();
|
||||
}
|
||||
|
||||
if (user.remote) {
|
||||
throw new ApiError(403, "User is not on this instance");
|
||||
}
|
||||
|
||||
// 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,
|
||||
new URL(context.req.url),
|
||||
"GET",
|
||||
);
|
||||
|
||||
return context.json(userJson, 200, headers.toJSON());
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import { CollectionSchema, NoteSchema } from "@versia/sdk/schemas";
|
||||
import { config } from "@versia-server/config";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import { apiRoute, handleZodError } from "@versia-server/kit/api";
|
||||
import { db, Note, User } from "@versia-server/kit/db";
|
||||
import { Notes } from "@versia-server/kit/tables";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||
import { z } from "zod";
|
||||
|
||||
const NOTES_PER_PAGE = 20;
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/users/:uuid/outbox",
|
||||
describeRoute({
|
||||
summary: "Get user outbox",
|
||||
tags: ["Federation"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "User outbox",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
CollectionSchema.extend({
|
||||
items: z.array(NoteSchema),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "User not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ApiError.zodSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description: "Cannot view users from remote instances",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ApiError.zodSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
uuid: z.uuid(),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
page: z.string().optional(),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { uuid } = context.req.valid("param");
|
||||
|
||||
const author = await User.fromId(uuid);
|
||||
|
||||
if (!author) {
|
||||
throw new ApiError(404, "User not found");
|
||||
}
|
||||
|
||||
if (author.remote) {
|
||||
throw new ApiError(403, "User is not on this instance");
|
||||
}
|
||||
|
||||
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),
|
||||
);
|
||||
|
||||
const totalNotes = await db.$count(
|
||||
Notes,
|
||||
and(
|
||||
eq(Notes.authorId, uuid),
|
||||
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||
),
|
||||
);
|
||||
|
||||
const json = new VersiaEntities.Collection({
|
||||
first: new URL(
|
||||
`/users/${uuid}/outbox?page=1`,
|
||||
config.http.base_url,
|
||||
).href,
|
||||
last: new URL(
|
||||
`/users/${uuid}/outbox?page=${Math.ceil(
|
||||
totalNotes / NOTES_PER_PAGE,
|
||||
)}`,
|
||||
config.http.base_url,
|
||||
).href,
|
||||
total: totalNotes,
|
||||
author: author.uri.href,
|
||||
next:
|
||||
notes.length === NOTES_PER_PAGE
|
||||
? new URL(
|
||||
`/users/${uuid}/outbox?page=${pageNumber + 1}`,
|
||||
config.http.base_url,
|
||||
).href
|
||||
: null,
|
||||
previous:
|
||||
pageNumber > 1
|
||||
? new URL(
|
||||
`/users/${uuid}/outbox?page=${pageNumber - 1}`,
|
||||
config.http.base_url,
|
||||
).href
|
||||
: null,
|
||||
items: notes.map((note) => note.toVersia()),
|
||||
});
|
||||
|
||||
const { headers } = await author.sign(
|
||||
json,
|
||||
new URL(context.req.url),
|
||||
"GET",
|
||||
);
|
||||
|
||||
return context.json(json, 200, headers.toJSON());
|
||||
},
|
||||
),
|
||||
);
|
||||
324
packages/api/routes/versia/v0.6/[entity_type]/[id]/collections/[collection_type].ts
vendored
Normal file
324
packages/api/routes/versia/v0.6/[entity_type]/[id]/collections/[collection_type].ts
vendored
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import {
|
||||
CollectionSchema,
|
||||
EntitySchema,
|
||||
URICollectionSchema,
|
||||
} from "@versia/sdk/schemas";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import { apiRoute, handleZodError } from "@versia-server/kit/api";
|
||||
import { db, Instance, Note, User } from "@versia-server/kit/db";
|
||||
import { Notes, Users } from "@versia-server/kit/tables";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||
import { z } from "zod";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/.versia/v0.6/entities/:entity_type/:id/collections/:collection_type",
|
||||
describeRoute({
|
||||
summary:
|
||||
"Retrieve the Versia representation of a collection attached to an entity.",
|
||||
tags: ["Federation"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Collection",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.union([
|
||||
CollectionSchema,
|
||||
URICollectionSchema,
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Collection not found.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ApiError.zodSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
entity_type: EntitySchema.shape.type,
|
||||
id: EntitySchema.shape.id,
|
||||
collection_type: z.string(),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
limit: z.coerce.number().int().min(1).max(40).default(40),
|
||||
offset: z.coerce.number().int().nonnegative().default(0),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { entity_type, id, collection_type } =
|
||||
context.req.valid("param");
|
||||
const { limit, offset } = context.req.valid("query");
|
||||
|
||||
let entity:
|
||||
| VersiaEntities.Collection
|
||||
| VersiaEntities.URICollection
|
||||
| null = null;
|
||||
|
||||
switch (entity_type) {
|
||||
case "Note": {
|
||||
const note = await Note.fromSql(
|
||||
and(
|
||||
eq(Notes.id, id),
|
||||
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||
),
|
||||
);
|
||||
|
||||
if (
|
||||
!(note && (await note.isViewableByUser(null))) ||
|
||||
note.remote
|
||||
) {
|
||||
throw ApiError.noteNotFound();
|
||||
}
|
||||
|
||||
switch (collection_type) {
|
||||
case "replies": {
|
||||
const replies = await Note.manyFromSql(
|
||||
and(
|
||||
eq(Notes.replyId, note.id),
|
||||
inArray(Notes.visibility, [
|
||||
"public",
|
||||
"unlisted",
|
||||
]),
|
||||
),
|
||||
undefined,
|
||||
limit,
|
||||
offset,
|
||||
);
|
||||
|
||||
const replyCount = await db.$count(
|
||||
Notes,
|
||||
and(
|
||||
eq(Notes.replyId, note.id),
|
||||
inArray(Notes.visibility, [
|
||||
"public",
|
||||
"unlisted",
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
entity = new VersiaEntities.URICollection({
|
||||
author: note.author.id,
|
||||
total: replyCount,
|
||||
items: replies.map((reply) =>
|
||||
reply.reference.toString(),
|
||||
),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "quotes": {
|
||||
const quotes = await Note.manyFromSql(
|
||||
and(
|
||||
eq(Notes.quotingId, note.id),
|
||||
inArray(Notes.visibility, [
|
||||
"public",
|
||||
"unlisted",
|
||||
]),
|
||||
),
|
||||
undefined,
|
||||
limit,
|
||||
offset,
|
||||
);
|
||||
|
||||
const quoteCount = await db.$count(
|
||||
Notes,
|
||||
and(
|
||||
eq(Notes.quotingId, note.id),
|
||||
inArray(Notes.visibility, [
|
||||
"public",
|
||||
"unlisted",
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
entity = new VersiaEntities.URICollection({
|
||||
author: note.author.id,
|
||||
total: quoteCount,
|
||||
items: quotes.map((quote) =>
|
||||
quote.reference.toString(),
|
||||
),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "pub.versia:share/Shares": {
|
||||
const shares = await Note.manyFromSql(
|
||||
and(
|
||||
eq(Notes.reblogId, note.id),
|
||||
inArray(Notes.visibility, [
|
||||
"public",
|
||||
"unlisted",
|
||||
]),
|
||||
),
|
||||
undefined,
|
||||
limit,
|
||||
offset,
|
||||
);
|
||||
|
||||
const shareCount = await db.$count(
|
||||
Notes,
|
||||
and(
|
||||
eq(Notes.reblogId, note.id),
|
||||
inArray(Notes.visibility, [
|
||||
"public",
|
||||
"unlisted",
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
entity = new VersiaEntities.URICollection({
|
||||
author: note.author.id,
|
||||
total: shareCount,
|
||||
items: shares.map((share) =>
|
||||
share.reference.toString(),
|
||||
),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "User": {
|
||||
const user = await User.fromId(id);
|
||||
|
||||
if (!user || user.remote) {
|
||||
throw ApiError.notFound();
|
||||
}
|
||||
|
||||
switch (collection_type) {
|
||||
case "outbox": {
|
||||
const total = await db.$count(
|
||||
Notes,
|
||||
and(
|
||||
eq(Notes.authorId, id),
|
||||
inArray(Notes.visibility, [
|
||||
"public",
|
||||
"unlisted",
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const outboxItems = await Note.manyFromSql(
|
||||
and(
|
||||
eq(Notes.authorId, id),
|
||||
inArray(Notes.visibility, [
|
||||
"public",
|
||||
"unlisted",
|
||||
]),
|
||||
),
|
||||
undefined,
|
||||
limit,
|
||||
offset,
|
||||
);
|
||||
|
||||
entity = new VersiaEntities.Collection({
|
||||
author: user.id,
|
||||
total,
|
||||
items: outboxItems.map((note) =>
|
||||
note.toVersia(),
|
||||
),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "followers": {
|
||||
if (user.data.isHidingCollections) {
|
||||
entity = new VersiaEntities.URICollection({
|
||||
author: user.id,
|
||||
items: [],
|
||||
total: 0,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
const total = await db.$count(
|
||||
Users,
|
||||
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${user.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."following" = true)`,
|
||||
);
|
||||
|
||||
const followers = await User.manyFromSql(
|
||||
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${user.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."following" = true)`,
|
||||
undefined,
|
||||
limit,
|
||||
offset,
|
||||
);
|
||||
|
||||
entity = new VersiaEntities.URICollection({
|
||||
author: user.id,
|
||||
items: followers.map((follower) =>
|
||||
follower.reference.toString(),
|
||||
),
|
||||
total,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "following": {
|
||||
if (user.data.isHidingCollections) {
|
||||
entity = new VersiaEntities.URICollection({
|
||||
author: user.id,
|
||||
items: [],
|
||||
total: 0,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
const total = await db.$count(
|
||||
Users,
|
||||
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."ownerId" = ${user.id} AND "Relationships"."subjectId" = ${Users.id} AND "Relationships"."following" = true)`,
|
||||
);
|
||||
|
||||
const following = await User.manyFromSql(
|
||||
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."ownerId" = ${user.id} AND "Relationships"."subjectId" = ${Users.id} AND "Relationships"."following" = true)`,
|
||||
undefined,
|
||||
limit,
|
||||
offset,
|
||||
);
|
||||
|
||||
entity = new VersiaEntities.URICollection({
|
||||
author: user.id,
|
||||
items: following.map((followed) =>
|
||||
followed.reference.toString(),
|
||||
),
|
||||
total,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
throw ApiError.notFound();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!entity) {
|
||||
throw ApiError.notFound();
|
||||
}
|
||||
|
||||
const { headers } = await Instance.sign(
|
||||
entity,
|
||||
new URL(context.req.url),
|
||||
"GET",
|
||||
);
|
||||
|
||||
return context.json(entity, 200, headers.toJSON());
|
||||
},
|
||||
),
|
||||
);
|
||||
178
packages/api/routes/versia/v0.6/[entity_type]/[id]/index.ts
vendored
Normal file
178
packages/api/routes/versia/v0.6/[entity_type]/[id]/index.ts
vendored
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import type * as VersiaEntities from "@versia/sdk/entities";
|
||||
import {
|
||||
DislikeSchema,
|
||||
EntitySchema,
|
||||
LikeSchema,
|
||||
NoteSchema,
|
||||
ReactionSchema,
|
||||
ShareSchema,
|
||||
UserSchema,
|
||||
} from "@versia/sdk/schemas";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import { apiRoute, handleZodError } from "@versia-server/kit/api";
|
||||
import { Instance, Like, Note, Reaction, User } from "@versia-server/kit/db";
|
||||
import { Likes, Notes } from "@versia-server/kit/tables";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||
import { z } from "zod";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/.versia/v0.6/entities/:entity_type/:id",
|
||||
describeRoute({
|
||||
summary: "Retrieve the Versia representation of an entity.",
|
||||
tags: ["Federation"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Entity",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.union([
|
||||
NoteSchema,
|
||||
UserSchema,
|
||||
LikeSchema,
|
||||
DislikeSchema,
|
||||
ReactionSchema,
|
||||
ShareSchema,
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
301: {
|
||||
description:
|
||||
"Redirect to user profile (for web browsers). Uses Accept header for detection.",
|
||||
},
|
||||
404: {
|
||||
description:
|
||||
"Entity not found, is remote, or the requester is not allowed to view it.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ApiError.zodSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
entity_type: EntitySchema.shape.type,
|
||||
id: EntitySchema.shape.id,
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { entity_type, id } = context.req.valid("param");
|
||||
|
||||
let entity:
|
||||
| VersiaEntities.Note
|
||||
| VersiaEntities.User
|
||||
| VersiaEntities.Like
|
||||
| VersiaEntities.Dislike
|
||||
| VersiaEntities.Reaction
|
||||
| VersiaEntities.Share
|
||||
| null = null;
|
||||
|
||||
switch (entity_type) {
|
||||
case "pub.versia:notes/Note": {
|
||||
const note = await Note.fromSql(
|
||||
and(
|
||||
eq(Notes.id, id),
|
||||
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||
),
|
||||
);
|
||||
|
||||
if (
|
||||
!(note && (await note.isViewableByUser(null))) ||
|
||||
note.remote
|
||||
) {
|
||||
throw ApiError.noteNotFound();
|
||||
}
|
||||
|
||||
entity = note.toVersia();
|
||||
break;
|
||||
}
|
||||
case "pub.versia:users/User": {
|
||||
const user = await User.fromId(id);
|
||||
|
||||
if (!user || user.remote) {
|
||||
throw ApiError.accountNotFound();
|
||||
}
|
||||
|
||||
entity = user.toVersia();
|
||||
break;
|
||||
}
|
||||
case "pub.versia:likes/Like": {
|
||||
// Don't fetch a like of a note that is not public or unlisted
|
||||
// prevents leaking the existence of a private note
|
||||
const like = await Like.fromSql(
|
||||
and(
|
||||
eq(Likes.id, id),
|
||||
sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."id" = ${Likes.likedId} AND "Notes"."visibility" IN ('public', 'unlisted'))`,
|
||||
),
|
||||
);
|
||||
|
||||
if (!like) {
|
||||
throw ApiError.likeNotFound();
|
||||
}
|
||||
|
||||
const liker = await User.fromId(like.data.likerId);
|
||||
|
||||
if (!liker || liker.remote) {
|
||||
throw ApiError.accountNotFound();
|
||||
}
|
||||
|
||||
entity = like.toVersia();
|
||||
break;
|
||||
}
|
||||
case "pub.versia:likes/Dislike": {
|
||||
// Versia Server does not support dislikes
|
||||
throw ApiError.notFound();
|
||||
}
|
||||
case "pub.versia:shares/Share": {
|
||||
const note = await Note.fromSql(
|
||||
and(
|
||||
eq(Notes.id, id),
|
||||
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||
),
|
||||
);
|
||||
|
||||
if (
|
||||
!(note && (await note.isViewableByUser(null))) ||
|
||||
note.remote ||
|
||||
!note.data.reblogId
|
||||
) {
|
||||
throw ApiError.notFound();
|
||||
}
|
||||
|
||||
entity = note.toVersiaShare();
|
||||
break;
|
||||
}
|
||||
case "pub.versia:reactions/Reaction": {
|
||||
const reaction = await Reaction.fromId(id);
|
||||
|
||||
if (!reaction) {
|
||||
throw ApiError.notFound();
|
||||
}
|
||||
|
||||
entity = reaction.toVersia();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!entity) {
|
||||
throw ApiError.notFound();
|
||||
}
|
||||
|
||||
const { headers } = await Instance.sign(
|
||||
entity,
|
||||
new URL(context.req.url),
|
||||
"GET",
|
||||
);
|
||||
|
||||
return context.json(entity, 200, headers.toJSON());
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -24,16 +24,13 @@ const userId = randomUUIDv7();
|
|||
const shareId = randomUUIDv7();
|
||||
const reactionId = randomUUIDv7();
|
||||
const reaction2Id = randomUUIDv7();
|
||||
const userKeys = await User.generateKeys();
|
||||
const privateKey = await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
Buffer.from(userKeys.private_key, "base64"),
|
||||
"Ed25519",
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const instanceKeys = await User.generateKeys();
|
||||
const inboxUrl = new URL("/inbox", config.http.base_url);
|
||||
|
||||
const instanceKeys = await crypto.subtle.generateKey("Ed25519", true, [
|
||||
"sign",
|
||||
"verify",
|
||||
]);
|
||||
|
||||
const inboxUrl = new URL("/.versia/v0.6/inbox", config.http.base_url);
|
||||
const { users, deleteUsers } = await getTestUsers(1);
|
||||
|
||||
disableRealRequests();
|
||||
|
|
@ -48,24 +45,29 @@ mock(new URL("/.well-known/versia", instanceUrl).href, {
|
|||
name: "Versia",
|
||||
description: "Versia instance",
|
||||
created_at: new Date().toISOString(),
|
||||
host: instanceUrl.hostname,
|
||||
domain: instanceUrl.hostname,
|
||||
software: {
|
||||
name: "Versia",
|
||||
version: "1.0.0",
|
||||
},
|
||||
compatibility: {
|
||||
extensions: [],
|
||||
versions: ["0.5.0"],
|
||||
versions: ["0.6.0"],
|
||||
},
|
||||
public_key: {
|
||||
algorithm: "ed25519",
|
||||
key: instanceKeys.public_key,
|
||||
key: Buffer.from(
|
||||
await crypto.subtle.exportKey(
|
||||
"spki",
|
||||
instanceKeys.publicKey,
|
||||
),
|
||||
).toString("base64"),
|
||||
},
|
||||
}).toJSON(),
|
||||
},
|
||||
});
|
||||
|
||||
mock(new URL(`/users/${userId}`, instanceUrl).href, {
|
||||
mock(new URL(`/.versia/v0.6/entities/User/${userId}`, instanceUrl).href, {
|
||||
response: {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
|
@ -73,31 +75,18 @@ mock(new URL(`/users/${userId}`, instanceUrl).href, {
|
|||
data: new VersiaEntities.User({
|
||||
id: userId,
|
||||
created_at: "2025-04-18T10:32:01.427Z",
|
||||
uri: new URL(`/users/${userId}`, instanceUrl).href,
|
||||
type: "User",
|
||||
username: "testuser",
|
||||
public_key: {
|
||||
algorithm: "ed25519",
|
||||
key: userKeys.public_key,
|
||||
actor: new URL(`/users/${userId}`, instanceUrl).href,
|
||||
},
|
||||
inbox: new URL(`/users/${userId}/inbox`, instanceUrl).href,
|
||||
collections: {
|
||||
featured: new URL(`/users/${userId}/featured`, instanceUrl)
|
||||
.href,
|
||||
followers: new URL(`/users/${userId}/followers`, instanceUrl)
|
||||
.href,
|
||||
following: new URL(`/users/${userId}/following`, instanceUrl)
|
||||
.href,
|
||||
outbox: new URL(`/users/${userId}/outbox`, instanceUrl).href,
|
||||
},
|
||||
fields: [],
|
||||
manually_approves_followers: false,
|
||||
indexable: true,
|
||||
}).toJSON(),
|
||||
},
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Delete the instance in database
|
||||
const instance = await Instance.resolve(instanceUrl);
|
||||
const instance = await Instance.resolve(instanceUrl.hostname);
|
||||
|
||||
if (!instance) {
|
||||
throw new Error("Instance not found");
|
||||
|
|
@ -111,18 +100,18 @@ afterAll(async () => {
|
|||
|
||||
describe("Inbox Tests", () => {
|
||||
test("should correctly process inbox request", async () => {
|
||||
const exampleRequest = new VersiaEntities.Note({
|
||||
const exampleNote = new VersiaEntities.Note({
|
||||
id: noteId,
|
||||
created_at: "2025-04-18T10:32:01.427Z",
|
||||
uri: new URL(`/notes/${noteId}`, instanceUrl).href,
|
||||
type: "Note",
|
||||
extensions: {
|
||||
"pub.versia:custom_emojis": {
|
||||
emojis: [],
|
||||
},
|
||||
},
|
||||
previews: [],
|
||||
attachments: [],
|
||||
author: new URL(`/users/${userId}`, instanceUrl).href,
|
||||
author: userId,
|
||||
content: {
|
||||
"text/html": {
|
||||
content: "<p>Hello!</p>",
|
||||
|
|
@ -133,10 +122,6 @@ describe("Inbox Tests", () => {
|
|||
remote: false,
|
||||
},
|
||||
},
|
||||
collections: {
|
||||
replies: new URL(`/notes/${noteId}/replies`, instanceUrl).href,
|
||||
quotes: new URL(`/notes/${noteId}/quotes`, instanceUrl).href,
|
||||
},
|
||||
group: "public",
|
||||
is_sensitive: false,
|
||||
mentions: [],
|
||||
|
|
@ -146,16 +131,17 @@ describe("Inbox Tests", () => {
|
|||
});
|
||||
|
||||
const signedRequest = await sign(
|
||||
privateKey,
|
||||
new URL(exampleRequest.data.author),
|
||||
instanceKeys.privateKey,
|
||||
new URL(exampleNote.data.author),
|
||||
new Request(inboxUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"Content-Type":
|
||||
"application/vnd.versia+json; charset=utf-8",
|
||||
Accept: "application/vnd.versia+json",
|
||||
"User-Agent": "Versia/1.0.0",
|
||||
},
|
||||
body: JSON.stringify(exampleRequest.toJSON()),
|
||||
body: JSON.stringify(exampleNote.toJSON()),
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -170,7 +156,9 @@ describe("Inbox Tests", () => {
|
|||
await sleep(500);
|
||||
|
||||
// Check if note was created in the database
|
||||
const note = await Note.fromSql(eq(Notes.uri, exampleRequest.data.uri));
|
||||
const note = await Note.fromSql(
|
||||
eq(Notes.remoteId, exampleNote.data.id),
|
||||
);
|
||||
|
||||
expect(note).not.toBeNull();
|
||||
});
|
||||
|
|
@ -179,20 +167,20 @@ describe("Inbox Tests", () => {
|
|||
const exampleRequest = new VersiaEntities.Share({
|
||||
id: shareId,
|
||||
created_at: "2025-04-18T10:32:01.427Z",
|
||||
uri: new URL(`/shares/${shareId}`, instanceUrl).href,
|
||||
type: "pub.versia:share/Share",
|
||||
author: new URL(`/users/${userId}`, instanceUrl).href,
|
||||
shared: new URL(`/notes/${noteId}`, instanceUrl).href,
|
||||
author: userId,
|
||||
shared: noteId,
|
||||
});
|
||||
|
||||
const signedRequest = await sign(
|
||||
privateKey,
|
||||
instanceKeys.privateKey,
|
||||
new URL(exampleRequest.data.author),
|
||||
new Request(inboxUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"Content-Type":
|
||||
"application/vnd.versia+json; charset=utf-8",
|
||||
Accept: "application/vnd.versia+json",
|
||||
"User-Agent": "Versia/1.0.0",
|
||||
},
|
||||
body: JSON.stringify(exampleRequest.toJSON()),
|
||||
|
|
@ -209,9 +197,7 @@ describe("Inbox Tests", () => {
|
|||
|
||||
await sleep(500);
|
||||
|
||||
const dbNote = await Note.fromSql(
|
||||
eq(Notes.uri, new URL(`/notes/${noteId}`, instanceUrl).href),
|
||||
);
|
||||
const dbNote = await Note.fromSql(eq(Notes.remoteId, noteId));
|
||||
|
||||
if (!dbNote) {
|
||||
throw new Error("DBNote not found");
|
||||
|
|
@ -221,6 +207,7 @@ describe("Inbox Tests", () => {
|
|||
const share = await Note.fromSql(
|
||||
and(
|
||||
eq(Notes.reblogId, dbNote.id),
|
||||
eq(Notes.remoteId, shareId),
|
||||
eq(Notes.authorId, dbNote.data.authorId),
|
||||
),
|
||||
);
|
||||
|
|
@ -232,21 +219,21 @@ describe("Inbox Tests", () => {
|
|||
const exampleRequest = new VersiaEntities.Reaction({
|
||||
id: reactionId,
|
||||
created_at: "2025-04-18T10:32:01.427Z",
|
||||
uri: new URL(`/reactions/${reactionId}`, instanceUrl).href,
|
||||
type: "pub.versia:reactions/Reaction",
|
||||
author: new URL(`/users/${userId}`, instanceUrl).href,
|
||||
object: new URL(`/notes/${noteId}`, instanceUrl).href,
|
||||
author: userId,
|
||||
object: noteId,
|
||||
content: "👍",
|
||||
});
|
||||
|
||||
const signedRequest = await sign(
|
||||
privateKey,
|
||||
instanceKeys.privateKey,
|
||||
new URL(exampleRequest.data.author),
|
||||
new Request(inboxUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"Content-Type":
|
||||
"application/vnd.versia+json; charset=utf-8",
|
||||
Accept: "application/vnd.versia+json",
|
||||
"User-Agent": "Versia/1.0.0",
|
||||
},
|
||||
body: JSON.stringify(exampleRequest.toJSON()),
|
||||
|
|
@ -263,18 +250,14 @@ describe("Inbox Tests", () => {
|
|||
|
||||
await sleep(500);
|
||||
|
||||
const dbNote = await Note.fromSql(
|
||||
eq(Notes.uri, new URL(`/notes/${noteId}`, instanceUrl).href),
|
||||
);
|
||||
const dbNote = await Note.fromSql(eq(Notes.remoteId, noteId));
|
||||
|
||||
if (!dbNote) {
|
||||
throw new Error("DBNote not found");
|
||||
}
|
||||
|
||||
// Find the remote user who reacted by URI
|
||||
const remoteUser = await User.fromSql(
|
||||
eq(Users.uri, new URL(`/users/${userId}`, instanceUrl).href),
|
||||
);
|
||||
const remoteUser = await User.fromSql(eq(Users.remoteId, userId));
|
||||
|
||||
if (!remoteUser) {
|
||||
throw new Error("Remote user not found");
|
||||
|
|
@ -311,10 +294,9 @@ describe("Inbox Tests", () => {
|
|||
const exampleRequest = new VersiaEntities.Reaction({
|
||||
id: reaction2Id,
|
||||
created_at: "2025-04-18T10:32:01.427Z",
|
||||
uri: new URL(`/reactions/${reaction2Id}`, instanceUrl).href,
|
||||
type: "pub.versia:reactions/Reaction",
|
||||
author: new URL(`/users/${userId}`, instanceUrl).href,
|
||||
object: new URL(`/notes/${noteId}`, instanceUrl).href,
|
||||
author: userId,
|
||||
object: noteId,
|
||||
content: ":neocat:",
|
||||
extensions: {
|
||||
"pub.versia:custom_emojis": {
|
||||
|
|
@ -323,9 +305,7 @@ describe("Inbox Tests", () => {
|
|||
name: ":neocat:",
|
||||
url: {
|
||||
"image/webp": {
|
||||
hash: {
|
||||
sha256: "e06240155d2cb90e8dc05327d023585ab9d47216ff547ad72aaf75c485fe9649",
|
||||
},
|
||||
hash: "e06240155d2cb90e8dc05327d023585ab9d47216ff547ad72aaf75c485fe9649",
|
||||
size: 4664,
|
||||
width: 256,
|
||||
height: 256,
|
||||
|
|
@ -341,13 +321,14 @@ describe("Inbox Tests", () => {
|
|||
});
|
||||
|
||||
const signedRequest = await sign(
|
||||
privateKey,
|
||||
instanceKeys.privateKey,
|
||||
new URL(exampleRequest.data.author),
|
||||
new Request(inboxUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"Content-Type":
|
||||
"application/vnd.versia+json; charset=utf-8",
|
||||
Accept: "application/vnd.versia+json",
|
||||
"User-Agent": "Versia/1.0.0",
|
||||
},
|
||||
body: JSON.stringify(exampleRequest.toJSON()),
|
||||
|
|
@ -364,18 +345,14 @@ describe("Inbox Tests", () => {
|
|||
|
||||
await sleep(500);
|
||||
|
||||
const dbNote = await Note.fromSql(
|
||||
eq(Notes.uri, new URL(`/notes/${noteId}`, instanceUrl).href),
|
||||
);
|
||||
const dbNote = await Note.fromSql(eq(Notes.remoteId, noteId));
|
||||
|
||||
if (!dbNote) {
|
||||
throw new Error("DBNote not found");
|
||||
}
|
||||
|
||||
// Find the remote user who reacted by URI
|
||||
const remoteUser = await User.fromSql(
|
||||
eq(Users.uri, new URL(`/users/${userId}`, instanceUrl).href),
|
||||
);
|
||||
const remoteUser = await User.fromSql(eq(Users.remoteId, userId));
|
||||
|
||||
if (!remoteUser) {
|
||||
throw new Error("Remote user not found");
|
||||
|
|
@ -409,36 +386,29 @@ describe("Inbox Tests", () => {
|
|||
});
|
||||
|
||||
test("should correctly process Delete", async () => {
|
||||
const deleteId = randomUUIDv7();
|
||||
|
||||
// First check that the note exists in the database
|
||||
const noteToDelete = await Note.fromSql(
|
||||
eq(Notes.uri, new URL(`/notes/${noteId}`, instanceUrl).href),
|
||||
);
|
||||
const noteToDelete = await Note.fromSql(eq(Notes.remoteId, noteId));
|
||||
|
||||
expect(noteToDelete).not.toBeNull();
|
||||
|
||||
// Create a Delete request
|
||||
const exampleRequest = new VersiaEntities.Delete({
|
||||
id: deleteId,
|
||||
created_at: new Date().toISOString(),
|
||||
type: "Delete",
|
||||
author: new URL(`/users/${userId}`, instanceUrl).href,
|
||||
author: userId,
|
||||
deleted_type: "Note",
|
||||
deleted: new URL(`/notes/${noteId}`, instanceUrl).href,
|
||||
deleted: noteId,
|
||||
});
|
||||
|
||||
// The author field is non-null in our test case, so we can safely assert it as a string
|
||||
const authorUrl = exampleRequest.data.author as string;
|
||||
|
||||
const signedRequest = await sign(
|
||||
privateKey,
|
||||
new URL(authorUrl),
|
||||
instanceKeys.privateKey,
|
||||
new URL(exampleRequest.data.author),
|
||||
new Request(inboxUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"Content-Type":
|
||||
"application/vnd.versia+json; charset=utf-8",
|
||||
Accept: "application/vnd.versia+json",
|
||||
"User-Agent": "Versia/1.0.0",
|
||||
},
|
||||
body: JSON.stringify(exampleRequest.toJSON()),
|
||||
|
|
@ -456,9 +426,7 @@ describe("Inbox Tests", () => {
|
|||
await sleep(500);
|
||||
|
||||
// Verify that the note was deleted from the database
|
||||
const noteExists = await Note.fromSql(
|
||||
eq(Notes.uri, new URL(`/notes/${noteId}`, instanceUrl).href),
|
||||
);
|
||||
const noteExists = await Note.fromSql(eq(Notes.remoteId, noteId));
|
||||
|
||||
expect(noteExists).toBeNull();
|
||||
});
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
import { apiRoute, handleZodError } from "@versia-server/kit/api";
|
||||
import { InboxJobType, inboxQueue } from "@versia-server/kit/queues/inbox";
|
||||
import { describeRoute, validator } from "hono-openapi";
|
||||
import { z } from "zod";
|
||||
import z from "zod";
|
||||
import { InboxJobType, inboxQueue } from "~/packages/kit/queues/inbox/queue";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
"/inbox",
|
||||
app.get(
|
||||
"/.versia/v0.6/inbox",
|
||||
describeRoute({
|
||||
summary: "Instance federation inbox",
|
||||
summary: "Instance inbox endpoint",
|
||||
tags: ["Federation"],
|
||||
responses: {
|
||||
200: {
|
||||
|
|
@ -18,12 +18,9 @@ export default apiRoute((app) =>
|
|||
validator(
|
||||
"header",
|
||||
z.object({
|
||||
"versia-signature": z.string().optional(),
|
||||
"versia-signed-at": z.coerce.number().optional(),
|
||||
"versia-signed-by": z
|
||||
.url()
|
||||
.or(z.string().startsWith("instance "))
|
||||
.optional(),
|
||||
"versia-signature": z.string(),
|
||||
"versia-signed-at": z.coerce.number(),
|
||||
"versia-signed-by": z.string(),
|
||||
authorization: z.string().optional(),
|
||||
}),
|
||||
handleZodError,
|
||||
88
packages/api/routes/versia/v0.6/instance.ts
vendored
Normal file
88
packages/api/routes/versia/v0.6/instance.ts
vendored
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import {
|
||||
type ImageContentFormatSchema,
|
||||
InstanceMetadataSchema,
|
||||
} from "@versia/sdk/schemas";
|
||||
import { config } from "@versia-server/config";
|
||||
import { apiRoute } from "@versia-server/kit/api";
|
||||
import { User } from "@versia-server/kit/db";
|
||||
import { Users } from "@versia-server/kit/tables";
|
||||
import { asc } from "drizzle-orm";
|
||||
import { describeRoute, resolver } from "hono-openapi";
|
||||
import type z from "zod";
|
||||
import { urlToContentFormat } from "@/content_types";
|
||||
import pkg from "../../../../../package.json" with { type: "json" };
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/.versia/v0.6/instance",
|
||||
describeRoute({
|
||||
summary: "Get instance metadata",
|
||||
tags: ["Federation"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Instance metadata",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(InstanceMetadataSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (context) => {
|
||||
// Get date of first user creation
|
||||
const firstUser = await User.fromSql(
|
||||
undefined,
|
||||
asc(Users.createdAt),
|
||||
);
|
||||
|
||||
const publicKey = Buffer.from(
|
||||
await crypto.subtle.exportKey(
|
||||
"spki",
|
||||
config.instance.keys.public,
|
||||
),
|
||||
).toString("base64");
|
||||
|
||||
return context.json(
|
||||
{
|
||||
type: "InstanceMetadata" as const,
|
||||
compatibility: {
|
||||
extensions: [
|
||||
"pub.versia:custom_emojis",
|
||||
"pub.versia:instance_messaging",
|
||||
"pub.versia:likes",
|
||||
"pub.versia:shares",
|
||||
"pub.versia:reactions",
|
||||
],
|
||||
versions: ["0.6.0"],
|
||||
},
|
||||
domain: config.http.base_url.hostname,
|
||||
name: config.instance.name,
|
||||
description: config.instance.description,
|
||||
public_key: {
|
||||
key: publicKey,
|
||||
algorithm: "ed25519" as const,
|
||||
},
|
||||
software: {
|
||||
name: "Versia Server",
|
||||
version: pkg.version,
|
||||
},
|
||||
banner: config.instance.branding.banner
|
||||
? (urlToContentFormat(
|
||||
config.instance.branding.banner,
|
||||
) as z.infer<typeof ImageContentFormatSchema>)
|
||||
: undefined,
|
||||
logo: config.instance.branding.logo
|
||||
? (urlToContentFormat(
|
||||
config.instance.branding.logo,
|
||||
) as z.infer<typeof ImageContentFormatSchema>)
|
||||
: undefined,
|
||||
created_at:
|
||||
firstUser?.data.createdAt.toISOString() ||
|
||||
"1970-01-01T00:00:00Z",
|
||||
} satisfies z.infer<typeof InstanceMetadataSchema>,
|
||||
200,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -1,87 +1,32 @@
|
|||
import { InstanceMetadataSchema } from "@versia/sdk/schemas";
|
||||
import { config } from "@versia-server/config";
|
||||
import { apiRoute } from "@versia-server/kit/api";
|
||||
import { User } from "@versia-server/kit/db";
|
||||
import { Users } from "@versia-server/kit/tables";
|
||||
import { asc } from "drizzle-orm";
|
||||
import { describeRoute, resolver } from "hono-openapi";
|
||||
import { urlToContentFormat } from "@/content_types";
|
||||
import pkg from "../../../../package.json" with { type: "json" };
|
||||
import z from "zod";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/.well-known/versia",
|
||||
describeRoute({
|
||||
summary: "Get instance metadata",
|
||||
summary: "Get supported versia protocol versions",
|
||||
tags: ["Federation"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Instance metadata",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(InstanceMetadataSchema),
|
||||
schema: resolver(
|
||||
z.strictObject({
|
||||
versions: z.array(z.string().min(1)),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (context) => {
|
||||
// Get date of first user creation
|
||||
const firstUser = await User.fromSql(
|
||||
undefined,
|
||||
asc(Users.createdAt),
|
||||
);
|
||||
|
||||
const publicKey = Buffer.from(
|
||||
await crypto.subtle.exportKey(
|
||||
"spki",
|
||||
config.instance.keys.public,
|
||||
),
|
||||
).toString("base64");
|
||||
|
||||
(context) => {
|
||||
return context.json(
|
||||
{
|
||||
type: "InstanceMetadata" as const,
|
||||
compatibility: {
|
||||
extensions: [
|
||||
"pub.versia:custom_emojis",
|
||||
"pub.versia:instance_messaging",
|
||||
"pub.versia:likes",
|
||||
"pub.versia:shares",
|
||||
"pub.versia:reactions",
|
||||
],
|
||||
versions: ["0.5.0"],
|
||||
},
|
||||
host: config.http.base_url.host,
|
||||
name: config.instance.name,
|
||||
description: config.instance.description,
|
||||
public_key: {
|
||||
key: publicKey,
|
||||
algorithm: "ed25519" as const,
|
||||
},
|
||||
software: {
|
||||
name: "Versia Server",
|
||||
version: pkg.version,
|
||||
},
|
||||
banner: config.instance.branding.banner
|
||||
? urlToContentFormat(config.instance.branding.banner)
|
||||
: undefined,
|
||||
logo: config.instance.branding.logo
|
||||
? urlToContentFormat(config.instance.branding.logo)
|
||||
: undefined,
|
||||
shared_inbox: new URL(
|
||||
"/inbox",
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
created_at: firstUser?.data.createdAt.toISOString(),
|
||||
extensions: {
|
||||
"pub.versia:instance_messaging": {
|
||||
endpoint: new URL(
|
||||
"/messaging",
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
},
|
||||
},
|
||||
versions: ["0.6.0"],
|
||||
},
|
||||
200,
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue