Merge pull request #43 from versia-pub/feat/v0.6

Upgrade to Versia Protocol v0.6
This commit is contained in:
Gaspard Wierzbinski 2026-03-31 04:18:25 +02:00 committed by GitHub
commit 63f5136584
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 4344 additions and 2389 deletions

565
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -28,7 +28,7 @@ export const refetchUserCommand = defineCommand(
const spinner = ora("Refetching user").start();
try {
await User.fromVersia(user.uri);
await User.fromVersia(user.reference);
} catch (error) {
spinner.fail(
`Failed to refetch user ${chalk.gray(user.data.username)}`,

View file

@ -8,7 +8,7 @@ export const retrieveUser = async (
): Promise<User | null> => {
const { username, domain } = parseUserAddress(usernameOrHandle);
const instance = domain ? await Instance.resolveFromHost(domain) : null;
const instance = domain ? await Instance.resolve(domain) : null;
const user = await User.fromSql(
and(

View file

@ -48,6 +48,7 @@
default = pkgs.mkShell rec {
libPath = with pkgs;
lib.makeLibraryPath [
vips
stdenv.cc.cc.lib
];
@ -55,7 +56,6 @@
buildInputs = with pkgs; [
bun
vips
nodePackages.typescript
nodePackages.typescript-language-server
nix-ld

View file

@ -87,7 +87,7 @@
"hono-openapi": "~1.1.2",
"hono-rate-limiter": "~0.5.1",
"html-to-text": "~9.0.5",
"ioredis": "~5.8.2",
"ioredis": "5.9.2",
"ip-matching": "~2.1.2",
"iso-639-1": "~3.1.5",
"linkify-html": "~4.3.2",
@ -158,6 +158,7 @@
"dependencies": {
"@bull-board/api": "catalog:",
"@bull-board/hono": "catalog:",
"@clerc/core": "catalog:",
"@clerc/plugin-completions": "catalog:",
"@clerc/plugin-friendly-error": "catalog:",
"@clerc/plugin-help": "catalog:",
@ -180,7 +181,6 @@
"blurhash": "catalog:",
"bullmq": "catalog:",
"chalk": "catalog:",
"@clerc/core": "catalog:",
"confbox": "catalog:",
"drizzle-orm": "catalog:",
"feed": "catalog:",
@ -216,5 +216,8 @@
"zod": "catalog:",
"zod-openapi": "catalog:",
"zod-validation-error": "catalog:"
},
"patchedDependencies": {
"bun-bagel@1.2.0": "patches/bun-bagel@1.2.0.patch"
}
}

View file

@ -50,7 +50,7 @@ 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);
return context.json(newUser.toApi(false), 200);
},

View file

@ -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,14 @@ 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);
throw ApiError.accountNotFound();
return context.json(foundAccount.toApi(), 200);
},
),
);

View file

@ -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,
);
accounts.push(foundAccount);
}
} else {
accounts.push(

View file

@ -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,
);
return context.json(
{
accounts: [newUser.toApi()],
statuses: [],
hashtags: [],
},
200,
);
}
}
}

View file

@ -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());
},
),
);

View file

@ -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());
},
),
);

View file

@ -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());
},
),
);

View file

@ -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());
},
),
);

View file

@ -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());
},
),
);

View file

@ -65,6 +65,7 @@ export default apiRoute((app) => {
const jwtPayload = (await verify(state, config.authentication.key, {
iss: config.http.base_url.toString(),
alg: "HS256",
})) as {
flow: string;
link?: boolean;

View file

@ -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());
},
),
);

View file

@ -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,
);
},
),
);

View file

@ -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());
},
),
);

View file

@ -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());
},
),
);

View 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());
},
),
);

View 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());
},
),
);

View file

@ -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();
@ -43,61 +40,64 @@ mock(new URL("/.well-known/versia", instanceUrl).href, {
headers: {
"Content-Type": "application/json",
},
data: {
versions: ["0.6.0"],
},
},
});
mock(new URL("/.versia/v0.6/instance", instanceUrl).href, {
response: {
headers: {
"Content-Type": "application/vnd.versia+json; charset=utf-8",
},
data: new VersiaEntities.InstanceMetadata({
type: "InstanceMetadata",
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",
"Content-Type": "application/vnd.versia+json; charset=utf-8",
},
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 +111,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 +133,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 +142,17 @@ describe("Inbox Tests", () => {
});
const signedRequest = await sign(
privateKey,
new URL(exampleRequest.data.author),
instanceKeys.privateKey,
instanceUrl,
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()),
}),
);
@ -165,12 +162,16 @@ describe("Inbox Tests", () => {
body: signedRequest.body,
});
console.log(await response.text());
expect(response.status).toBe(200);
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 +180,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,
new URL(exampleRequest.data.author),
instanceKeys.privateKey,
instanceUrl,
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 +210,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 +220,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 +232,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,
new URL(exampleRequest.data.author),
instanceKeys.privateKey,
instanceUrl,
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 +263,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 +307,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 +318,7 @@ describe("Inbox Tests", () => {
name: ":neocat:",
url: {
"image/webp": {
hash: {
sha256: "e06240155d2cb90e8dc05327d023585ab9d47216ff547ad72aaf75c485fe9649",
},
hash: "e06240155d2cb90e8dc05327d023585ab9d47216ff547ad72aaf75c485fe9649",
size: 4664,
width: 256,
height: 256,
@ -341,13 +334,14 @@ describe("Inbox Tests", () => {
});
const signedRequest = await sign(
privateKey,
new URL(exampleRequest.data.author),
instanceKeys.privateKey,
instanceUrl,
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 +358,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 +399,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,
instanceUrl,
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 +439,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();
});

View file

@ -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",
"/.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,

View 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,
);
},
),
);

View file

@ -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,
);

View file

@ -1,3 +1,4 @@
import { sign } from "@versia/sdk/crypto";
import * as VersiaEntities from "@versia/sdk/entities";
import { FederationRequester } from "@versia/sdk/http";
import { config } from "@versia-server/config";
@ -15,6 +16,7 @@ import {
inArray,
type SQL,
} from "drizzle-orm";
import type { HttpVerb, KnownEntity } from "~/types/api.ts";
import { ApiError } from "../api-error.ts";
import { db } from "../tables/db.ts";
import { Instances } from "../tables/schema.ts";
@ -111,6 +113,13 @@ export class Instance extends BaseInterface<typeof Instances> {
}
}
public static get federationRequester(): FederationRequester {
return new FederationRequester(
config.instance.keys.private,
config.http.base_url,
);
}
public static async fromUser(user: User): Promise<Instance | null> {
if (!user.data.instanceId) {
return null;
@ -139,29 +148,24 @@ export class Instance extends BaseInterface<typeof Instances> {
return this.data.id;
}
public static async fetchMetadata(url: URL): Promise<{
public static async fetchMetadata(domain: string): Promise<{
metadata: VersiaEntities.InstanceMetadata;
protocol: "versia" | "activitypub";
}> {
const origin = new URL(url).origin;
const wellKnownUrl = new URL("/.well-known/versia", origin);
try {
const metadata = await new FederationRequester(
config.instance.keys.private,
config.http.base_url,
).fetchEntity(wellKnownUrl, VersiaEntities.InstanceMetadata);
const metadata =
await Instance.federationRequester.resolveInstance(domain);
return { metadata, protocol: "versia" };
} catch {
// If the server doesn't have a Versia well-known endpoint, it's not a Versia instance
// Try to resolve ActivityPub metadata instead
const data = await Instance.fetchActivityPubMetadata(url);
const data = await Instance.fetchActivityPubMetadata(domain);
if (!data) {
throw new ApiError(
404,
`Instance at ${origin} is not reachable or does not exist`,
`Instance at ${domain} is not reachable or does not exist`,
);
}
@ -173,9 +177,9 @@ export class Instance extends BaseInterface<typeof Instances> {
}
private static async fetchActivityPubMetadata(
url: URL,
domain: string,
): Promise<VersiaEntities.InstanceMetadata | null> {
const origin = new URL(url).origin;
const origin = new URL(`https://${domain}`);
const wellKnownUrl = new URL("/.well-known/nodeinfo", origin);
// Go to endpoint, then follow the links to the actual metadata
@ -254,7 +258,7 @@ export class Instance extends BaseInterface<typeof Instances> {
key: "",
algorithm: "ed25519",
},
host: new URL(url).host,
domain: origin.hostname,
compatibility: {
extensions: [],
versions: [],
@ -268,50 +272,33 @@ export class Instance extends BaseInterface<typeof Instances> {
}
}
public static resolveFromHost(host: string): Promise<Instance> {
if (host.startsWith("http")) {
const url = new URL(host);
return Instance.resolve(url);
}
const url = new URL(`https://${host}`);
return Instance.resolve(url);
}
public static async resolve(url: URL): Promise<Instance> {
const host = url.host;
public static async resolve(domain: string): Promise<Instance> {
const existingInstance = await Instance.fromSql(
eq(Instances.baseUrl, host),
eq(Instances.baseUrl, domain),
);
if (existingInstance) {
return existingInstance;
}
const output = await Instance.fetchMetadata(url);
const output = await Instance.fetchMetadata(domain);
const { metadata, protocol } = output;
return Instance.insert({
id: randomUUIDv7(),
baseUrl: host,
baseUrl: domain,
name: metadata.data.name,
version: metadata.data.software.version,
logo: metadata.data.logo,
protocol,
publicKey: metadata.data.public_key,
inbox: metadata.data.shared_inbox ?? null,
extensions: metadata.data.extensions ?? null,
});
}
public async updateFromRemote(): Promise<Instance> {
const output = await Instance.fetchMetadata(
new URL(`https://${this.data.baseUrl}`),
);
const output = await Instance.fetchMetadata(this.data.baseUrl);
if (!output) {
federationResolversLogger.error`Failed to update instance ${chalk.bold(
@ -328,13 +315,39 @@ export class Instance extends BaseInterface<typeof Instances> {
logo: metadata.data.logo,
protocol,
publicKey: metadata.data.public_key,
inbox: metadata.data.shared_inbox ?? null,
extensions: metadata.data.extensions ?? null,
});
return this;
}
/**
* Signs a Versia entity with this instance's private key
*
* @param entity Entity to sign
* @param signatureUrl URL to embed in signature (must be the same URI of queries made with this signature)
* @param signatureMethod HTTP method to embed in signature (default: POST)
* @returns The signed string and headers to send with the request
*/
public static async sign(
entity: KnownEntity | VersiaEntities.Collection,
signatureUrl: URL,
signatureMethod: HttpVerb = "POST",
): Promise<{
headers: Headers;
}> {
const { headers } = await sign(
config.instance.keys.private,
config.http.base_url,
new Request(signatureUrl, {
method: signatureMethod,
body: JSON.stringify(entity),
}),
);
return { headers };
}
public async sendMessage(content: string): Promise<void> {
if (
!this.data.extensions?.["pub.versia:instance_messaging"]?.endpoint

View file

@ -11,17 +11,22 @@ import {
} from "drizzle-orm";
import { db } from "../tables/db.ts";
import {
type Instances,
Likes,
type Notes,
Notifications,
type Users,
} from "../tables/schema.ts";
import { BaseInterface } from "./base.ts";
import { User } from "./user.ts";
import type { User } from "./user.ts";
type LikeType = InferSelectModel<typeof Likes> & {
liker: InferSelectModel<typeof Users>;
liked: InferSelectModel<typeof Notes>;
liked: InferSelectModel<typeof Notes> & {
author: InferSelectModel<typeof Users> & {
instance: InferSelectModel<typeof Instances> | null;
};
};
};
export class Like extends BaseInterface<typeof Likes, LikeType> {
@ -57,7 +62,15 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
where: sql,
orderBy,
with: {
liked: true,
liked: {
with: {
author: {
with: {
instance: true,
},
},
},
},
liker: true,
},
});
@ -65,6 +78,7 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
if (!found) {
return null;
}
return new Like(found);
}
@ -73,7 +87,6 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
orderBy: SQL<unknown> | undefined = desc(Likes.id),
limit?: number,
offset?: number,
extra?: Parameters<typeof db.query.Likes.findMany>[0],
): Promise<Like[]> {
const found = await db.query.Likes.findMany({
where: sql,
@ -81,9 +94,16 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
limit,
offset,
with: {
liked: true,
liked: {
with: {
author: {
with: {
instance: true,
},
},
},
},
liker: true,
...extra?.with,
},
});
@ -146,37 +166,28 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
}
public toVersia(): VersiaEntities.Like {
let likedReference = this.data.liked.id;
if (this.data.liked.author.instance) {
likedReference = `${this.data.liked.author.instance.baseUrl}:${this.data.liked.remoteId}`;
}
return new VersiaEntities.Like({
id: this.data.id,
author: User.getUri(
this.data.liker.id,
this.data.liker.uri ? new URL(this.data.liker.uri) : null,
).href,
id: this.id,
author: this.data.liker.id,
type: "pub.versia:likes/Like",
created_at: this.data.createdAt.toISOString(),
liked: this.data.liked.uri
? new URL(this.data.liked.uri).href
: new URL(`/notes/${this.data.liked.id}`, config.http.base_url)
.href,
uri: this.getUri().href,
liked: likedReference,
});
}
public unlikeToVersia(unliker?: User): VersiaEntities.Delete {
return new VersiaEntities.Delete({
type: "Delete",
id: crypto.randomUUID(),
created_at: new Date().toISOString(),
author: User.getUri(
unliker?.id ?? this.data.liker.id,
unliker?.data.uri
? new URL(unliker.data.uri)
: this.data.liker.uri
? new URL(this.data.liker.uri)
: null,
).href,
author: unliker ? unliker.id : this.data.liker.id,
deleted_type: "pub.versia:likes/Like",
deleted: this.getUri().href,
deleted: this.id,
});
}
}

View file

@ -476,9 +476,7 @@ export class Media extends BaseInterface<typeof Medias> {
[file.type]: {
content: uri.toString(),
remote: true,
hash: {
sha256: hash,
},
hash,
width,
height,
description: options?.description,

View file

@ -3,7 +3,6 @@ import type {
Status as StatusSchema,
} from "@versia/client/schemas";
import * as VersiaEntities from "@versia/sdk/entities";
import { FederationRequester } from "@versia/sdk/http";
import type { NonTextContentFormatSchema } from "@versia/sdk/schemas";
import { config } from "@versia-server/config";
import { randomUUIDv7 } from "bun";
@ -25,7 +24,6 @@ import { mergeAndDeduplicate } from "@/lib.ts";
import { sanitizedHtmlStrip } from "@/sanitization";
import { versiaTextToHtml } from "../parsers.ts";
import { DeliveryJobType, deliveryQueue } from "../queues/delivery/queue.ts";
import { uuid } from "../regex.ts";
import { db } from "../tables/db.ts";
import {
EmojiToNote,
@ -166,8 +164,24 @@ const findManyNotes = async (
: sql`false`.as("liked"),
},
},
reply: true,
quote: true,
reply: {
with: {
author: {
with: {
instance: true,
},
},
},
},
quote: {
with: {
author: {
with: {
instance: true,
},
},
},
},
},
extras: {
pinned: userId
@ -197,19 +211,13 @@ const findManyNotes = async (
return output.map((post) => ({
...post,
author: transformOutputToUserWithRelations(post.author),
mentions: post.mentions.map((mention) => ({
...mention.user,
endpoints: mention.user.endpoints,
})),
mentions: post.mentions.map((mention) => mention.user),
attachments: post.attachments.map((attachment) => attachment.media),
emojis: (post.emojis ?? []).map((emoji) => emoji.emoji),
reblog: post.reblog && {
...post.reblog,
author: transformOutputToUserWithRelations(post.reblog.author),
mentions: post.reblog.mentions.map((mention) => ({
...mention.user,
endpoints: mention.user.endpoints,
})),
mentions: post.reblog.mentions.map((mention) => mention.user),
attachments: post.reblog.attachments.map(
(attachment) => attachment.media,
),
@ -236,8 +244,20 @@ type NoteTypeWithRelations = NoteType & {
attachments: (typeof Media.$type)[];
reblog: NoteTypeWithoutRecursiveRelations | null;
emojis: (typeof Emoji.$type)[];
reply: NoteType | null;
quote: NoteType | null;
reply:
| (NoteType & {
author: InferSelectModel<typeof Users> & {
instance: typeof Instance.$type | null;
};
})
| null;
quote:
| (NoteType & {
author: InferSelectModel<typeof Users> & {
instance: typeof Instance.$type | null;
};
})
| null;
client: typeof Client.$type | null;
pinned: boolean;
reblogged: boolean;
@ -404,6 +424,17 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
return this.data.id;
}
public get reference(): VersiaEntities.Reference {
if (this.remote) {
const instanceUrl = new URL(
this.author.data.instance?.baseUrl || "",
);
return new VersiaEntities.Reference(this.id, instanceUrl.hostname);
}
return new VersiaEntities.Reference(this.id);
}
public async federateToUsers(): Promise<void> {
const users = await this.getUsersToFederateTo();
@ -489,13 +520,13 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
* If the note is already reblogged, it will return the existing reblog. Also creates a notification for the author of the note.
* @param reblogger The user reblogging the note
* @param visibility The visibility of the reblog
* @param uri The URI of the reblog, if it is remote
* @param remoteId The remote ID of the reblog, if it is from a remote user
* @returns The reblog object created or the existing reblog
*/
public async reblog(
reblogger: User,
visibility: z.infer<typeof StatusSchema.shape.visibility>,
uri?: URL,
remoteId?: string,
): Promise<Note> {
const existingReblog = await Note.fromSql(
and(eq(Notes.authorId, reblogger.id), eq(Notes.reblogId, this.id)),
@ -515,7 +546,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
sensitive: false,
updatedAt: new Date(),
clientId: null,
uri: uri?.href,
remoteId,
});
await this.recalculateReblogCount();
@ -612,10 +643,10 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
*
* If the note is already liked, it will return the existing like. Also creates a notification for the author of the note.
* @param liker The user liking the note
* @param uri The URI of the like, if it is remote
* @param remoteId The id of the like, if it is remote
* @returns The like object created or the existing like
*/
public async like(liker: User, uri?: URL): Promise<Like> {
public async like(liker: User, remoteId?: string): Promise<Like> {
// Check if the user has already liked the note
const existingLike = await Like.fromSql(
and(eq(Likes.likerId, liker.id), eq(Likes.likedId, this.id)),
@ -629,7 +660,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
id: randomUUIDv7(),
likerId: liker.id,
likedId: this.id,
uri: uri?.href,
remoteId,
});
await this.recalculateLikeCount();
@ -904,73 +935,107 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
}
/**
* Resolve a note from a URI
* @param uri - The URI of the note to resolve
* Resolve a note from a reference
* @param reference - The URI of the note to resolve
* @returns The resolved note
*/
public static async resolve(uri: URL): Promise<Note | null> {
public static async resolve(
reference: VersiaEntities.Reference,
defaultInstance?: Instance,
): Promise<Note | null> {
// Check if note not already in database
const foundNote = await Note.fromSql(eq(Notes.uri, uri.href));
if (
!(reference.domain || defaultInstance) ||
reference.domain === config.http.base_url.hostname
) {
return await Note.fromId(reference.id);
}
const instance = reference.domain
? await Instance.resolve(reference.domain)
: (defaultInstance as Instance);
const foundNote = await Note.fromSql(
and(
eq(Notes.remoteId, reference.id),
eq(
Notes.authorId,
sql`(
SELECT "Users".id FROM "Users"
WHERE "Users"."instanceId" = ${instance.id}
LIMIT 1
)`,
),
),
);
if (foundNote) {
return foundNote;
}
// Check if URI is of a local note
if (uri.origin === config.http.base_url.origin) {
const noteUuid = uri.pathname.match(uuid);
if (!noteUuid?.[0]) {
throw new Error(
`URI ${uri} is of a local note, but it could not be parsed`,
);
}
return await Note.fromId(noteUuid[0]);
}
return Note.fromVersia(uri);
return Note.fromVersia(
reference.domain
? reference
: new VersiaEntities.Reference(
reference.id,
instance.data.baseUrl,
),
);
}
/**
* Takes a Versia Note representation, and serializes it to the database.
*
* If the note already exists, it will update it.
* @param versiaNote - URL or Versia Note representation
* @param versiaNote - Reference or Versia Note representation
*/
public static async fromVersia(
versiaNote: VersiaEntities.Note | URL,
): Promise<Note> {
if (versiaNote instanceof URL) {
// No bridge support for notes yet
const note = await new FederationRequester(
config.instance.keys.private,
config.http.base_url,
).fetchEntity(versiaNote, VersiaEntities.Note);
versiaNote: VersiaEntities.Note,
instance: Instance,
): Promise<Note>;
return Note.fromVersia(note);
public static async fromVersia(
reference: VersiaEntities.Reference,
): Promise<Note>;
public static async fromVersia(
versiaNote: VersiaEntities.Note | VersiaEntities.Reference,
instance?: Instance,
): Promise<Note> {
if (versiaNote instanceof VersiaEntities.Reference) {
if (!versiaNote.domain) {
throw new Error(
"Cannot fetch Versia note from reference without domain",
);
}
// No bridge support for notes yet
const note = await Instance.federationRequester.fetchEntity(
versiaNote,
VersiaEntities.Note,
);
const instance = await Instance.resolve(versiaNote.domain);
return Note.fromVersia(note, instance);
}
const {
author: authorUrl,
created_at,
uri,
extensions,
group,
is_sensitive,
mentions: noteMentions,
quotes,
replies_to,
subject,
} = versiaNote.data;
const instance = await Instance.resolve(new URL(authorUrl));
const author = await User.resolve(new URL(authorUrl));
if (!instance) {
throw new Error("Instance must be provided when fetching note");
}
const { created_at, extensions, group, id, is_sensitive, subject } =
versiaNote.data;
const author = await User.resolve(versiaNote.author, instance);
if (!author) {
throw new Error("Entity author could not be resolved");
}
const existingNote = await Note.fromSql(eq(Notes.uri, uri));
const existingNote = await Note.fromSql(
and(eq(Notes.remoteId, id), eq(Notes.authorId, author.id)),
);
const note =
existingNote ??
@ -978,7 +1043,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
id: randomUUIDv7(),
authorId: author.id,
visibility: "public",
uri,
remoteId: id,
createdAt: new Date(created_at),
}));
@ -999,9 +1064,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
const mentions = (
await Promise.all(
noteMentions?.map((mention) =>
User.resolve(new URL(mention)),
) ?? [],
versiaNote.mentions.map((m) => User.resolve(m, instance)) ?? [],
)
).filter((m) => m !== null);
@ -1011,10 +1074,12 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
? "direct"
: (group as "public" | "followers" | "unlisted");
const reply = replies_to
? await Note.resolve(new URL(replies_to))
const reply = versiaNote.repliesTo
? await Note.resolve(versiaNote.repliesTo, instance)
: null;
const quote = versiaNote.quotes
? await Note.resolve(versiaNote.quotes, instance)
: null;
const quote = quotes ? await Note.resolve(new URL(quotes)) : null;
const spoiler = subject ? await sanitizedHtmlStrip(subject) : undefined;
await note.update({
@ -1169,9 +1234,11 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
mention.username,
mention.instance?.baseUrl,
),
url: User.getUri(
mention.id,
mention.uri ? new URL(mention.uri) : null,
url: new URL(
`/@${mention.username}${
mention.instance ? `@${mention.instance.baseUrl}` : ""
}`,
config.http.base_url,
).toString(),
username: mention.username,
})),
@ -1191,9 +1258,9 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
sensitive: data.sensitive,
spoiler_text: data.spoilerText,
tags: [],
uri: data.uri || this.getUri().toString(),
uri: this.getUri().toString(),
visibility: data.visibility,
url: data.uri || this.getMastoUri().toString(),
url: this.getMastoUri().toString(),
bookmarked: false,
quote: data.quotingId
? ((await Note.fromId(data.quotingId, userFetching?.id).then(
@ -1207,9 +1274,14 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
}
public getUri(): URL {
return this.data.uri
? new URL(this.data.uri)
: new URL(`/notes/${this.id}`, config.http.base_url);
const domain = this.author.data.instance?.baseUrl
? new URL(`https://${this.author.data.instance.baseUrl}`)
: config.http.base_url;
return new URL(
`/.versia/v0.6/entities/Note/${this.id}`,
`https://${domain}`,
);
}
/**
@ -1224,14 +1296,11 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
}
public deleteToVersia(): VersiaEntities.Delete {
const id = crypto.randomUUID();
return new VersiaEntities.Delete({
type: "Delete",
id,
author: this.author.uri.href,
author: this.author.id,
deleted_type: "Note",
deleted: this.getUri().href,
deleted: this.id,
created_at: new Date().toISOString(),
});
}
@ -1242,12 +1311,24 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
*/
public toVersia(): VersiaEntities.Note {
const status = this.data;
let quoteReference = status.quote?.id ?? null;
if (quoteReference && status.quote?.author.instance) {
quoteReference = `${status.quote.author.instance.baseUrl}:${status.quote.remoteId}`;
}
let replyReference = status.reply?.id ?? null;
if (replyReference && status.reply?.author.instance) {
replyReference = `${status.reply.author.instance.baseUrl}:${status.reply.remoteId}`;
}
return new VersiaEntities.Note({
type: "Note",
created_at: status.createdAt.toISOString(),
id: status.id,
author: this.author.uri.href,
uri: this.getUri().href,
author: this.author.id,
content: {
"text/html": {
content: status.content,
@ -1258,20 +1339,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
remote: false,
},
},
collections: {
replies: new URL(
`/notes/${status.id}/replies`,
config.http.base_url,
).href,
quotes: new URL(
`/notes/${status.id}/quotes`,
config.http.base_url,
).href,
"pub.versia:share/Shares": new URL(
`/notes/${status.id}/shares`,
config.http.base_url,
).href,
},
previews: [],
attachments: status.attachments.map(
(attachment) =>
new Media(attachment).toVersia().data as z.infer<
@ -1279,25 +1347,13 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
>,
),
is_sensitive: status.sensitive,
mentions: status.mentions.map(
(mention) =>
User.getUri(
mention.id,
mention.uri ? new URL(mention.uri) : null,
).href,
mentions: status.mentions.map((mention) =>
mention.instance
? `${mention.instance.baseUrl}:${mention.id}`
: mention.id,
),
quotes: status.quote
? status.quote.uri
? new URL(status.quote.uri).href
: new URL(`/notes/${status.quote.id}`, config.http.base_url)
.href
: null,
replies_to: status.reply
? status.reply.uri
? new URL(status.reply.uri).href
: new URL(`/notes/${status.reply.id}`, config.http.base_url)
.href
: null,
quotes: quoteReference,
replies_to: replyReference,
subject: status.spoilerText,
// TODO: Refactor as part of groups
group: status.visibility === "public" ? "public" : "followers",
@ -1319,26 +1375,22 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
return new VersiaEntities.Share({
type: "pub.versia:share/Share",
id: crypto.randomUUID(),
author: this.author.uri.href,
uri: new URL(`/shares/${this.id}`, config.http.base_url).href,
author: this.author.id,
id: this.id,
created_at: new Date().toISOString(),
shared: new Note(this.data.reblog as NoteTypeWithRelations).getUri()
.href,
shared: this.data.reblog.author.instance
? `${this.data.reblog.author.instance.baseUrl}:${this.data.reblog.id}`
: this.data.reblog.id,
});
}
public toVersiaUnshare(): VersiaEntities.Delete {
return new VersiaEntities.Delete({
type: "Delete",
id: crypto.randomUUID(),
created_at: new Date().toISOString(),
author: User.getUri(
this.data.authorId,
this.data.author.uri ? new URL(this.data.author.uri) : null,
).href,
author: this.author.id,
deleted_type: "pub.versia:share/Share",
deleted: new URL(`/shares/${this.id}`, config.http.base_url).href,
deleted: this.id,
});
}

View file

@ -1,5 +1,4 @@
import * as VersiaEntities from "@versia/sdk/entities";
import { config } from "@versia-server/config";
import { randomUUIDv7 } from "bun";
import {
and,
@ -12,17 +11,26 @@ import {
type SQL,
} from "drizzle-orm";
import { db } from "../tables/db.ts";
import { type Notes, Reactions, type Users } from "../tables/schema.ts";
import {
type Instances,
type Notes,
Reactions,
type Users,
} from "../tables/schema.ts";
import { BaseInterface } from "./base.ts";
import { Emoji } from "./emoji.ts";
import { Instance } from "./instance.ts";
import type { Note } from "./note.ts";
import { User } from "./user.ts";
import type { User } from "./user.ts";
type ReactionType = InferSelectModel<typeof Reactions> & {
emoji: typeof Emoji.$type | null;
author: InferSelectModel<typeof Users>;
note: InferSelectModel<typeof Notes>;
note: InferSelectModel<typeof Notes> & {
author: InferSelectModel<typeof Users> & {
instance: InferSelectModel<typeof Instances> | null;
};
};
};
export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
@ -64,7 +72,15 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
},
},
author: true,
note: true,
note: {
with: {
author: {
with: {
instance: true,
},
},
},
},
},
orderBy,
});
@ -96,7 +112,15 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
},
},
author: true,
note: true,
note: {
with: {
author: {
with: {
instance: true,
},
},
},
},
},
});
@ -210,19 +234,18 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
throw new Error("Cannot convert a non-local reaction to Versia");
}
let noteReference = this.data.note.id;
if (this.data.note.author.instance) {
noteReference = `${this.data.note.author.instance.baseUrl}:${this.data.note.remoteId}`;
}
return new VersiaEntities.Reaction({
uri: this.getUri(config.http.base_url).href,
type: "pub.versia:reactions/Reaction",
author: User.getUri(
this.data.authorId,
this.data.author.uri ? new URL(this.data.author.uri) : null,
).href,
author: this.data.author.id,
created_at: this.data.createdAt.toISOString(),
id: this.id,
object: this.data.note.uri
? new URL(this.data.note.uri).href
: new URL(`/notes/${this.data.noteId}`, config.http.base_url)
.href,
object: noteReference,
content: this.hasCustomEmoji()
? `:${this.data.emoji?.shortcode}:`
: this.data.emojiText || "",
@ -243,14 +266,10 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
public toVersiaUnreact(): VersiaEntities.Delete {
return new VersiaEntities.Delete({
type: "Delete",
id: crypto.randomUUID(),
created_at: new Date().toISOString(),
author: User.getUri(
this.data.authorId,
this.data.author.uri ? new URL(this.data.author.uri) : null,
).href,
author: this.data.authorId,
deleted_type: "pub.versia:reactions/Reaction",
deleted: this.getUri(config.http.base_url).href,
deleted: this.id,
});
}
@ -279,7 +298,7 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
return Reaction.insert({
id: randomUUIDv7(),
uri: reactionToConvert.data.uri,
remoteId: reactionToConvert.data.id,
authorId: author.id,
noteId: note.id,
emojiId: emoji ? emoji.id : null,

View file

@ -4,15 +4,11 @@ import type {
RolePermission,
Source,
} from "@versia/client/schemas";
import { sign } from "@versia/sdk/crypto";
import * as VersiaEntities from "@versia/sdk/entities";
import { FederationRequester } from "@versia/sdk/http";
import type { ImageContentFormatSchema } from "@versia/sdk/schemas";
import { config, ProxiableUrl } from "@versia-server/config";
import {
federationDeliveryLogger,
federationResolversLogger,
} from "@versia-server/logging";
import { federationDeliveryLogger } from "@versia-server/logging";
import { password as bunPassword, randomUUIDv7 } from "bun";
import chalk from "chalk";
import {
@ -33,10 +29,9 @@ import { htmlToText } from "html-to-text";
import type { z } from "zod";
import { getBestContentType } from "@/content_types";
import { randomString } from "@/math";
import type { HttpVerb, KnownEntity } from "~/types/api.ts";
import type { KnownEntity } from "~/types/api.ts";
import { DeliveryJobType, deliveryQueue } from "../queues/delivery/queue.ts";
import { PushJobType, pushQueue } from "../queues/push/queue.ts";
import { uuid } from "../regex.ts";
import { db } from "../tables/db.ts";
import {
EmojiToUser,
@ -79,7 +74,7 @@ export const userRelations = {
// TODO: Remove this function and use what drizzle outputs directly instead of transforming it
export const transformOutputToUserWithRelations = (
user: Omit<InferSelectModel<typeof Users>, "endpoints"> & {
user: InferSelectModel<typeof Users> & {
followerCount: unknown;
followingCount: unknown;
statusCount: unknown;
@ -96,7 +91,6 @@ export const transformOutputToUserWithRelations = (
roleId: string;
role?: typeof Role.$type;
}[];
endpoints: unknown;
},
): typeof User.$type => {
return {
@ -104,17 +98,6 @@ export const transformOutputToUserWithRelations = (
followerCount: Number(user.followerCount),
followingCount: Number(user.followingCount),
statusCount: Number(user.statusCount),
endpoints:
user.endpoints ??
({} as Partial<{
dislikes: string;
featured: string;
likes: string;
followers: string;
following: string;
inbox: string;
outbox: string;
}>),
emojis: user.emojis.map(
(emoji) =>
(emoji as unknown as Record<string, object>)
@ -239,14 +222,26 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return !this.local;
}
public get uri(): URL {
return this.data.uri
? new URL(this.data.uri)
: new URL(`/users/${this.data.id}`, config.http.base_url);
public get reference(): VersiaEntities.Reference {
if (this.local) {
return new VersiaEntities.Reference(this.id);
}
return new VersiaEntities.Reference(
this.data.remoteId as string,
(this.data.instance as typeof Instance.$type).baseUrl,
);
}
public static getUri(id: string, uri: URL | null): URL {
return uri ? uri : new URL(`/users/${id}`, config.http.base_url);
public get uri(): URL {
const domain = this.data.instance?.baseUrl
? new URL(`https://${this.data.instance.baseUrl}`)
: config.http.base_url;
return new URL(
`/.versia/v0.6/entities/User/${this.id}`,
`https://${domain}`,
);
}
public hasPermission(permission: RolePermission): boolean {
@ -335,13 +330,13 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
}
private unfollowToVersia(followee: User): VersiaEntities.Unfollow {
const id = crypto.randomUUID();
return new VersiaEntities.Unfollow({
type: "Unfollow",
id,
author: this.uri.href,
author: this.id,
created_at: new Date().toISOString(),
followee: followee.uri.href,
followee: followee.data.instance
? `${followee.data.instance.baseUrl}:${followee.id}`
: followee.id,
});
}
@ -359,10 +354,11 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
const entity = new VersiaEntities.FollowAccept({
type: "FollowAccept",
id: crypto.randomUUID(),
author: this.uri.href,
author: this.id,
created_at: new Date().toISOString(),
follower: follower.uri.href,
follower: follower.data.instance
? `${follower.data.instance.baseUrl}:${follower.id}`
: follower.id,
});
await deliveryQueue.add(DeliveryJobType.FederateEntity, {
@ -383,10 +379,11 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
const entity = new VersiaEntities.FollowReject({
type: "FollowReject",
id: crypto.randomUUID(),
author: this.uri.href,
author: this.id,
created_at: new Date().toISOString(),
follower: follower.uri.href,
follower: follower.data.instance
? `${follower.data.instance.baseUrl}:${follower.id}`
: follower.id,
});
await deliveryQueue.add(DeliveryJobType.FederateEntity, {
@ -396,41 +393,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
});
}
/**
* Signs a Versia entity with that user's private key
*
* @param entity Entity to sign
* @param signatureUrl URL to embed in signature (must be the same URI of queries made with this signature)
* @param signatureMethod HTTP method to embed in signature (default: POST)
* @returns The signed string and headers to send with the request
*/
public async sign(
entity: KnownEntity | VersiaEntities.Collection,
signatureUrl: URL,
signatureMethod: HttpVerb = "POST",
): Promise<{
headers: Headers;
}> {
const privateKey = await crypto.subtle.importKey(
"pkcs8",
Buffer.from(this.data.privateKey ?? "", "base64"),
"Ed25519",
false,
["sign"],
);
const { headers } = await sign(
privateKey,
this.uri,
new Request(signatureUrl, {
method: signatureMethod,
body: JSON.stringify(entity),
}),
);
return { headers };
}
/**
* Perform a WebFinger lookup to find a user's URI
* @param username
@ -708,54 +670,55 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
* Takes a Versia User representation, and serializes it to the database.
*
* If the user already exists, it will update it.
* @param versiaUser URL or Versia User representation
* @param versiaUser Reference or Versia User representation
*/
public static async fromVersia(
versiaUser: VersiaEntities.User | URL,
versiaUser: VersiaEntities.User,
instance: Instance,
): Promise<User>;
public static async fromVersia(
versiaUser: VersiaEntities.Reference,
): Promise<User>;
public static async fromVersia(
versiaUser: VersiaEntities.User | VersiaEntities.Reference,
instance?: Instance,
): Promise<User> {
if (versiaUser instanceof URL) {
let uri = versiaUser;
const instance = await Instance.resolve(uri);
if (instance.data.protocol === "activitypub") {
if (!config.federation.bridge) {
throw new Error("ActivityPub bridge is not enabled");
}
uri = new URL(
`/apbridge/versia/query?${new URLSearchParams({
user_url: uri.href,
})}`,
config.federation.bridge.url,
if (versiaUser instanceof VersiaEntities.Reference) {
if (!versiaUser.domain) {
throw new Error(
"Cannot fetch Versia user from reference without domain",
);
}
const user = await new FederationRequester(
config.instance.keys.private,
config.http.base_url,
).fetchEntity(uri, VersiaEntities.User);
const user = await Instance.federationRequester.fetchEntity(
versiaUser,
VersiaEntities.User,
);
return User.fromVersia(user);
const instance = await Instance.resolve(versiaUser.domain);
return User.fromVersia(user, instance);
}
if (!instance) {
throw new Error("Instance must be provided when fetching user");
}
const {
username,
inbox,
avatar,
header,
display_name,
id,
fields,
collections,
created_at,
manually_approves_followers,
bio,
public_key,
uri,
extensions,
} = versiaUser.data;
const instance = await Instance.resolve(new URL(versiaUser.data.uri));
const existingUser = await User.fromSql(
eq(Users.uri, versiaUser.data.uri),
and(eq(Users.instanceId, instance.id), eq(Users.remoteId, id)),
);
const user =
@ -763,60 +726,50 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
(await User.insert({
username,
id: randomUUIDv7(),
publicKey: public_key.key,
uri,
instanceId: instance.id,
remoteId: id,
}));
// Avatars and headers are stored in a separate table, so we need to update them separately
let userAvatar: Media | null = null;
let userHeader: Media | null = null;
if (avatar) {
if (versiaUser.avatar) {
if (user.avatar) {
userAvatar = new Media(
await user.avatar.update({
content: avatar,
content: versiaUser.avatar.data,
}),
);
} else {
userAvatar = await Media.insert({
id: randomUUIDv7(),
content: avatar,
content: versiaUser.avatar.data,
});
}
}
if (header) {
if (versiaUser.header) {
if (user.header) {
userHeader = new Media(
await user.header.update({
content: header,
content: versiaUser.header.data,
}),
);
} else {
userHeader = await Media.insert({
id: randomUUIDv7(),
content: header,
content: versiaUser.header.data,
});
}
}
await user.update({
createdAt: new Date(created_at),
endpoints: {
inbox,
outbox: collections.outbox,
followers: collections.followers,
following: collections.following,
featured: collections.featured,
likes: collections["pub.versia:likes/Likes"] ?? undefined,
dislikes: collections["pub.versia:likes/Dislikes"] ?? undefined,
},
isLocked: manually_approves_followers ?? false,
isLocked: manually_approves_followers,
avatarId: userAvatar?.id,
headerId: userHeader?.id,
fields: fields ?? [],
fields,
displayName: display_name,
note: getBestContentType(bio).content,
});
@ -847,31 +800,49 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return user;
}
public static async resolve(uri: URL): Promise<User | null> {
federationResolversLogger.debug`Resolving user ${chalk.gray(uri)}`;
public static async resolve(
reference: VersiaEntities.Reference,
defaultInstance?: Instance,
): Promise<User> {
// Check if user not already in database
const foundUser = await User.fromSql(eq(Users.uri, uri.href));
if (
!(reference.domain || defaultInstance) ||
reference.domain === config.http.base_url.hostname
) {
const user = await User.fromId(reference.id);
if (!user) {
throw new Error(
"Failed to resolve user reference: User not found",
);
}
return user;
}
const instance = reference.domain
? await Instance.resolve(reference.domain)
: (defaultInstance as Instance);
const foundUser = await User.fromSql(
and(
eq(Users.instanceId, instance.id),
eq(Users.remoteId, reference.id),
),
);
if (foundUser) {
return foundUser;
}
// Check if URI is of a local user
if (uri.origin === config.http.base_url.origin) {
const userUuid = uri.href.match(uuid);
if (!userUuid?.[0]) {
throw new Error(
`URI ${uri} is of a local user, but it could not be parsed`,
);
}
return await User.fromId(userUuid[0]);
}
federationResolversLogger.debug`User not found in database, fetching from remote`;
return User.fromVersia(uri);
return User.fromVersia(
reference.domain
? reference
: new VersiaEntities.Reference(
reference.id,
instance.data.baseUrl,
),
);
}
/**
@ -890,31 +861,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return this.avatar?.getUrl();
}
public static async generateKeys(): Promise<{
private_key: string;
public_key: string;
}> {
const keys = await crypto.subtle.generateKey("Ed25519", true, [
"sign",
"verify",
]);
const privateKey = Buffer.from(
await crypto.subtle.exportKey("pkcs8", keys.privateKey),
).toString("base64");
const publicKey = Buffer.from(
await crypto.subtle.exportKey("spki", keys.publicKey),
).toString("base64");
// Add header, footer and newlines later on
// These keys are base64 encrypted
return {
private_key: privateKey,
public_key: publicKey,
};
}
public static async register(
username: string,
options?: Partial<{
@ -924,8 +870,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
isAdmin: boolean;
}>,
): Promise<User> {
const keys = await User.generateKeys();
const user = await User.insert({
id: randomUUIDv7(),
username,
@ -937,9 +881,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
note: "",
avatarId: options?.avatar?.id,
isAdmin: options?.isAdmin,
publicKey: keys.public_key,
fields: [],
privateKey: keys.private_key,
updatedAt: new Date(),
source: {
language: "en",
@ -999,11 +941,9 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
newUser.avatar ||
newUser.header ||
newUser.fields ||
newUser.publicKey ||
newUser.isAdmin ||
newUser.isBot ||
newUser.isLocked ||
newUser.endpoints ||
newUser.isDiscoverable ||
newUser.isIndexable)
) {
@ -1013,20 +953,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return updated.data;
}
public get federationRequester(): Promise<FederationRequester> {
return crypto.subtle
.importKey(
"pkcs8",
Buffer.from(this.data.privateKey ?? "", "base64"),
"Ed25519",
false,
["sign"],
)
.then((k) => {
return new FederationRequester(k, this.uri);
});
}
/**
* Get all remote followers of the user
* @returns The remote followers
@ -1076,17 +1002,13 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
entity: KnownEntity,
user: User,
): Promise<{ ok: boolean }> {
const inbox = user.data.instance?.inbox || user.data.endpoints?.inbox;
if (!inbox) {
throw new Error(
`User ${chalk.gray(user.uri)} does not have an inbox endpoint`,
);
if (!user.data.instance) {
throw new Error("Cannot federate to a local user");
}
try {
await (await this.federationRequester).postEntity(
new URL(inbox),
await Instance.federationRequester.postEntity(
user.data.instance.baseUrl,
entity,
);
} catch (e) {
@ -1110,9 +1032,12 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
display_name: user.displayName || user.username,
note: user.note,
uri: this.uri.href,
url:
user.uri ||
new URL(`/@${user.username}`, config.http.base_url).href,
url: new URL(
`/@${user.username}${
user.instanceId ? `@${user.instance?.baseUrl}` : ""
}`,
config.http.base_url,
).href,
avatar: this.getAvatarUrl().proxied,
header: this.getHeaderUrl()?.proxied ?? "",
locked: user.isLocked,
@ -1166,7 +1091,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return new VersiaEntities.User({
id: user.id,
type: "User",
uri: this.uri.href,
bio: {
"text/html": {
content: user.note,
@ -1178,34 +1102,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
},
},
created_at: user.createdAt.toISOString(),
collections: {
featured: new URL(
`/users/${user.id}/featured`,
config.http.base_url,
).href,
"pub.versia:likes/Likes": new URL(
`/users/${user.id}/likes`,
config.http.base_url,
).href,
"pub.versia:likes/Dislikes": new URL(
`/users/${user.id}/dislikes`,
config.http.base_url,
).href,
followers: new URL(
`/users/${user.id}/followers`,
config.http.base_url,
).href,
following: new URL(
`/users/${user.id}/following`,
config.http.base_url,
).href,
outbox: new URL(
`/users/${user.id}/outbox`,
config.http.base_url,
).href,
},
inbox: new URL(`/users/${user.id}/inbox`, config.http.base_url)
.href,
indexable: this.data.isIndexable,
username: user.username,
manually_approves_followers: this.data.isLocked,
@ -1217,11 +1113,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
>,
display_name: user.displayName,
fields: user.fields,
public_key: {
actor: new URL(`/users/${user.id}`, config.http.base_url).href,
key: user.publicKey,
algorithm: "ed25519",
},
extensions: {
"pub.versia:custom_emojis": {
emojis: user.emojis.map((emoji) =>

View file

@ -182,27 +182,36 @@ export class InboxProcessor {
shouldCheckSignature && federationInboxLogger.debug`Signature is valid`;
try {
// TODO: Rip out bridge code so this is never null
const instance = this.sender?.instance as Instance;
await new EntitySorter(this.body)
.on(VersiaEntities.Note, (n) => InboxProcessor.processNote(n))
.on(VersiaEntities.Note, (n) =>
InboxProcessor.processNote(n, instance),
)
.on(VersiaEntities.Follow, (f) =>
InboxProcessor.processFollowRequest(f),
InboxProcessor.processFollowRequest(f, instance),
)
.on(VersiaEntities.FollowAccept, (f) =>
InboxProcessor.processFollowAccept(f),
InboxProcessor.processFollowAccept(f, instance),
)
.on(VersiaEntities.FollowReject, (f) =>
InboxProcessor.processFollowReject(f),
InboxProcessor.processFollowReject(f, instance),
)
.on(VersiaEntities.Like, (l) =>
InboxProcessor.processLikeRequest(l),
InboxProcessor.processLikeRequest(l, instance),
)
.on(VersiaEntities.Delete, (d) =>
InboxProcessor.processDelete(d),
InboxProcessor.processDelete(d, instance),
)
.on(VersiaEntities.User, (u) =>
InboxProcessor.processUser(u, instance),
)
.on(VersiaEntities.Share, (s) =>
InboxProcessor.processShare(s, instance),
)
.on(VersiaEntities.User, (u) => InboxProcessor.processUser(u))
.on(VersiaEntities.Share, (s) => InboxProcessor.processShare(s))
.on(VersiaEntities.Reaction, (r) =>
InboxProcessor.processReaction(r),
InboxProcessor.processReaction(r, instance),
)
.sort(() => {
throw new ApiError(400, "Unknown entity type");
@ -220,9 +229,10 @@ export class InboxProcessor {
*/
private static async processReaction(
reaction: VersiaEntities.Reaction,
sender: Instance,
): Promise<void> {
const author = await User.resolve(new URL(reaction.data.author));
const note = await Note.resolve(new URL(reaction.data.object));
const author = await User.resolve(reaction.author, sender);
const note = await Note.resolve(reaction.object, sender);
if (!author) {
throw new ApiError(404, "Author not found");
@ -241,7 +251,10 @@ export class InboxProcessor {
* @param {VersiaNote} note - The Note entity to process.
* @returns {Promise<void>}
*/
private static async processNote(note: VersiaEntities.Note): Promise<void> {
private static async processNote(
note: VersiaEntities.Note,
sender: Instance,
): Promise<void> {
// If note has a blocked word
if (
Object.values(note.content?.data ?? {})
@ -257,16 +270,20 @@ export class InboxProcessor {
return;
}
await Note.fromVersia(note);
await Note.fromVersia(note, sender);
}
/**
* Handles User entity processing.
*
* @param {VersiaUser} user - The User entity to process.
* @param {string} domain - The domain of the user.
* @returns {Promise<void>}
*/
private static async processUser(user: VersiaEntities.User): Promise<void> {
private static async processUser(
user: VersiaEntities.User,
sender: Instance,
): Promise<void> {
if (
config.validation.filters.username.some((filter) =>
filter.test(user.data.username),
@ -294,7 +311,7 @@ export class InboxProcessor {
return;
}
await User.fromVersia(user);
await User.fromVersia(user, sender);
}
/**
@ -305,9 +322,10 @@ export class InboxProcessor {
*/
private static async processFollowRequest(
follow: VersiaEntities.Follow,
sender: Instance,
): Promise<void> {
const author = await User.resolve(new URL(follow.data.author));
const followee = await User.resolve(new URL(follow.data.followee));
const author = await User.resolve(follow.author, sender);
const followee = await User.resolve(follow.followee, sender);
if (!author) {
throw new ApiError(404, "Author not found");
@ -353,11 +371,10 @@ export class InboxProcessor {
*/
private static async processFollowAccept(
followAccept: VersiaEntities.FollowAccept,
sender: Instance,
): Promise<void> {
const author = await User.resolve(new URL(followAccept.data.author));
const follower = await User.resolve(
new URL(followAccept.data.follower),
);
const author = await User.resolve(followAccept.author, sender);
const follower = await User.resolve(followAccept.follower, sender);
if (!author) {
throw new ApiError(404, "Author not found");
@ -390,11 +407,10 @@ export class InboxProcessor {
*/
private static async processFollowReject(
followReject: VersiaEntities.FollowReject,
sender: Instance,
): Promise<void> {
const author = await User.resolve(new URL(followReject.data.author));
const follower = await User.resolve(
new URL(followReject.data.follower),
);
const author = await User.resolve(followReject.author, sender);
const follower = await User.resolve(followReject.follower, sender);
if (!author) {
throw new ApiError(404, "Author not found");
@ -427,9 +443,10 @@ export class InboxProcessor {
*/
private static async processShare(
share: VersiaEntities.Share,
sender: Instance,
): Promise<void> {
const author = await User.resolve(new URL(share.data.author));
const sharedNote = await Note.resolve(new URL(share.data.shared));
const author = await User.resolve(share.author, sender);
const sharedNote = await Note.resolve(share.shared, sender);
if (!author) {
throw new ApiError(404, "Author not found");
@ -439,7 +456,7 @@ export class InboxProcessor {
throw new ApiError(404, "Shared Note not found");
}
await sharedNote.reblog(author, "public", new URL(share.data.uri));
await sharedNote.reblog(author, "public", share.data.id);
}
/**
@ -450,18 +467,17 @@ export class InboxProcessor {
*/ // JS doesn't allow the use of `delete` as a variable name
public static async processDelete(
delete_: VersiaEntities.Delete,
sender: Instance,
): Promise<void> {
const toDelete = delete_.data.deleted;
const toDelete = delete_.deleted;
const author = delete_.data.author
? await User.resolve(new URL(delete_.data.author))
: null;
const author = await User.resolve(delete_.author, sender);
switch (delete_.data.deleted_type) {
case "Note": {
const note = await Note.fromSql(
eq(Notes.uri, toDelete),
author ? eq(Notes.authorId, author.id) : undefined,
eq(Notes.remoteId, toDelete.id),
eq(Notes.authorId, author.id),
);
if (!note) {
@ -475,7 +491,7 @@ export class InboxProcessor {
return;
}
case "User": {
const userToDelete = await User.resolve(new URL(toDelete));
const userToDelete = await User.resolve(toDelete, sender);
if (!userToDelete) {
throw new ApiError(404, "User to delete not found");
@ -490,8 +506,8 @@ export class InboxProcessor {
}
case "pub.versia:likes/Like": {
const like = await Like.fromSql(
eq(Likes.uri, toDelete),
author ? eq(Likes.likerId, author.id) : undefined,
eq(Likes.remoteId, toDelete.id),
eq(Likes.likerId, author.id),
);
if (!like) {
@ -525,7 +541,10 @@ export class InboxProcessor {
}
const reblog = await Note.fromSql(
and(eq(Notes.uri, toDelete), eq(Notes.authorId, author.id)),
and(
eq(Notes.remoteId, toDelete.id),
eq(Notes.authorId, author.id),
),
);
if (!reblog) {
@ -567,9 +586,10 @@ export class InboxProcessor {
*/
private static async processLikeRequest(
like: VersiaEntities.Like,
sender: Instance,
): Promise<void> {
const author = await User.resolve(new URL(like.data.author));
const likedNote = await Note.resolve(new URL(like.data.liked));
const author = await User.resolve(like.author, sender);
const likedNote = await Note.resolve(like.liked, sender);
if (!author) {
throw new ApiError(404, "Author not found");
@ -579,7 +599,7 @@ export class InboxProcessor {
throw new ApiError(404, "Liked Note not found");
}
await likedNote.like(author, new URL(like.data.uri));
await likedNote.like(author, like.data.id);
}
/**

View file

@ -1,4 +1,4 @@
import type * as VersiaEntities from "@versia/sdk/entities";
import * as VersiaEntities from "@versia/sdk/entities";
import { FederationRequester } from "@versia/sdk/http";
import { config } from "@versia-server/config";
import { and, eq, inArray, isNull, or } from "drizzle-orm";
@ -13,6 +13,7 @@ import {
letter,
} from "magic-regexp";
import { sanitizeHtml, sanitizeHtmlInline } from "@/sanitization";
import { Instance } from "./db/instance.ts";
import { User } from "./db/user.ts";
import { markdownToHtml } from "./markdown.ts";
import { mention } from "./regex.ts";
@ -81,7 +82,14 @@ export const parseMentionsFromText = async (text: string): Promise<User[]> => {
);
if (url) {
const user = await User.resolve(url);
const userEntity = await Instance.federationRequester.fetchSigned(
url,
VersiaEntities.User,
);
const instance = await Instance.resolve(url.hostname);
const user = await User.fromVersia(userEntity, instance);
if (user) {
finalList.push(user);

View file

@ -17,7 +17,7 @@ export const getFetchWorker = (): Worker<FetchJobData, void, FetchJobType> =>
await job.log(`Fetching instance metadata from [${uri}]`);
// Check if exists
const host = new URL(uri).host;
const host = new URL(uri).hostname;
const existingInstance = await Instance.fromSql(
eq(Instances.baseUrl, host),
@ -37,7 +37,7 @@ export const getFetchWorker = (): Worker<FetchJobData, void, FetchJobType> =>
return;
}
await Instance.resolve(new URL(uri));
await Instance.resolve(host);
await job.log(
`✔ Finished fetching instance metadata from [${uri}]`,

View file

@ -2,7 +2,6 @@ import { config } from "@versia-server/config";
import { Worker } from "bullmq";
import { ApiError } from "../../api-error.ts";
import { Instance } from "../../db/instance.ts";
import { User } from "../../db/user.ts";
import { InboxProcessor } from "../../inbox-processor.ts";
import { connection } from "../../redis.ts";
import { type InboxJobData, InboxJobType, inboxQueue } from "./queue.ts";
@ -72,51 +71,29 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
"versia-signed-by": string;
};
const sender = await User.resolve(new URL(signedBy));
const sender = await Instance.resolve(signedBy);
if (!(sender || signedBy.startsWith("instance "))) {
if (!sender) {
await job.log(
`Could not resolve sender URI [${signedBy}]`,
`Could not resolve sender domain [${signedBy}]`,
);
return;
}
if (sender?.local) {
throw new Error(
"Cannot process federation requests from local users",
);
}
const remoteInstance = sender
? await Instance.fromUser(sender)
: await Instance.resolveFromHost(
signedBy.split(" ")[1],
);
if (!remoteInstance) {
await job.log("Could not resolve the remote instance.");
return;
}
await job.log(
`Entity [${data.id}] is from remote instance [${remoteInstance.data.baseUrl}]`,
`Entity [${data.id}] is from remote instance [${sender.data.baseUrl}]`,
);
if (!remoteInstance.data.publicKey?.key) {
if (!sender.data.publicKey?.key) {
throw new Error(
`Instance ${remoteInstance.data.baseUrl} has no public key stored in database`,
`Instance ${sender.data.baseUrl} has no public key stored in database`,
);
}
const key = await crypto.subtle.importKey(
"spki",
Buffer.from(
sender?.data.publicKey ??
remoteInstance.data.publicKey.key,
"base64",
),
Buffer.from(sender.data.publicKey.key, "base64"),
"Ed25519",
false,
["verify"],
@ -127,7 +104,7 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
req,
data,
{
instance: remoteInstance,
instance: sender,
key,
},
undefined,
@ -147,10 +124,10 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
);
await job.log(
`Sending error message to instance [${remoteInstance.data.baseUrl}]`,
`Sending error message to instance [${sender.data.baseUrl}]`,
);
await remoteInstance.sendMessage(
await sender.sendMessage(
`Failed processing entity [${
data.uri
}] delivered to inbox. Returned error:\n\n${JSON.stringify(

View file

@ -0,0 +1,14 @@
ALTER TABLE "Likes" DROP CONSTRAINT "Likes_uri_unique";--> statement-breakpoint
ALTER TABLE "Notes" DROP CONSTRAINT "Notes_uri_unique";--> statement-breakpoint
ALTER TABLE "Users" DROP CONSTRAINT "Users_uri_unique";--> statement-breakpoint
DROP INDEX "Users_uri_index";--> statement-breakpoint
ALTER TABLE "Likes" ADD COLUMN "remote_id" text;--> statement-breakpoint
ALTER TABLE "Notes" ADD COLUMN "remote_id" text;--> statement-breakpoint
ALTER TABLE "Reaction" ADD COLUMN "remote_id" text;--> statement-breakpoint
ALTER TABLE "Users" ADD COLUMN "remote_id" text;--> statement-breakpoint
ALTER TABLE "Likes" DROP COLUMN "uri";--> statement-breakpoint
ALTER TABLE "Notes" DROP COLUMN "uri";--> statement-breakpoint
ALTER TABLE "Users" DROP COLUMN "uri";--> statement-breakpoint
ALTER TABLE "Users" DROP COLUMN "endpoints";--> statement-breakpoint
ALTER TABLE "Users" DROP COLUMN "public_key";--> statement-breakpoint
ALTER TABLE "Users" DROP COLUMN "private_key";

File diff suppressed because it is too large Load diff

View file

@ -379,6 +379,13 @@
"when": 1765422160004,
"tag": "0053_lively_hellfire_club",
"breakpoints": true
},
{
"idx": 54,
"version": "7",
"when": 1771983340896,
"tag": "0054_good_madelyne_pryor",
"breakpoints": true
}
]
}

View file

@ -137,6 +137,7 @@ export const PushSubscriptionsRelations = relations(
export const Reactions = pgTable("Reaction", {
id: id(),
uri: uri(),
remoteId: text("remote_id"),
// Emoji ID is nullable, in which case it is a text emoji, and the emojiText field is used
emojiId: uuid("emojiId").references(() => Emojis.id, {
onDelete: "cascade",
@ -244,7 +245,7 @@ export const Markers = pgTable("Markers", {
export const Likes = pgTable("Likes", {
id: id(),
uri: uri(),
remoteId: text("remote_id"),
likerId: uuid("likerId")
.notNull()
.references(() => Users.id, {
@ -472,7 +473,7 @@ export const NotificationsRelations = relations(Notifications, ({ one }) => ({
export const Notes = pgTable("Notes", {
id: id(),
uri: uri(),
remoteId: text("remote_id"),
authorId: uuid("authorId")
.notNull()
.references(() => Users.id, {
@ -600,7 +601,7 @@ export const Users = pgTable(
"Users",
{
id: id(),
uri: uri(),
remoteId: text("remote_id"),
username: text("username").notNull(),
displayName: text("display_name"),
password: text("password"),
@ -615,15 +616,6 @@ export const Users = pgTable(
value: z.infer<typeof TextContentFormatSchema>;
}[]
>(),
endpoints: jsonb("endpoints").$type<Partial<{
dislikes?: string;
featured: string;
likes?: string;
followers: string;
following: string;
inbox: string;
outbox: string;
}> | null>(),
source: jsonb("source").$type<z.infer<typeof Source>>(),
avatarId: uuid("avatarId").references(() => Medias.id, {
onDelete: "set null",
@ -646,8 +638,6 @@ export const Users = pgTable(
.notNull(),
isIndexable: boolean("is_indexable").default(true).notNull(),
sanctions: text("sanctions").array(),
publicKey: text("public_key").notNull(),
privateKey: text("private_key"),
instanceId: uuid("instanceId").references(() => Instances.id, {
onDelete: "cascade",
onUpdate: "cascade",
@ -656,11 +646,7 @@ export const Users = pgTable(
.default(false)
.notNull(),
},
(table) => [
uniqueIndex().on(table.uri),
index().on(table.username),
uniqueIndex().on(table.email),
],
(table) => [index().on(table.username), uniqueIndex().on(table.email)],
);
export const UsersRelations = relations(Users, ({ many, one }) => ({

View file

@ -13,15 +13,15 @@ const base64ToArrayBuffer = (base64: string): ArrayBuffer =>
* Signs a request using the Ed25519 algorithm, according to the [**Versia**](https://versia.pub/signatures) specification.
*
* @see https://versia.pub/signatures
* @param privateKey - Private key of the User that is signing the request.
* @param authorUrl - URL of the User that is signing the request.
* @param privateKey - Private key of the instance that is signing the request.
* @param instance - URL of the instance that is signing the request.
* @param req - Request to sign.
* @param timestamp - (optional) Timestamp of the request.
* @returns The signed request.
*/
export const sign = async (
privateKey: CryptoKey,
authorUrl: URL,
instance: URL,
req: Request,
timestamp = new Date(),
): Promise<Request> => {
@ -48,7 +48,7 @@ export const sign = async (
...req.headers,
"Versia-Signature": signatureBase64,
"Versia-Signed-At": String(timestampSecs),
"Versia-Signed-By": authorUrl.href,
"Versia-Signed-By": instance.hostname,
},
});

View file

@ -1,7 +1,7 @@
import type { z } from "zod";
import { DeleteSchema } from "../schemas/delete.ts";
import type { JSONObject } from "../types.ts";
import { Entity } from "./entity.ts";
import { Entity, Reference } from "./entity.ts";
export class Delete extends Entity {
public static override name = "Delete";
@ -10,6 +10,14 @@ export class Delete extends Entity {
super(data);
}
public get author(): Reference {
return Reference.fromString(this.data.author);
}
public get deleted(): Reference {
return Reference.fromString(this.data.deleted);
}
public static override fromJSON(json: JSONObject): Promise<Delete> {
return DeleteSchema.parseAsync(json).then((u) => new Delete(u));
}

View file

@ -15,3 +15,31 @@ export class Entity {
return this.data;
}
}
export class Reference {
public constructor(
public id: string,
public domain?: string,
) {}
public static fromString(str: string): Reference {
// Expect format: domain:id or id (if domain is the local instance)
// Handle IPv6 addresses in brackets
const chunks = str.split(":");
if (chunks.length === 2) {
return new Reference(chunks[1], chunks[0]);
}
if (chunks.length > 2) {
const domain = chunks.slice(0, -1).join(":");
const id = chunks.at(-1) as string;
return new Reference(id, domain);
}
return new Reference(str);
}
public toString(): string {
return this.domain ? `${this.domain}:${this.id}` : this.id;
}
}

View file

@ -1,7 +1,7 @@
import type { z } from "zod";
import { DislikeSchema, LikeSchema } from "../../schemas/extensions/likes.ts";
import type { JSONObject } from "../../types.ts";
import { Entity } from "../entity.ts";
import { Entity, Reference } from "../entity.ts";
export class Like extends Entity {
public static override name = "pub.versia:likes/Like";
@ -10,6 +10,14 @@ export class Like extends Entity {
super(data);
}
public get author(): Reference {
return Reference.fromString(this.data.author);
}
public get liked(): Reference {
return Reference.fromString(this.data.liked);
}
public static override fromJSON(json: JSONObject): Promise<Like> {
return LikeSchema.parseAsync(json).then((u) => new Like(u));
}
@ -22,6 +30,14 @@ export class Dislike extends Entity {
super(data);
}
public get author(): Reference {
return Reference.fromString(this.data.author);
}
public get disliked(): Reference {
return Reference.fromString(this.data.disliked);
}
public static override fromJSON(json: JSONObject): Promise<Dislike> {
return DislikeSchema.parseAsync(json).then((u) => new Dislike(u));
}

View file

@ -1,7 +1,7 @@
import type { z } from "zod";
import { VoteSchema } from "../../schemas/extensions/polls.ts";
import type { JSONObject } from "../../types.ts";
import { Entity } from "../entity.ts";
import { Entity, Reference } from "../entity.ts";
export class Vote extends Entity {
public static override name = "pub.versia:polls/Vote";
@ -10,6 +10,14 @@ export class Vote extends Entity {
super(data);
}
public get author(): Reference {
return Reference.fromString(this.data.author);
}
public get poll(): Reference {
return Reference.fromString(this.data.poll);
}
public static override fromJSON(json: JSONObject): Promise<Vote> {
return VoteSchema.parseAsync(json).then((u) => new Vote(u));
}

View file

@ -1,7 +1,7 @@
import type { z } from "zod";
import { ReactionSchema } from "../../schemas/extensions/reactions.ts";
import type { JSONObject } from "../../types.ts";
import { Entity } from "../entity.ts";
import { Entity, Reference } from "../entity.ts";
export class Reaction extends Entity {
public static override name = "pub.versia:reactions/Reaction";
@ -10,6 +10,14 @@ export class Reaction extends Entity {
super(data);
}
public get author(): Reference {
return Reference.fromString(this.data.author);
}
public get object(): Reference {
return Reference.fromString(this.data.object);
}
public static override fromJSON(json: JSONObject): Promise<Reaction> {
return ReactionSchema.parseAsync(json).then((u) => new Reaction(u));
}

View file

@ -1,7 +1,7 @@
import type { z } from "zod";
import { ReportSchema } from "../../schemas/extensions/reports.ts";
import type { JSONObject } from "../../types.ts";
import { Entity } from "../entity.ts";
import { Entity, Reference } from "../entity.ts";
export class Report extends Entity {
public static override name = "pub.versia:reports/Report";
@ -10,6 +10,14 @@ export class Report extends Entity {
super(data);
}
public get author(): Reference | null {
return this.data.author ? Reference.fromString(this.data.author) : null;
}
public get reported(): Reference[] {
return this.data.reported.map((r) => Reference.fromString(r));
}
public static override fromJSON(json: JSONObject): Promise<Report> {
return ReportSchema.parseAsync(json).then((u) => new Report(u));
}

View file

@ -1,7 +1,7 @@
import type { z } from "zod";
import { ShareSchema } from "../../schemas/extensions/share.ts";
import type { JSONObject } from "../../types.ts";
import { Entity } from "../entity.ts";
import { Entity, Reference } from "../entity.ts";
export class Share extends Entity {
public static override name = "pub.versia:share/Share";
@ -10,6 +10,14 @@ export class Share extends Entity {
super(data);
}
public get author(): Reference {
return Reference.fromString(this.data.author);
}
public get shared(): Reference {
return Reference.fromString(this.data.shared);
}
public static override fromJSON(json: JSONObject): Promise<Share> {
return ShareSchema.parseAsync(json).then((u) => new Share(u));
}

View file

@ -6,7 +6,7 @@ import {
UnfollowSchema,
} from "../schemas/follow.ts";
import type { JSONObject } from "../types.ts";
import { Entity } from "./entity.ts";
import { Entity, Reference } from "./entity.ts";
export class Follow extends Entity {
public static override name = "Follow";
@ -15,6 +15,14 @@ export class Follow extends Entity {
super(data);
}
public get author(): Reference {
return Reference.fromString(this.data.author);
}
public get followee(): Reference {
return Reference.fromString(this.data.followee);
}
public static override fromJSON(json: JSONObject): Promise<Follow> {
return FollowSchema.parseAsync(json).then((u) => new Follow(u));
}
@ -29,6 +37,14 @@ export class FollowAccept extends Entity {
super(data);
}
public get author(): Reference {
return Reference.fromString(this.data.author);
}
public get follower(): Reference {
return Reference.fromString(this.data.follower);
}
public static override fromJSON(json: JSONObject): Promise<FollowAccept> {
return FollowAcceptSchema.parseAsync(json).then(
(u) => new FollowAccept(u),
@ -45,6 +61,14 @@ export class FollowReject extends Entity {
super(data);
}
public get author(): Reference {
return Reference.fromString(this.data.author);
}
public get follower(): Reference {
return Reference.fromString(this.data.follower);
}
public static override fromJSON(json: JSONObject): Promise<FollowReject> {
return FollowRejectSchema.parseAsync(json).then(
(u) => new FollowReject(u),
@ -59,6 +83,14 @@ export class Unfollow extends Entity {
super(data);
}
public get author(): Reference {
return Reference.fromString(this.data.author);
}
public get followee(): Reference {
return Reference.fromString(this.data.followee);
}
public static override fromJSON(json: JSONObject): Promise<Unfollow> {
return UnfollowSchema.parseAsync(json).then((u) => new Unfollow(u));
}

View file

@ -8,7 +8,7 @@ export {
VideoContentFormat,
} from "./contentformat.ts";
export { Delete } from "./delete.ts";
export { Entity } from "./entity.ts";
export { Entity, Reference } from "./entity.ts";
export { Dislike, Like } from "./extensions/likes.ts";
export { Vote } from "./extensions/polls.ts";
export { Reaction } from "./extensions/reactions.ts";

View file

@ -2,7 +2,7 @@ import type { z } from "zod";
import { NoteSchema } from "../schemas/note.ts";
import type { JSONObject } from "../types.ts";
import { NonTextContentFormat, TextContentFormat } from "./contentformat.ts";
import { Entity } from "./entity.ts";
import { Entity, Reference } from "./entity.ts";
export class Note extends Entity {
public static override name = "Note";
@ -15,6 +15,35 @@ export class Note extends Entity {
return NoteSchema.parseAsync(json).then((n) => new Note(n));
}
public get author(): Reference {
return Reference.fromString(this.data.author);
}
public get group(): Reference | null {
if (
!this.data.group ||
["public", "followers"].includes(this.data.group)
) {
return null;
}
return Reference.fromString(this.data.group);
}
public get mentions(): Reference[] {
return this.data.mentions.map((m) => Reference.fromString(m));
}
public get quotes(): Reference | null {
return this.data.quotes ? Reference.fromString(this.data.quotes) : null;
}
public get repliesTo(): Reference | null {
return this.data.replies_to
? Reference.fromString(this.data.replies_to)
: null;
}
public get attachments(): NonTextContentFormat[] {
return (
this.data.attachments?.map((a) => new NonTextContentFormat(a)) ?? []

View file

@ -1,10 +1,12 @@
import { sign } from "./crypto.ts";
import { Collection, URICollection } from "./entities/collection.ts";
import type { Entity } from "./entities/entity.ts";
import type { Entity, Reference } from "./entities/entity.ts";
import { InstanceMetadata } from "./entities/instancemetadata.ts";
import { homepage, version } from "./package.json" with { type: "json" };
import { WebFingerSchema } from "./schemas/webfinger.ts";
const DEFAULT_UA = `VersiaFederationClient/${version} (+${homepage})`;
const CONTENT_TYPE = "application/vnd.versia+json";
/**
* A class that handles fetching Versia entities
@ -22,22 +24,22 @@ const DEFAULT_UA = `VersiaFederationClient/${version} (+${homepage})`;
export class FederationRequester {
public constructor(
private readonly privateKey: CryptoKey,
private readonly authorUrl: URL,
private readonly instance: URL,
) {}
public async fetchEntity<T extends typeof Entity>(
public async fetchSigned<T extends typeof Entity>(
url: URL,
expectedType: T,
entityType: T,
): Promise<InstanceType<T>> {
const req = new Request(url, {
method: "GET",
headers: {
Accept: "application/json",
Accept: CONTENT_TYPE,
"User-Agent": DEFAULT_UA,
},
});
const finalReq = await sign(this.privateKey, this.authorUrl, req);
const finalReq = await sign(this.privateKey, this.instance, req);
const res = await fetch(finalReq);
@ -49,79 +51,116 @@ export class FederationRequester {
const contentType = res.headers.get("Content-Type");
if (!contentType?.includes("application/json")) {
if (
!(
contentType?.includes("application/vnd.versia+json") &&
contentType?.includes("charset=utf-8")
)
) {
throw new Error(
`Expected JSON response from ${url.toString()}, got "${contentType}"`,
`Expected application/vnd.versia+json; charset=utf-8 response from ${url.toString()}, got "${contentType}"`,
);
}
const jsonData = await res.json();
const type = jsonData.type;
if (type && type !== expectedType.name) {
if (
(!type || type !== entityType.name) &&
// (URI)Collections don't have a type field
![Collection, URICollection].some((et) => et === entityType)
) {
throw new Error(
`Expected entity type "${expectedType.name}", got "${type}"`,
`Expected entity type "${entityType.name}", got "${type}"`,
);
}
const entity = await expectedType.fromJSON(jsonData);
const entity = await entityType.fromJSON(jsonData);
return entity as InstanceType<T>;
}
public async postEntity(url: URL, entity: Entity): Promise<Response> {
public fetchEntity<T extends typeof Entity>(
reference: Reference,
entityType: T,
): Promise<InstanceType<T>> {
const url = new URL(
`/.versia/v0.6/entities/${encodeURIComponent(
entityType.name,
)}/${encodeURIComponent(reference.id)}`,
`https://${reference.domain}`,
);
return this.fetchSigned(url, entityType);
}
public async postEntity(domain: string, entity: Entity): Promise<Response> {
const url = new URL("/.versia/v0.6/inbox", `https://${domain}`);
const req = new Request(url, {
method: "POST",
headers: {
Accept: "application/json",
Accept: CONTENT_TYPE,
"User-Agent": DEFAULT_UA,
"Content-Type": "application/json; charset=utf-8",
"Content-Type": "application/vnd.versia+json; charset=utf-8",
},
body: JSON.stringify(entity.toJSON()),
});
const finalReq = await sign(this.privateKey, this.authorUrl, req);
const finalReq = await sign(this.privateKey, this.instance, req);
return fetch(finalReq);
}
/**
* Recursively go through a Collection of entities until reaching the end
* @param url URL to reach the Collection
* @param expectedType
* @param reference Entity Reference
* @param entityType
* @param collectionItemType
* @param options.limit Limit the number of entities to fetch
*/
public async resolveCollection<T extends typeof Entity>(
url: URL,
expectedType: T,
public async resolveCollection<
E extends typeof Entity,
T extends typeof Entity,
>(
reference: Reference,
collectionName: string,
entityType: E,
collectionItemType: T,
options?: {
limit?: number;
},
): Promise<InstanceType<T>[]> {
const url = new URL(
`/.versia/v0.6/entities/${encodeURIComponent(
entityType.name,
)}/${encodeURIComponent(reference.id)}/collections/${encodeURIComponent(
collectionName,
)}`,
`https://${reference.domain}`,
);
const entities: InstanceType<T>[] = [];
let nextUrl: URL | null = url;
let limit = options?.limit ?? Number.POSITIVE_INFINITY;
while (nextUrl && limit > 0) {
const collection: Collection = await this.fetchEntity(
nextUrl,
Collection,
);
let collection = await this.fetchSigned(url, Collection);
const total = collection.data.total;
for (const entity of collection.data.items) {
if (entity.type === expectedType.name) {
entities.push(
(await expectedType.fromJSON(
entity,
)) as InstanceType<T>,
);
}
while (collection && limit > 0) {
entities.push(
...collection.data.items.map(
(item) =>
collectionItemType.fromJSON(item) as InstanceType<T>,
),
);
limit -= collection.data.items.length;
if (entities.length >= total) {
break;
}
nextUrl = collection.data.next
? new URL(collection.data.next)
: null;
limit -= collection.data.items.length;
url.searchParams.set("offset", entities.length.toString());
collection = await this.fetchSigned(url, Collection);
}
return entities;
@ -129,33 +168,46 @@ export class FederationRequester {
/**
* Recursively go through a URICollection of entities until reaching the end
* @param url URL to reach the Collection
* @param reference Entity Reference
* @param entityType
* @param options.limit Limit the number of entities to fetch
*/
public async resolveURICollection(
url: URL,
public async resolveURICollection<E extends typeof Entity>(
reference: Reference,
collectionName: string,
entityType: E,
options?: {
limit?: number;
},
): Promise<URL[]> {
const entities: string[] = [];
let nextUrl: URL | null = url;
): Promise<string[]> {
const url = new URL(
`/.versia/v0.6/entities/${encodeURIComponent(
entityType.name,
)}/${encodeURIComponent(reference.id)}/collections/${encodeURIComponent(
collectionName,
)}`,
`https://${reference.domain}`,
);
const uris: string[] = [];
let limit = options?.limit ?? Number.POSITIVE_INFINITY;
while (nextUrl && limit > 0) {
const collection: URICollection = await this.fetchEntity(
nextUrl,
URICollection,
);
let collection = await this.fetchSigned(url, URICollection);
const total = collection.data.total;
entities.push(...collection.data.items);
nextUrl = collection.data.next
? new URL(collection.data.next)
: null;
while (collection && limit > 0) {
uris.push(...collection.data.items);
limit -= collection.data.items.length;
if (uris.length >= total) {
break;
}
url.searchParams.set("offset", uris.length.toString());
collection = await this.fetchSigned(url, URICollection);
}
return entities.map((u) => new URL(u));
return uris;
}
/**
@ -164,21 +216,21 @@ export class FederationRequester {
*/
public static async resolveWebFinger(
username: string,
hostname: string,
contentType = "application/json",
serverUrl = `https://${hostname}`,
domain: string,
contentType = "application/vnd.versia+json",
serverUrl = `https://${domain}`,
): Promise<URL | null> {
const res = await fetch(
new URL(
`/.well-known/webfinger?${new URLSearchParams({
resource: `acct:${username}@${hostname}`,
resource: `acct:${username}@${domain}`,
})}`,
serverUrl,
),
{
method: "GET",
headers: {
Accept: "application/json",
Accept: "application/jrd+json, application/json",
"User-Agent": DEFAULT_UA,
},
},
@ -204,4 +256,57 @@ export class FederationRequester {
return new URL(selfLink.href);
}
/**
* Resolve instance metadata from a domain
*
* Fetches well-known for version discovery, and if versia is supported, fetches the instance metadata
* @param domain
*/
public async resolveInstance(domain: string): Promise<InstanceMetadata> {
const wellKnownUrl = new URL(
"/.well-known/versia",
`https://${domain}`,
);
const wellKnownRes = await fetch(wellKnownUrl, {
method: "GET",
headers: {
Accept: "application/json",
"User-Agent": DEFAULT_UA,
},
});
if (!wellKnownRes.ok) {
throw new Error(
`Failed to fetch well-known from ${wellKnownUrl.toString()}: got HTTP code ${wellKnownRes.status} with body "${await wellKnownRes.text()}"`,
);
}
const wellKnownData = await wellKnownRes.json();
if (
!(
wellKnownData.versions &&
Array.isArray(wellKnownData.versions) &&
wellKnownData.versions.includes("0.6.0")
)
) {
throw new Error(
`Instance at ${domain} does not support Versia v0.6`,
);
}
const metadataUrl = new URL(
"/.versia/v0.6/instance",
`https://${domain}`,
);
const metadataRes = await this.fetchSigned(
metadataUrl,
InstanceMetadata,
);
return metadataRes;
}
}

View file

@ -7,7 +7,7 @@
".": "./inbox-processor.ts",
"./http": "./http.ts",
"./crypto": "./crypto.ts",
"./entities": "./entities.ts",
"./schemas": "./schemas.ts"
"./entities": "./entities/index.ts",
"./schemas": "./schemas/index.ts"
}
}

View file

@ -1,16 +1,13 @@
import { z } from "zod";
import { u64, url } from "./common.ts";
import { u64 } from "./common.ts";
import { ReferenceSchema } from "./entity.ts";
export const CollectionSchema = z.strictObject({
author: url.nullable(),
first: url,
last: url,
author: ReferenceSchema.nullable(),
total: u64,
next: url.nullable(),
previous: url.nullable(),
items: z.array(z.any()),
});
export const URICollectionSchema = CollectionSchema.extend({
items: z.array(url),
items: z.array(ReferenceSchema),
});

View file

@ -2,26 +2,6 @@ import { types } from "mime-types";
import { z } from "zod";
import { f64, u64 } from "./common.ts";
const hashSizes = {
sha256: 64,
sha512: 128,
"sha3-256": 64,
"sha3-512": 128,
"blake2b-256": 64,
"blake2b-512": 128,
"blake3-256": 64,
"blake3-512": 128,
md5: 32,
sha1: 40,
sha224: 56,
sha384: 96,
"sha3-224": 56,
"sha3-384": 96,
"blake2s-256": 64,
"blake2s-512": 128,
"blake3-224": 56,
"blake3-384": 96,
};
const allMimeTypes = Object.values(types) as [string, ...string[]];
const textMimeTypes = Object.values(types).filter((v) =>
v.startsWith("text/"),
@ -46,16 +26,7 @@ export const ContentFormatSchema = z.partialRecord(
remote: z.boolean(),
description: z.string().nullish(),
size: u64.nullish(),
hash: z
.strictObject(
Object.fromEntries(
Object.entries(hashSizes).map(([k, v]) => [
k,
z.string().length(v).nullish(),
]),
),
)
.nullish(),
hash: z.hash("sha256").nullish(),
thumbhash: z.string().nullish(),
width: u64.nullish(),
height: u64.nullish(),

View file

@ -1,11 +1,9 @@
import { z } from "zod";
import { url } from "./common.ts";
import { EntitySchema } from "./entity.ts";
import { ReferenceSchema, TransientEntitySchema } from "./entity.ts";
export const DeleteSchema = EntitySchema.extend({
uri: z.null().optional(),
export const DeleteSchema = TransientEntitySchema.extend({
type: z.literal("Delete"),
author: url.nullable(),
author: ReferenceSchema,
deleted_type: z.string(),
deleted: url,
deleted: ReferenceSchema,
});

View file

@ -1,6 +1,5 @@
import { z } from "zod";
import { isISOString } from "../regex.ts";
import { url } from "./common.ts";
import { CustomEmojiExtensionSchema } from "./extensions/emojis.ts";
export const ExtensionPropertySchema = z
@ -10,14 +9,26 @@ export const ExtensionPropertySchema = z
})
.catchall(z.any());
export const ReferenceSchema = z.string();
export const EntitySchema = z.strictObject({
// biome-ignore lint/style/useNamingConvention: required for JSON schema
$schema: z.url().nullish(),
id: z.string().max(512),
id: z
.string()
.max(512)
.regex(
// a-z, A-Z, 0-9, - and _
/^[A-Za-z0-9\-_]+$/,
"can only contain alphanumeric characters, hyphens and underscores",
),
created_at: z
.string()
.refine((v) => isISOString(v), "must be a valid ISO8601 datetime"),
uri: url,
.refine((v) => isISOString(v), "must be a valid RFC 3339 datetime"),
type: z.string(),
extensions: ExtensionPropertySchema.nullish(),
});
export const TransientEntitySchema = EntitySchema.extend({
id: z.null().optional(),
});

View file

@ -1,41 +1,38 @@
import { z } from "zod";
import { url } from "../common.ts";
import { TextContentFormatSchema } from "../contentformat.ts";
import { EntitySchema } from "../entity.ts";
import {
EntitySchema,
ReferenceSchema,
TransientEntitySchema,
} from "../entity.ts";
export const GroupSchema = EntitySchema.extend({
type: z.literal("pub.versia:groups/Group"),
name: TextContentFormatSchema.nullish(),
description: TextContentFormatSchema.nullish(),
open: z.boolean().nullish(),
members: url,
notes: url.nullish(),
open: z.boolean(),
});
export const GroupSubscribeSchema = EntitySchema.extend({
export const GroupSubscribeSchema = TransientEntitySchema.extend({
type: z.literal("pub.versia:groups/Subscribe"),
uri: z.null().optional(),
subscriber: url,
group: url,
subscriber: ReferenceSchema,
group: ReferenceSchema,
});
export const GroupUnsubscribeSchema = EntitySchema.extend({
export const GroupUnsubscribeSchema = TransientEntitySchema.extend({
type: z.literal("pub.versia:groups/Unsubscribe"),
uri: z.null().optional(),
subscriber: url,
group: url,
subscriber: ReferenceSchema,
group: ReferenceSchema,
});
export const GroupSubscribeAcceptSchema = EntitySchema.extend({
export const GroupSubscribeAcceptSchema = TransientEntitySchema.extend({
type: z.literal("pub.versia:groups/SubscribeAccept"),
uri: z.null().optional(),
subscriber: url,
group: url,
subscriber: ReferenceSchema,
group: ReferenceSchema,
});
export const GroupSubscribeRejectSchema = EntitySchema.extend({
export const GroupSubscribeRejectSchema = TransientEntitySchema.extend({
type: z.literal("pub.versia:groups/SubscribeReject"),
uri: z.null().optional(),
subscriber: url,
group: url,
subscriber: ReferenceSchema,
group: ReferenceSchema,
});

View file

@ -1,15 +1,14 @@
import { z } from "zod";
import { url } from "../common.ts";
import { EntitySchema } from "../entity.ts";
import { EntitySchema, ReferenceSchema } from "../entity.ts";
export const LikeSchema = EntitySchema.extend({
type: z.literal("pub.versia:likes/Like"),
author: url,
liked: url,
author: ReferenceSchema,
liked: ReferenceSchema,
});
export const DislikeSchema = EntitySchema.extend({
type: z.literal("pub.versia:likes/Dislike"),
author: url,
disliked: url,
author: ReferenceSchema,
disliked: ReferenceSchema,
});

View file

@ -1,15 +1,13 @@
import { z } from "zod";
import { url } from "../common.ts";
import { EntitySchema } from "../entity.ts";
import { ReferenceSchema, TransientEntitySchema } from "../entity.ts";
export const MigrationSchema = EntitySchema.extend({
export const MigrationSchema = TransientEntitySchema.extend({
type: z.literal("pub.versia:migration/Migration"),
uri: z.null().optional(),
author: url,
destination: url,
author: ReferenceSchema,
destination: ReferenceSchema,
});
export const MigrationExtensionSchema = z.strictObject({
previous: url,
new: url.nullish(),
previous: ReferenceSchema,
new: ReferenceSchema.nullish(),
});

View file

@ -1,13 +1,13 @@
import { z } from "zod";
import { isISOString } from "../../regex.ts";
import { u64, url } from "../common.ts";
import { u64 } from "../common.ts";
import { TextContentFormatSchema } from "../contentformat.ts";
import { EntitySchema } from "../entity.ts";
import { EntitySchema, ReferenceSchema } from "../entity.ts";
export const VoteSchema = EntitySchema.extend({
type: z.literal("pub.versia:polls/Vote"),
author: url,
poll: url,
author: ReferenceSchema,
poll: ReferenceSchema,
option: u64,
});
@ -17,6 +17,6 @@ export const PollExtensionSchema = z.strictObject({
multiple_choice: z.boolean(),
expires_at: z
.string()
.refine((v) => isISOString(v), "must be a valid ISO8601 datetime")
.refine((v) => isISOString(v), "must be a valid RFC 3339 datetime")
.nullish(),
});

View file

@ -1,10 +1,9 @@
import { z } from "zod";
import { url } from "../common.ts";
import { EntitySchema } from "../entity.ts";
import { EntitySchema, ReferenceSchema } from "../entity.ts";
export const ReactionSchema = EntitySchema.extend({
type: z.literal("pub.versia:reactions/Reaction"),
author: url,
object: url,
author: ReferenceSchema,
object: ReferenceSchema,
content: z.string().min(1).max(256),
});

View file

@ -1,12 +1,10 @@
import { z } from "zod";
import { url } from "../common.ts";
import { EntitySchema } from "../entity.ts";
import { ReferenceSchema, TransientEntitySchema } from "../entity.ts";
export const ReportSchema = EntitySchema.extend({
export const ReportSchema = TransientEntitySchema.extend({
type: z.literal("pub.versia:reports/Report"),
uri: z.null().optional(),
author: url.nullish(),
reported: z.array(url),
author: ReferenceSchema.nullish(),
reported: z.array(ReferenceSchema),
tags: z.array(z.string()),
comment: z
.string()

View file

@ -1,9 +1,8 @@
import { z } from "zod";
import { url } from "../common.ts";
import { EntitySchema } from "../entity.ts";
import { EntitySchema, ReferenceSchema } from "../entity.ts";
export const ShareSchema = EntitySchema.extend({
type: z.literal("pub.versia:share/Share"),
author: url,
shared: url,
author: ReferenceSchema,
shared: ReferenceSchema,
});

View file

@ -7,11 +7,11 @@
import { z } from "zod";
import { ianaTimezoneRegex, isISOString } from "../../regex.ts";
import { url } from "../common.ts";
import {
AudioContentFormatSchema,
ImageContentFormatSchema,
} from "../contentformat.ts";
import { ReferenceSchema } from "../entity.ts";
export const VanityExtensionSchema = z.strictObject({
avatar_overlays: z.array(ImageContentFormatSchema).nullish(),
@ -21,24 +21,21 @@ export const VanityExtensionSchema = z.strictObject({
pronouns: z.record(
z.string(),
z.array(
z.union([
z.strictObject({
subject: z.string(),
object: z.string(),
dependent_possessive: z.string(),
independent_possessive: z.string(),
reflexive: z.string(),
}),
z.string(),
]),
z.strictObject({
subject: z.string(),
object: z.string(),
dependent_possessive: z.string(),
independent_possessive: z.string(),
reflexive: z.string(),
}),
),
),
birthday: z
.string()
.refine((v) => isISOString(v), "must be a valid ISO8601 datetime")
.refine((v) => isISOString(v), "must be a valid RFC 3339 datetime")
.nullish(),
location: z.string().nullish(),
aliases: z.array(url).nullish(),
aliases: z.array(ReferenceSchema).nullish(),
timezone: z
.string()
.regex(ianaTimezoneRegex, "must be a valid IANA timezone")

View file

@ -1,31 +1,26 @@
import { z } from "zod";
import { url } from "./common.ts";
import { EntitySchema } from "./entity.ts";
import { ReferenceSchema, TransientEntitySchema } from "./entity.ts";
export const FollowSchema = EntitySchema.extend({
export const FollowSchema = TransientEntitySchema.extend({
type: z.literal("Follow"),
uri: z.null().optional(),
author: url,
followee: url,
author: ReferenceSchema,
followee: ReferenceSchema,
});
export const FollowAcceptSchema = EntitySchema.extend({
export const FollowAcceptSchema = TransientEntitySchema.extend({
type: z.literal("FollowAccept"),
uri: z.null().optional(),
author: url,
follower: url,
author: ReferenceSchema,
follower: ReferenceSchema,
});
export const FollowRejectSchema = EntitySchema.extend({
export const FollowRejectSchema = TransientEntitySchema.extend({
type: z.literal("FollowReject"),
uri: z.null().optional(),
author: url,
follower: url,
author: ReferenceSchema,
follower: ReferenceSchema,
});
export const UnfollowSchema = EntitySchema.extend({
export const UnfollowSchema = TransientEntitySchema.extend({
type: z.literal("Unfollow"),
uri: z.null().optional(),
author: url,
followee: url,
author: ReferenceSchema,
followee: ReferenceSchema,
});

View file

@ -8,7 +8,11 @@ export {
VideoContentFormatSchema,
} from "./contentformat.ts";
export { DeleteSchema } from "./delete.ts";
export { EntitySchema } from "./entity.ts";
export {
EntitySchema,
ReferenceSchema,
TransientEntitySchema,
} from "./entity.ts";
export { DislikeSchema, LikeSchema } from "./extensions/likes.ts";
export { VoteSchema } from "./extensions/polls.ts";
export { ReactionSchema } from "./extensions/reactions.ts";

View file

@ -1,13 +1,10 @@
import { z } from "zod";
import { extensionRegex, semverRegex } from "../regex.ts";
import { url } from "./common.ts";
import { ImageContentFormatSchema } from "./contentformat.ts";
import { EntitySchema } from "./entity.ts";
import { TransientEntitySchema } from "./entity.ts";
export const InstanceMetadataSchema = EntitySchema.extend({
export const InstanceMetadataSchema = TransientEntitySchema.extend({
type: z.literal("InstanceMetadata"),
id: z.null().optional(),
uri: z.null().optional(),
name: z.string().min(1),
software: z.strictObject({
name: z.string().min(1),
@ -28,14 +25,11 @@ export const InstanceMetadataSchema = EntitySchema.extend({
),
}),
description: z.string().nullish(),
host: z.string(),
shared_inbox: url.nullish(),
domain: z.string(),
public_key: z.strictObject({
key: z.string().min(1),
algorithm: z.literal("ed25519"),
}),
moderators: url.nullish(),
admins: url.nullish(),
logo: ImageContentFormatSchema.nullish(),
banner: ImageContentFormatSchema.nullish(),
});

View file

@ -4,13 +4,13 @@ import {
NonTextContentFormatSchema,
TextContentFormatSchema,
} from "./contentformat.ts";
import { EntitySchema } from "./entity.ts";
import { EntitySchema, ReferenceSchema } from "./entity.ts";
import { PollExtensionSchema } from "./extensions/polls.ts";
export const NoteSchema = EntitySchema.extend({
type: z.literal("Note"),
attachments: z.array(NonTextContentFormatSchema).nullish(),
author: url,
attachments: z.array(NonTextContentFormatSchema),
author: ReferenceSchema,
category: z
.enum([
"microblog",
@ -23,16 +23,6 @@ export const NoteSchema = EntitySchema.extend({
])
.nullish(),
content: TextContentFormatSchema.nullish(),
collections: z
.strictObject({
replies: url,
quotes: url,
"pub.versia:reactions/Reactions": url.nullish(),
"pub.versia:share/Shares": url.nullish(),
"pub.versia:likes/Likes": url.nullish(),
"pub.versia:likes/Dislikes": url.nullish(),
})
.catchall(url),
device: z
.strictObject({
name: z.string(),
@ -40,22 +30,20 @@ export const NoteSchema = EntitySchema.extend({
url: url.nullish(),
})
.nullish(),
group: url.or(z.enum(["public", "followers"])).nullish(),
is_sensitive: z.boolean().nullish(),
mentions: z.array(url).nullish(),
previews: z
.array(
z.strictObject({
link: url,
title: z.string(),
description: z.string().nullish(),
image: url.nullish(),
icon: url.nullish(),
}),
)
.nullish(),
quotes: url.nullish(),
replies_to: url.nullish(),
group: ReferenceSchema.or(z.enum(["public", "followers"])).nullish(),
is_sensitive: z.boolean(),
mentions: z.array(ReferenceSchema),
previews: z.array(
z.strictObject({
link: url,
title: z.string(),
description: z.string().nullish(),
image: url.nullish(),
icon: url.nullish(),
}),
),
quotes: ReferenceSchema.nullish(),
replies_to: ReferenceSchema.nullish(),
subject: z.string().nullish(),
extensions: EntitySchema.shape.extensions
.unwrap()

View file

@ -1,5 +1,4 @@
import { z } from "zod";
import { url } from "./common.ts";
import {
ImageContentFormatSchema,
TextContentFormatSchema,
@ -8,25 +7,17 @@ import { EntitySchema } from "./entity.ts";
import { MigrationExtensionSchema } from "./extensions/migration.ts";
import { VanityExtensionSchema } from "./extensions/vanity.ts";
export const PublicKeyDataSchema = z.strictObject({
key: z.string().min(1),
actor: url,
algorithm: z.literal("ed25519"),
});
export const UserSchema = EntitySchema.extend({
type: z.literal("User"),
avatar: ImageContentFormatSchema.nullish(),
bio: TextContentFormatSchema.nullish(),
display_name: z.string().nullish(),
fields: z
.array(
z.strictObject({
key: TextContentFormatSchema,
value: TextContentFormatSchema,
}),
)
.nullish(),
fields: z.array(
z.strictObject({
key: TextContentFormatSchema,
value: TextContentFormatSchema,
}),
),
username: z
.string()
.min(1)
@ -35,20 +26,8 @@ export const UserSchema = EntitySchema.extend({
"must be alphanumeric, and may contain _ or -",
),
header: ImageContentFormatSchema.nullish(),
public_key: PublicKeyDataSchema,
manually_approves_followers: z.boolean().nullish(),
indexable: z.boolean().nullish(),
inbox: url,
collections: z
.object({
featured: url,
followers: url,
following: url,
outbox: url,
"pub.versia:likes/Likes": url.nullish(),
"pub.versia:likes/Dislikes": url.nullish(),
})
.catchall(url),
manually_approves_followers: z.boolean(),
indexable: z.boolean(),
extensions: EntitySchema.shape.extensions
.unwrap()
.unwrap()

View file

@ -0,0 +1,11 @@
diff --git a/dist/index.js b/dist/index.js
index 801380e3811704fc199b80f884ec3ff87d5116f1..381de429cae24d254609c097dad8b52d8865c2cb 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -1,5 +1,5 @@
// @bun
-var $={method:"GET",data:null,headers:new Headers,response:{data:null,headers:new Headers,status:200}};function Q(z){let X=`${z.replace(/[.*+?^${}()|[\]\\]/g,"\\$&").replace(/\\\*/g,"[\\s\\S]*")}$`;return new RegExp(X)}var f=(z)=>(W)=>{let[J,X]=z,[V,Y]=W;if(!(J.toString()===V.toString()||J.match(V)))return!1;let x=X?.method||"GET",H=Y?.method||"GET";if(x.toLowerCase()!==H.toLowerCase())return!1;let G=new Headers(X?.headers),b=new Headers(Y?.headers),K=[...G.keys(),...b.keys()];for(let y of K){let N=G.get(y),g=b.get(y);if(N!==g)return!1}return!0},v=(z,W=$)=>{let{headers:J,data:X,response:V}=W,Y=V?.data??X,w=V?.headers??J,x=Y instanceof Blob||Y instanceof FormData?Y:new Blob([JSON.stringify(Y)]);return new Response(x,{headers:w,status:z})};var Z,P=!1,j=new Map,m=(z,W=$)=>{let J=z instanceof Request?z.url:z,X=J instanceof RegExp?J:new RegExp(Q(J.toString())),V=[...j.entries()].find(f([X.toString(),W]));if(process.env.VERBOSE){if(!V)console.debug("\x1B[1mRegistered mocked request\x1B[0m");else console.debug("\x1B[1mRequest already mocked\x1B[0m \x1B[2mupdated\x1B[0m");console.debug("\x1B[2mURL\x1B[0m",J),console.debug("\x1B[2mPath Pattern\x1B[0m",X),console.debug("\x1B[2mStatus\x1B[0m",W.response?.status||200),console.debug("\x1B[2mMethod\x1B[0m",W.method||"GET"),console.debug(`
+var $={method:"GET",data:null,headers:new Headers,response:{data:null,headers:new Headers,status:200}};function Q(z){let X=`${z.replace(/[.*+?^${}()|[\]\\]/g,"\\$&").replace(/\\\*/g,"[\\s\\S]*")}$`;return new RegExp(X)}var f=(z)=>(W)=>{let[J,X]=z,[V,Y]=W;if(!(J.toString()===V.toString()||J.match(V)))return!1;let x=X?.method||"GET",H=Y?.method||"GET";if(x.toLowerCase()!==H.toLowerCase())return!1;let G=new Headers(X?.headers),b=new Headers(Y?.headers),K=[...G.keys(),...b.keys()];for(let y of K){let N=G.get(y),g=b.get(y);if(g&&N!==g)return!1}return!0},v=(z,W=$)=>{let{headers:J,data:X,response:V}=W,Y=V?.data??X,w=V?.headers??J,x=Y instanceof Blob||Y instanceof FormData?Y:new Blob([JSON.stringify(Y)]);return new Response(x,{headers:w,status:z})};var Z,P=!1,j=new Map,m=(z,W=$)=>{let J=z instanceof Request?z.url:z,X=J instanceof RegExp?J:new RegExp(Q(J.toString())),V=[...j.entries()].find(f([X.toString(),W]));if(process.env.VERBOSE){if(!V)console.debug("\x1B[1mRegistered mocked request\x1B[0m");else console.debug("\x1B[1mRequest already mocked\x1B[0m \x1B[2mupdated\x1B[0m");console.debug("\x1B[2mURL\x1B[0m",J),console.debug("\x1B[2mPath Pattern\x1B[0m",X),console.debug("\x1B[2mStatus\x1B[0m",W.response?.status||200),console.debug("\x1B[2mMethod\x1B[0m",W.method||"GET"),console.debug(`
`)}if(!V)j.set(X,W);else return;if(!Z)Z=globalThis.fetch,globalThis.fetch=F;return!0},h=()=>{if(j.clear(),Z)globalThis.fetch=Z.bind({}),Z=void 0},F=async(z,W)=>{let J=z instanceof Request?z.url:z.toString(),X=z instanceof Request?z:new Request(J),V=[...j.entries()].find(f([J,W]));if(!V){if(P)return Promise.reject(v(404));return await Z(X,W)}if(process.env.VERBOSE)console.debug("\x1B[2mMocked fetch called\x1B[0m",J);if(V[1].throw)throw V[1].throw;let Y=V[1].response?.status||200;return v(Y,V[1])},I=()=>{P=!1},O=()=>{P=!0};export{m as mock,I as enableRealRequests,O as disableRealRequests,h as clearMocks};
//# debugId=6C205C9DF1DC0DBE64756E2164756E21

View file

@ -29,6 +29,7 @@ export interface ApiRouteExports {
}
export type KnownEntity =
| VersiaEntities.Entity
| VersiaEntities.Note
| VersiaEntities.InstanceMetadata
| VersiaEntities.User

View file

@ -57,7 +57,12 @@ export const applyToHono = (app: Hono<HonoEnv>): void => {
throw new ApiError(401, "Missing JWT cookie");
}
const result = await verify(jwtCookie, config.authentication.key);
const result = await verify(jwtCookie, config.authentication.key, {
alg: "HS256",
iss: config.http.base_url.toString(),
}).catch(() => {
throw new ApiError(401, "Invalid JWT");
});
const { sub } = result;