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(); const spinner = ora("Refetching user").start();
try { try {
await User.fromVersia(user.uri); await User.fromVersia(user.reference);
} catch (error) { } catch (error) {
spinner.fail( spinner.fail(
`Failed to refetch user ${chalk.gray(user.data.username)}`, `Failed to refetch user ${chalk.gray(user.data.username)}`,

View file

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

View file

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

View file

@ -87,7 +87,7 @@
"hono-openapi": "~1.1.2", "hono-openapi": "~1.1.2",
"hono-rate-limiter": "~0.5.1", "hono-rate-limiter": "~0.5.1",
"html-to-text": "~9.0.5", "html-to-text": "~9.0.5",
"ioredis": "~5.8.2", "ioredis": "5.9.2",
"ip-matching": "~2.1.2", "ip-matching": "~2.1.2",
"iso-639-1": "~3.1.5", "iso-639-1": "~3.1.5",
"linkify-html": "~4.3.2", "linkify-html": "~4.3.2",
@ -158,6 +158,7 @@
"dependencies": { "dependencies": {
"@bull-board/api": "catalog:", "@bull-board/api": "catalog:",
"@bull-board/hono": "catalog:", "@bull-board/hono": "catalog:",
"@clerc/core": "catalog:",
"@clerc/plugin-completions": "catalog:", "@clerc/plugin-completions": "catalog:",
"@clerc/plugin-friendly-error": "catalog:", "@clerc/plugin-friendly-error": "catalog:",
"@clerc/plugin-help": "catalog:", "@clerc/plugin-help": "catalog:",
@ -180,7 +181,6 @@
"blurhash": "catalog:", "blurhash": "catalog:",
"bullmq": "catalog:", "bullmq": "catalog:",
"chalk": "catalog:", "chalk": "catalog:",
"@clerc/core": "catalog:",
"confbox": "catalog:", "confbox": "catalog:",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"feed": "catalog:", "feed": "catalog:",
@ -216,5 +216,8 @@
"zod": "catalog:", "zod": "catalog:",
"zod-openapi": "catalog:", "zod-openapi": "catalog:",
"zod-validation-error": "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"); 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); return context.json(newUser.toApi(false), 200);
}, },

View file

@ -2,6 +2,7 @@ import {
Account as AccountSchema, Account as AccountSchema,
RolePermission, RolePermission,
} from "@versia/client/schemas"; } from "@versia/client/schemas";
import * as VersiaEntities from "@versia/sdk/entities";
import { config } from "@versia-server/config"; import { config } from "@versia-server/config";
import { ApiError } from "@versia-server/kit"; import { ApiError } from "@versia-server/kit";
import { apiRoute, auth, handleZodError } from "@versia-server/kit/api"; import { apiRoute, auth, handleZodError } from "@versia-server/kit/api";
@ -73,7 +74,7 @@ export default apiRoute((app) =>
// User is remote // User is remote
// Try to fetch it from database // Try to fetch it from database
const instance = await Instance.resolveFromHost(domain); const instance = await Instance.resolve(domain);
if (!instance) { if (!instance) {
return context.json( return context.json(
@ -100,13 +101,14 @@ export default apiRoute((app) =>
throw ApiError.accountNotFound(); throw ApiError.accountNotFound();
} }
const foundAccount = await User.resolve(uri); const accountData = await Instance.federationRequester.fetchSigned(
uri,
VersiaEntities.User,
);
const foundAccount = await User.fromVersia(accountData, instance);
if (foundAccount) {
return context.json(foundAccount.toApi(), 200); return context.json(foundAccount.toApi(), 200);
}
throw ApiError.accountNotFound();
}, },
), ),
); );

View file

@ -3,9 +3,10 @@ import {
RolePermission, RolePermission,
zBoolean, zBoolean,
} from "@versia/client/schemas"; } from "@versia/client/schemas";
import * as VersiaEntities from "@versia/sdk/entities";
import { ApiError } from "@versia-server/kit"; import { ApiError } from "@versia-server/kit";
import { apiRoute, auth, handleZodError } from "@versia-server/kit/api"; 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 { parseUserAddress } from "@versia-server/kit/parsers";
import { Users } from "@versia-server/kit/tables"; import { Users } from "@versia-server/kit/tables";
import { eq, ilike, not, or, sql } from "drizzle-orm"; import { eq, ilike, not, or, sql } from "drizzle-orm";
@ -88,14 +89,22 @@ export default apiRoute((app) =>
const accounts: User[] = []; const accounts: User[] = [];
if (resolve && domain) { if (resolve && domain) {
const instance = await Instance.resolve(domain);
const uri = await User.webFinger(username, domain); const uri = await User.webFinger(username, domain);
if (uri) { if (uri) {
const resolvedUser = await User.resolve(uri); const accountData =
await Instance.federationRequester.fetchSigned(
uri,
VersiaEntities.User,
);
if (resolvedUser) { const foundAccount = await User.fromVersia(
accounts.push(resolvedUser); accountData,
} instance,
);
accounts.push(foundAccount);
} }
} else { } else {
accounts.push( accounts.push(

View file

@ -6,10 +6,11 @@ import {
userAddressRegex, userAddressRegex,
zBoolean, zBoolean,
} from "@versia/client/schemas"; } from "@versia/client/schemas";
import * as VersiaEntities from "@versia/sdk/entities";
import { config } from "@versia-server/config"; import { config } from "@versia-server/config";
import { ApiError } from "@versia-server/kit"; import { ApiError } from "@versia-server/kit";
import { apiRoute, auth, handleZodError } from "@versia-server/kit/api"; 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 { parseUserAddress } from "@versia-server/kit/parsers";
import { searchManager } from "@versia-server/kit/search"; import { searchManager } from "@versia-server/kit/search";
import { Instances, Notes, Users } from "@versia-server/kit/tables"; import { Instances, Notes, Users } from "@versia-server/kit/tables";
@ -187,12 +188,21 @@ export default apiRoute((app) =>
} }
if (resolve && domain) { if (resolve && domain) {
const instance = await Instance.resolve(domain);
const uri = await User.webFinger(username, domain); const uri = await User.webFinger(username, domain);
if (uri) { if (uri) {
const newUser = await User.resolve(uri); const accountData =
await Instance.federationRequester.fetchSigned(
uri,
VersiaEntities.User,
);
const newUser = await User.fromVersia(
accountData,
instance,
);
if (newUser) {
return context.json( return context.json(
{ {
accounts: [newUser.toApi()], accounts: [newUser.toApi()],
@ -204,7 +214,6 @@ export default apiRoute((app) =>
} }
} }
} }
}
accountResults = await searchManager.searchAccounts( accountResults = await searchManager.searchAccounts(
q, q,

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, { const jwtPayload = (await verify(state, config.authentication.key, {
iss: config.http.base_url.toString(), iss: config.http.base_url.toString(),
alg: "HS256",
})) as { })) as {
flow: string; flow: string;
link?: boolean; 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 shareId = randomUUIDv7();
const reactionId = randomUUIDv7(); const reactionId = randomUUIDv7();
const reaction2Id = randomUUIDv7(); const reaction2Id = randomUUIDv7();
const userKeys = await User.generateKeys();
const privateKey = await crypto.subtle.importKey( const instanceKeys = await crypto.subtle.generateKey("Ed25519", true, [
"pkcs8", "sign",
Buffer.from(userKeys.private_key, "base64"), "verify",
"Ed25519", ]);
false,
["sign"], const inboxUrl = new URL("/.versia/v0.6/inbox", config.http.base_url);
);
const instanceKeys = await User.generateKeys();
const inboxUrl = new URL("/inbox", config.http.base_url);
const { users, deleteUsers } = await getTestUsers(1); const { users, deleteUsers } = await getTestUsers(1);
disableRealRequests(); disableRealRequests();
@ -43,61 +40,64 @@ mock(new URL("/.well-known/versia", instanceUrl).href, {
headers: { headers: {
"Content-Type": "application/json", "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({ data: new VersiaEntities.InstanceMetadata({
type: "InstanceMetadata", type: "InstanceMetadata",
name: "Versia", name: "Versia",
description: "Versia instance", description: "Versia instance",
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
host: instanceUrl.hostname, domain: instanceUrl.hostname,
software: { software: {
name: "Versia", name: "Versia",
version: "1.0.0", version: "1.0.0",
}, },
compatibility: { compatibility: {
extensions: [], extensions: [],
versions: ["0.5.0"], versions: ["0.6.0"],
}, },
public_key: { public_key: {
algorithm: "ed25519", algorithm: "ed25519",
key: instanceKeys.public_key, key: Buffer.from(
await crypto.subtle.exportKey(
"spki",
instanceKeys.publicKey,
),
).toString("base64"),
}, },
}).toJSON(), }).toJSON(),
}, },
}); });
mock(new URL(`/users/${userId}`, instanceUrl).href, { mock(new URL(`/.versia/v0.6/entities/User/${userId}`, instanceUrl).href, {
response: { response: {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/vnd.versia+json; charset=utf-8",
}, },
data: new VersiaEntities.User({ data: new VersiaEntities.User({
id: userId, id: userId,
created_at: "2025-04-18T10:32:01.427Z", created_at: "2025-04-18T10:32:01.427Z",
uri: new URL(`/users/${userId}`, instanceUrl).href,
type: "User", type: "User",
username: "testuser", username: "testuser",
public_key: { fields: [],
algorithm: "ed25519", manually_approves_followers: false,
key: userKeys.public_key, indexable: true,
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,
},
}).toJSON(), }).toJSON(),
}, },
}); });
afterAll(async () => { afterAll(async () => {
// Delete the instance in database // Delete the instance in database
const instance = await Instance.resolve(instanceUrl); const instance = await Instance.resolve(instanceUrl.hostname);
if (!instance) { if (!instance) {
throw new Error("Instance not found"); throw new Error("Instance not found");
@ -111,18 +111,18 @@ afterAll(async () => {
describe("Inbox Tests", () => { describe("Inbox Tests", () => {
test("should correctly process inbox request", async () => { test("should correctly process inbox request", async () => {
const exampleRequest = new VersiaEntities.Note({ const exampleNote = new VersiaEntities.Note({
id: noteId, id: noteId,
created_at: "2025-04-18T10:32:01.427Z", created_at: "2025-04-18T10:32:01.427Z",
uri: new URL(`/notes/${noteId}`, instanceUrl).href,
type: "Note", type: "Note",
extensions: { extensions: {
"pub.versia:custom_emojis": { "pub.versia:custom_emojis": {
emojis: [], emojis: [],
}, },
}, },
previews: [],
attachments: [], attachments: [],
author: new URL(`/users/${userId}`, instanceUrl).href, author: userId,
content: { content: {
"text/html": { "text/html": {
content: "<p>Hello!</p>", content: "<p>Hello!</p>",
@ -133,10 +133,6 @@ describe("Inbox Tests", () => {
remote: false, remote: false,
}, },
}, },
collections: {
replies: new URL(`/notes/${noteId}/replies`, instanceUrl).href,
quotes: new URL(`/notes/${noteId}/quotes`, instanceUrl).href,
},
group: "public", group: "public",
is_sensitive: false, is_sensitive: false,
mentions: [], mentions: [],
@ -146,16 +142,17 @@ describe("Inbox Tests", () => {
}); });
const signedRequest = await sign( const signedRequest = await sign(
privateKey, instanceKeys.privateKey,
new URL(exampleRequest.data.author), instanceUrl,
new Request(inboxUrl, { new Request(inboxUrl, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type":
Accept: "application/json", "application/vnd.versia+json; charset=utf-8",
Accept: "application/vnd.versia+json",
"User-Agent": "Versia/1.0.0", "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, body: signedRequest.body,
}); });
console.log(await response.text());
expect(response.status).toBe(200); expect(response.status).toBe(200);
await sleep(500); await sleep(500);
// Check if note was created in the database // 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(); expect(note).not.toBeNull();
}); });
@ -179,20 +180,20 @@ describe("Inbox Tests", () => {
const exampleRequest = new VersiaEntities.Share({ const exampleRequest = new VersiaEntities.Share({
id: shareId, id: shareId,
created_at: "2025-04-18T10:32:01.427Z", created_at: "2025-04-18T10:32:01.427Z",
uri: new URL(`/shares/${shareId}`, instanceUrl).href,
type: "pub.versia:share/Share", type: "pub.versia:share/Share",
author: new URL(`/users/${userId}`, instanceUrl).href, author: userId,
shared: new URL(`/notes/${noteId}`, instanceUrl).href, shared: noteId,
}); });
const signedRequest = await sign( const signedRequest = await sign(
privateKey, instanceKeys.privateKey,
new URL(exampleRequest.data.author), instanceUrl,
new Request(inboxUrl, { new Request(inboxUrl, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type":
Accept: "application/json", "application/vnd.versia+json; charset=utf-8",
Accept: "application/vnd.versia+json",
"User-Agent": "Versia/1.0.0", "User-Agent": "Versia/1.0.0",
}, },
body: JSON.stringify(exampleRequest.toJSON()), body: JSON.stringify(exampleRequest.toJSON()),
@ -209,9 +210,7 @@ describe("Inbox Tests", () => {
await sleep(500); await sleep(500);
const dbNote = await Note.fromSql( const dbNote = await Note.fromSql(eq(Notes.remoteId, noteId));
eq(Notes.uri, new URL(`/notes/${noteId}`, instanceUrl).href),
);
if (!dbNote) { if (!dbNote) {
throw new Error("DBNote not found"); throw new Error("DBNote not found");
@ -221,6 +220,7 @@ describe("Inbox Tests", () => {
const share = await Note.fromSql( const share = await Note.fromSql(
and( and(
eq(Notes.reblogId, dbNote.id), eq(Notes.reblogId, dbNote.id),
eq(Notes.remoteId, shareId),
eq(Notes.authorId, dbNote.data.authorId), eq(Notes.authorId, dbNote.data.authorId),
), ),
); );
@ -232,21 +232,21 @@ describe("Inbox Tests", () => {
const exampleRequest = new VersiaEntities.Reaction({ const exampleRequest = new VersiaEntities.Reaction({
id: reactionId, id: reactionId,
created_at: "2025-04-18T10:32:01.427Z", created_at: "2025-04-18T10:32:01.427Z",
uri: new URL(`/reactions/${reactionId}`, instanceUrl).href,
type: "pub.versia:reactions/Reaction", type: "pub.versia:reactions/Reaction",
author: new URL(`/users/${userId}`, instanceUrl).href, author: userId,
object: new URL(`/notes/${noteId}`, instanceUrl).href, object: noteId,
content: "👍", content: "👍",
}); });
const signedRequest = await sign( const signedRequest = await sign(
privateKey, instanceKeys.privateKey,
new URL(exampleRequest.data.author), instanceUrl,
new Request(inboxUrl, { new Request(inboxUrl, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type":
Accept: "application/json", "application/vnd.versia+json; charset=utf-8",
Accept: "application/vnd.versia+json",
"User-Agent": "Versia/1.0.0", "User-Agent": "Versia/1.0.0",
}, },
body: JSON.stringify(exampleRequest.toJSON()), body: JSON.stringify(exampleRequest.toJSON()),
@ -263,18 +263,14 @@ describe("Inbox Tests", () => {
await sleep(500); await sleep(500);
const dbNote = await Note.fromSql( const dbNote = await Note.fromSql(eq(Notes.remoteId, noteId));
eq(Notes.uri, new URL(`/notes/${noteId}`, instanceUrl).href),
);
if (!dbNote) { if (!dbNote) {
throw new Error("DBNote not found"); throw new Error("DBNote not found");
} }
// Find the remote user who reacted by URI // Find the remote user who reacted by URI
const remoteUser = await User.fromSql( const remoteUser = await User.fromSql(eq(Users.remoteId, userId));
eq(Users.uri, new URL(`/users/${userId}`, instanceUrl).href),
);
if (!remoteUser) { if (!remoteUser) {
throw new Error("Remote user not found"); throw new Error("Remote user not found");
@ -311,10 +307,9 @@ describe("Inbox Tests", () => {
const exampleRequest = new VersiaEntities.Reaction({ const exampleRequest = new VersiaEntities.Reaction({
id: reaction2Id, id: reaction2Id,
created_at: "2025-04-18T10:32:01.427Z", created_at: "2025-04-18T10:32:01.427Z",
uri: new URL(`/reactions/${reaction2Id}`, instanceUrl).href,
type: "pub.versia:reactions/Reaction", type: "pub.versia:reactions/Reaction",
author: new URL(`/users/${userId}`, instanceUrl).href, author: userId,
object: new URL(`/notes/${noteId}`, instanceUrl).href, object: noteId,
content: ":neocat:", content: ":neocat:",
extensions: { extensions: {
"pub.versia:custom_emojis": { "pub.versia:custom_emojis": {
@ -323,9 +318,7 @@ describe("Inbox Tests", () => {
name: ":neocat:", name: ":neocat:",
url: { url: {
"image/webp": { "image/webp": {
hash: { hash: "e06240155d2cb90e8dc05327d023585ab9d47216ff547ad72aaf75c485fe9649",
sha256: "e06240155d2cb90e8dc05327d023585ab9d47216ff547ad72aaf75c485fe9649",
},
size: 4664, size: 4664,
width: 256, width: 256,
height: 256, height: 256,
@ -341,13 +334,14 @@ describe("Inbox Tests", () => {
}); });
const signedRequest = await sign( const signedRequest = await sign(
privateKey, instanceKeys.privateKey,
new URL(exampleRequest.data.author), instanceUrl,
new Request(inboxUrl, { new Request(inboxUrl, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type":
Accept: "application/json", "application/vnd.versia+json; charset=utf-8",
Accept: "application/vnd.versia+json",
"User-Agent": "Versia/1.0.0", "User-Agent": "Versia/1.0.0",
}, },
body: JSON.stringify(exampleRequest.toJSON()), body: JSON.stringify(exampleRequest.toJSON()),
@ -364,18 +358,14 @@ describe("Inbox Tests", () => {
await sleep(500); await sleep(500);
const dbNote = await Note.fromSql( const dbNote = await Note.fromSql(eq(Notes.remoteId, noteId));
eq(Notes.uri, new URL(`/notes/${noteId}`, instanceUrl).href),
);
if (!dbNote) { if (!dbNote) {
throw new Error("DBNote not found"); throw new Error("DBNote not found");
} }
// Find the remote user who reacted by URI // Find the remote user who reacted by URI
const remoteUser = await User.fromSql( const remoteUser = await User.fromSql(eq(Users.remoteId, userId));
eq(Users.uri, new URL(`/users/${userId}`, instanceUrl).href),
);
if (!remoteUser) { if (!remoteUser) {
throw new Error("Remote user not found"); throw new Error("Remote user not found");
@ -409,36 +399,29 @@ describe("Inbox Tests", () => {
}); });
test("should correctly process Delete", async () => { test("should correctly process Delete", async () => {
const deleteId = randomUUIDv7();
// First check that the note exists in the database // First check that the note exists in the database
const noteToDelete = await Note.fromSql( const noteToDelete = await Note.fromSql(eq(Notes.remoteId, noteId));
eq(Notes.uri, new URL(`/notes/${noteId}`, instanceUrl).href),
);
expect(noteToDelete).not.toBeNull(); expect(noteToDelete).not.toBeNull();
// Create a Delete request // Create a Delete request
const exampleRequest = new VersiaEntities.Delete({ const exampleRequest = new VersiaEntities.Delete({
id: deleteId,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
type: "Delete", type: "Delete",
author: new URL(`/users/${userId}`, instanceUrl).href, author: userId,
deleted_type: "Note", 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( const signedRequest = await sign(
privateKey, instanceKeys.privateKey,
new URL(authorUrl), instanceUrl,
new Request(inboxUrl, { new Request(inboxUrl, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type":
Accept: "application/json", "application/vnd.versia+json; charset=utf-8",
Accept: "application/vnd.versia+json",
"User-Agent": "Versia/1.0.0", "User-Agent": "Versia/1.0.0",
}, },
body: JSON.stringify(exampleRequest.toJSON()), body: JSON.stringify(exampleRequest.toJSON()),
@ -456,9 +439,7 @@ describe("Inbox Tests", () => {
await sleep(500); await sleep(500);
// Verify that the note was deleted from the database // Verify that the note was deleted from the database
const noteExists = await Note.fromSql( const noteExists = await Note.fromSql(eq(Notes.remoteId, noteId));
eq(Notes.uri, new URL(`/notes/${noteId}`, instanceUrl).href),
);
expect(noteExists).toBeNull(); expect(noteExists).toBeNull();
}); });

View file

@ -1,13 +1,13 @@
import { apiRoute, handleZodError } from "@versia-server/kit/api"; import { apiRoute, handleZodError } from "@versia-server/kit/api";
import { InboxJobType, inboxQueue } from "@versia-server/kit/queues/inbox";
import { describeRoute, validator } from "hono-openapi"; 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) => export default apiRoute((app) =>
app.post( app.post(
"/inbox", "/.versia/v0.6/inbox",
describeRoute({ describeRoute({
summary: "Instance federation inbox", summary: "Instance inbox endpoint",
tags: ["Federation"], tags: ["Federation"],
responses: { responses: {
200: { 200: {
@ -18,12 +18,9 @@ export default apiRoute((app) =>
validator( validator(
"header", "header",
z.object({ z.object({
"versia-signature": z.string().optional(), "versia-signature": z.string(),
"versia-signed-at": z.coerce.number().optional(), "versia-signed-at": z.coerce.number(),
"versia-signed-by": z "versia-signed-by": z.string(),
.url()
.or(z.string().startsWith("instance "))
.optional(),
authorization: z.string().optional(), authorization: z.string().optional(),
}), }),
handleZodError, 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 { 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 { describeRoute, resolver } from "hono-openapi";
import { urlToContentFormat } from "@/content_types"; import z from "zod";
import pkg from "../../../../package.json" with { type: "json" };
export default apiRoute((app) => export default apiRoute((app) =>
app.get( app.get(
"/.well-known/versia", "/.well-known/versia",
describeRoute({ describeRoute({
summary: "Get instance metadata", summary: "Get supported versia protocol versions",
tags: ["Federation"], tags: ["Federation"],
responses: { responses: {
200: { 200: {
description: "Instance metadata", description: "Instance metadata",
content: { content: {
"application/json": { "application/json": {
schema: resolver(InstanceMetadataSchema), schema: resolver(
z.strictObject({
versions: z.array(z.string().min(1)),
}),
),
}, },
}, },
}, },
}, },
}), }),
async (context) => { (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( return context.json(
{ {
type: "InstanceMetadata" as const, versions: ["0.6.0"],
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(),
},
},
}, },
200, 200,
); );

View file

@ -1,3 +1,4 @@
import { sign } from "@versia/sdk/crypto";
import * as VersiaEntities from "@versia/sdk/entities"; import * as VersiaEntities from "@versia/sdk/entities";
import { FederationRequester } from "@versia/sdk/http"; import { FederationRequester } from "@versia/sdk/http";
import { config } from "@versia-server/config"; import { config } from "@versia-server/config";
@ -15,6 +16,7 @@ import {
inArray, inArray,
type SQL, type SQL,
} from "drizzle-orm"; } from "drizzle-orm";
import type { HttpVerb, KnownEntity } from "~/types/api.ts";
import { ApiError } from "../api-error.ts"; import { ApiError } from "../api-error.ts";
import { db } from "../tables/db.ts"; import { db } from "../tables/db.ts";
import { Instances } from "../tables/schema.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> { public static async fromUser(user: User): Promise<Instance | null> {
if (!user.data.instanceId) { if (!user.data.instanceId) {
return null; return null;
@ -139,29 +148,24 @@ export class Instance extends BaseInterface<typeof Instances> {
return this.data.id; return this.data.id;
} }
public static async fetchMetadata(url: URL): Promise<{ public static async fetchMetadata(domain: string): Promise<{
metadata: VersiaEntities.InstanceMetadata; metadata: VersiaEntities.InstanceMetadata;
protocol: "versia" | "activitypub"; protocol: "versia" | "activitypub";
}> { }> {
const origin = new URL(url).origin;
const wellKnownUrl = new URL("/.well-known/versia", origin);
try { try {
const metadata = await new FederationRequester( const metadata =
config.instance.keys.private, await Instance.federationRequester.resolveInstance(domain);
config.http.base_url,
).fetchEntity(wellKnownUrl, VersiaEntities.InstanceMetadata);
return { metadata, protocol: "versia" }; return { metadata, protocol: "versia" };
} catch { } catch {
// If the server doesn't have a Versia well-known endpoint, it's not a Versia instance // If the server doesn't have a Versia well-known endpoint, it's not a Versia instance
// Try to resolve ActivityPub metadata instead // Try to resolve ActivityPub metadata instead
const data = await Instance.fetchActivityPubMetadata(url); const data = await Instance.fetchActivityPubMetadata(domain);
if (!data) { if (!data) {
throw new ApiError( throw new ApiError(
404, 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( private static async fetchActivityPubMetadata(
url: URL, domain: string,
): Promise<VersiaEntities.InstanceMetadata | null> { ): Promise<VersiaEntities.InstanceMetadata | null> {
const origin = new URL(url).origin; const origin = new URL(`https://${domain}`);
const wellKnownUrl = new URL("/.well-known/nodeinfo", origin); const wellKnownUrl = new URL("/.well-known/nodeinfo", origin);
// Go to endpoint, then follow the links to the actual metadata // Go to endpoint, then follow the links to the actual metadata
@ -254,7 +258,7 @@ export class Instance extends BaseInterface<typeof Instances> {
key: "", key: "",
algorithm: "ed25519", algorithm: "ed25519",
}, },
host: new URL(url).host, domain: origin.hostname,
compatibility: { compatibility: {
extensions: [], extensions: [],
versions: [], versions: [],
@ -268,50 +272,33 @@ export class Instance extends BaseInterface<typeof Instances> {
} }
} }
public static resolveFromHost(host: string): Promise<Instance> { public static async resolve(domain: 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;
const existingInstance = await Instance.fromSql( const existingInstance = await Instance.fromSql(
eq(Instances.baseUrl, host), eq(Instances.baseUrl, domain),
); );
if (existingInstance) { if (existingInstance) {
return existingInstance; return existingInstance;
} }
const output = await Instance.fetchMetadata(url); const output = await Instance.fetchMetadata(domain);
const { metadata, protocol } = output; const { metadata, protocol } = output;
return Instance.insert({ return Instance.insert({
id: randomUUIDv7(), id: randomUUIDv7(),
baseUrl: host, baseUrl: domain,
name: metadata.data.name, name: metadata.data.name,
version: metadata.data.software.version, version: metadata.data.software.version,
logo: metadata.data.logo, logo: metadata.data.logo,
protocol, protocol,
publicKey: metadata.data.public_key, publicKey: metadata.data.public_key,
inbox: metadata.data.shared_inbox ?? null,
extensions: metadata.data.extensions ?? null, extensions: metadata.data.extensions ?? null,
}); });
} }
public async updateFromRemote(): Promise<Instance> { public async updateFromRemote(): Promise<Instance> {
const output = await Instance.fetchMetadata( const output = await Instance.fetchMetadata(this.data.baseUrl);
new URL(`https://${this.data.baseUrl}`),
);
if (!output) { if (!output) {
federationResolversLogger.error`Failed to update instance ${chalk.bold( federationResolversLogger.error`Failed to update instance ${chalk.bold(
@ -328,13 +315,39 @@ export class Instance extends BaseInterface<typeof Instances> {
logo: metadata.data.logo, logo: metadata.data.logo,
protocol, protocol,
publicKey: metadata.data.public_key, publicKey: metadata.data.public_key,
inbox: metadata.data.shared_inbox ?? null,
extensions: metadata.data.extensions ?? null, extensions: metadata.data.extensions ?? null,
}); });
return this; 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> { public async sendMessage(content: string): Promise<void> {
if ( if (
!this.data.extensions?.["pub.versia:instance_messaging"]?.endpoint !this.data.extensions?.["pub.versia:instance_messaging"]?.endpoint

View file

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

View file

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

View file

@ -4,15 +4,11 @@ import type {
RolePermission, RolePermission,
Source, Source,
} from "@versia/client/schemas"; } from "@versia/client/schemas";
import { sign } from "@versia/sdk/crypto";
import * as VersiaEntities from "@versia/sdk/entities"; import * as VersiaEntities from "@versia/sdk/entities";
import { FederationRequester } from "@versia/sdk/http"; import { FederationRequester } from "@versia/sdk/http";
import type { ImageContentFormatSchema } from "@versia/sdk/schemas"; import type { ImageContentFormatSchema } from "@versia/sdk/schemas";
import { config, ProxiableUrl } from "@versia-server/config"; import { config, ProxiableUrl } from "@versia-server/config";
import { import { federationDeliveryLogger } from "@versia-server/logging";
federationDeliveryLogger,
federationResolversLogger,
} from "@versia-server/logging";
import { password as bunPassword, randomUUIDv7 } from "bun"; import { password as bunPassword, randomUUIDv7 } from "bun";
import chalk from "chalk"; import chalk from "chalk";
import { import {
@ -33,10 +29,9 @@ import { htmlToText } from "html-to-text";
import type { z } from "zod"; import type { z } from "zod";
import { getBestContentType } from "@/content_types"; import { getBestContentType } from "@/content_types";
import { randomString } from "@/math"; 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 { DeliveryJobType, deliveryQueue } from "../queues/delivery/queue.ts";
import { PushJobType, pushQueue } from "../queues/push/queue.ts"; import { PushJobType, pushQueue } from "../queues/push/queue.ts";
import { uuid } from "../regex.ts";
import { db } from "../tables/db.ts"; import { db } from "../tables/db.ts";
import { import {
EmojiToUser, EmojiToUser,
@ -79,7 +74,7 @@ export const userRelations = {
// TODO: Remove this function and use what drizzle outputs directly instead of transforming it // TODO: Remove this function and use what drizzle outputs directly instead of transforming it
export const transformOutputToUserWithRelations = ( export const transformOutputToUserWithRelations = (
user: Omit<InferSelectModel<typeof Users>, "endpoints"> & { user: InferSelectModel<typeof Users> & {
followerCount: unknown; followerCount: unknown;
followingCount: unknown; followingCount: unknown;
statusCount: unknown; statusCount: unknown;
@ -96,7 +91,6 @@ export const transformOutputToUserWithRelations = (
roleId: string; roleId: string;
role?: typeof Role.$type; role?: typeof Role.$type;
}[]; }[];
endpoints: unknown;
}, },
): typeof User.$type => { ): typeof User.$type => {
return { return {
@ -104,17 +98,6 @@ export const transformOutputToUserWithRelations = (
followerCount: Number(user.followerCount), followerCount: Number(user.followerCount),
followingCount: Number(user.followingCount), followingCount: Number(user.followingCount),
statusCount: Number(user.statusCount), 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( emojis: user.emojis.map(
(emoji) => (emoji) =>
(emoji as unknown as Record<string, object>) (emoji as unknown as Record<string, object>)
@ -239,14 +222,26 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return !this.local; return !this.local;
} }
public get uri(): URL { public get reference(): VersiaEntities.Reference {
return this.data.uri if (this.local) {
? new URL(this.data.uri) return new VersiaEntities.Reference(this.id);
: new URL(`/users/${this.data.id}`, config.http.base_url);
} }
public static getUri(id: string, uri: URL | null): URL { return new VersiaEntities.Reference(
return uri ? uri : new URL(`/users/${id}`, config.http.base_url); this.data.remoteId as string,
(this.data.instance as typeof Instance.$type).baseUrl,
);
}
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 { public hasPermission(permission: RolePermission): boolean {
@ -335,13 +330,13 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
} }
private unfollowToVersia(followee: User): VersiaEntities.Unfollow { private unfollowToVersia(followee: User): VersiaEntities.Unfollow {
const id = crypto.randomUUID();
return new VersiaEntities.Unfollow({ return new VersiaEntities.Unfollow({
type: "Unfollow", type: "Unfollow",
id, author: this.id,
author: this.uri.href,
created_at: new Date().toISOString(), 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({ const entity = new VersiaEntities.FollowAccept({
type: "FollowAccept", type: "FollowAccept",
id: crypto.randomUUID(), author: this.id,
author: this.uri.href,
created_at: new Date().toISOString(), 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, { await deliveryQueue.add(DeliveryJobType.FederateEntity, {
@ -383,10 +379,11 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
const entity = new VersiaEntities.FollowReject({ const entity = new VersiaEntities.FollowReject({
type: "FollowReject", type: "FollowReject",
id: crypto.randomUUID(), author: this.id,
author: this.uri.href,
created_at: new Date().toISOString(), 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, { 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 * Perform a WebFinger lookup to find a user's URI
* @param username * @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. * Takes a Versia User representation, and serializes it to the database.
* *
* If the user already exists, it will update it. * 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( 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> { ): Promise<User> {
if (versiaUser instanceof URL) { if (versiaUser instanceof VersiaEntities.Reference) {
let uri = versiaUser; if (!versiaUser.domain) {
const instance = await Instance.resolve(uri); throw new Error(
"Cannot fetch Versia user from reference without domain",
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,
); );
} }
const user = await new FederationRequester( const user = await Instance.federationRequester.fetchEntity(
config.instance.keys.private, versiaUser,
config.http.base_url, VersiaEntities.User,
).fetchEntity(uri, 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 { const {
username, username,
inbox,
avatar,
header,
display_name, display_name,
id,
fields, fields,
collections,
created_at, created_at,
manually_approves_followers, manually_approves_followers,
bio, bio,
public_key,
uri,
extensions, extensions,
} = versiaUser.data; } = versiaUser.data;
const instance = await Instance.resolve(new URL(versiaUser.data.uri));
const existingUser = await User.fromSql( const existingUser = await User.fromSql(
eq(Users.uri, versiaUser.data.uri), and(eq(Users.instanceId, instance.id), eq(Users.remoteId, id)),
); );
const user = const user =
@ -763,60 +726,50 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
(await User.insert({ (await User.insert({
username, username,
id: randomUUIDv7(), id: randomUUIDv7(),
publicKey: public_key.key,
uri,
instanceId: instance.id, instanceId: instance.id,
remoteId: id,
})); }));
// Avatars and headers are stored in a separate table, so we need to update them separately // Avatars and headers are stored in a separate table, so we need to update them separately
let userAvatar: Media | null = null; let userAvatar: Media | null = null;
let userHeader: Media | null = null; let userHeader: Media | null = null;
if (avatar) { if (versiaUser.avatar) {
if (user.avatar) { if (user.avatar) {
userAvatar = new Media( userAvatar = new Media(
await user.avatar.update({ await user.avatar.update({
content: avatar, content: versiaUser.avatar.data,
}), }),
); );
} else { } else {
userAvatar = await Media.insert({ userAvatar = await Media.insert({
id: randomUUIDv7(), id: randomUUIDv7(),
content: avatar, content: versiaUser.avatar.data,
}); });
} }
} }
if (header) { if (versiaUser.header) {
if (user.header) { if (user.header) {
userHeader = new Media( userHeader = new Media(
await user.header.update({ await user.header.update({
content: header, content: versiaUser.header.data,
}), }),
); );
} else { } else {
userHeader = await Media.insert({ userHeader = await Media.insert({
id: randomUUIDv7(), id: randomUUIDv7(),
content: header, content: versiaUser.header.data,
}); });
} }
} }
await user.update({ await user.update({
createdAt: new Date(created_at), createdAt: new Date(created_at),
endpoints: { isLocked: manually_approves_followers,
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,
avatarId: userAvatar?.id, avatarId: userAvatar?.id,
headerId: userHeader?.id, headerId: userHeader?.id,
fields: fields ?? [], fields,
displayName: display_name, displayName: display_name,
note: getBestContentType(bio).content, note: getBestContentType(bio).content,
}); });
@ -847,33 +800,51 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return user; return user;
} }
public static async resolve(uri: URL): Promise<User | null> { public static async resolve(
federationResolversLogger.debug`Resolving user ${chalk.gray(uri)}`; reference: VersiaEntities.Reference,
defaultInstance?: Instance,
): Promise<User> {
// Check if user not already in database // 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) { if (foundUser) {
return foundUser; return foundUser;
} }
// Check if URI is of a local user return User.fromVersia(
if (uri.origin === config.http.base_url.origin) { reference.domain
const userUuid = uri.href.match(uuid); ? reference
: new VersiaEntities.Reference(
if (!userUuid?.[0]) { reference.id,
throw new Error( instance.data.baseUrl,
`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);
}
/** /**
* Get the user's avatar in raw URL format * Get the user's avatar in raw URL format
* @returns The raw URL for the user's avatar * @returns The raw URL for the user's avatar
@ -890,31 +861,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return this.avatar?.getUrl(); 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( public static async register(
username: string, username: string,
options?: Partial<{ options?: Partial<{
@ -924,8 +870,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
isAdmin: boolean; isAdmin: boolean;
}>, }>,
): Promise<User> { ): Promise<User> {
const keys = await User.generateKeys();
const user = await User.insert({ const user = await User.insert({
id: randomUUIDv7(), id: randomUUIDv7(),
username, username,
@ -937,9 +881,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
note: "", note: "",
avatarId: options?.avatar?.id, avatarId: options?.avatar?.id,
isAdmin: options?.isAdmin, isAdmin: options?.isAdmin,
publicKey: keys.public_key,
fields: [], fields: [],
privateKey: keys.private_key,
updatedAt: new Date(), updatedAt: new Date(),
source: { source: {
language: "en", language: "en",
@ -999,11 +941,9 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
newUser.avatar || newUser.avatar ||
newUser.header || newUser.header ||
newUser.fields || newUser.fields ||
newUser.publicKey ||
newUser.isAdmin || newUser.isAdmin ||
newUser.isBot || newUser.isBot ||
newUser.isLocked || newUser.isLocked ||
newUser.endpoints ||
newUser.isDiscoverable || newUser.isDiscoverable ||
newUser.isIndexable) newUser.isIndexable)
) { ) {
@ -1013,20 +953,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return updated.data; 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 * Get all remote followers of the user
* @returns The remote followers * @returns The remote followers
@ -1076,17 +1002,13 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
entity: KnownEntity, entity: KnownEntity,
user: User, user: User,
): Promise<{ ok: boolean }> { ): Promise<{ ok: boolean }> {
const inbox = user.data.instance?.inbox || user.data.endpoints?.inbox; if (!user.data.instance) {
throw new Error("Cannot federate to a local user");
if (!inbox) {
throw new Error(
`User ${chalk.gray(user.uri)} does not have an inbox endpoint`,
);
} }
try { try {
await (await this.federationRequester).postEntity( await Instance.federationRequester.postEntity(
new URL(inbox), user.data.instance.baseUrl,
entity, entity,
); );
} catch (e) { } catch (e) {
@ -1110,9 +1032,12 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
display_name: user.displayName || user.username, display_name: user.displayName || user.username,
note: user.note, note: user.note,
uri: this.uri.href, uri: this.uri.href,
url: url: new URL(
user.uri || `/@${user.username}${
new URL(`/@${user.username}`, config.http.base_url).href, user.instanceId ? `@${user.instance?.baseUrl}` : ""
}`,
config.http.base_url,
).href,
avatar: this.getAvatarUrl().proxied, avatar: this.getAvatarUrl().proxied,
header: this.getHeaderUrl()?.proxied ?? "", header: this.getHeaderUrl()?.proxied ?? "",
locked: user.isLocked, locked: user.isLocked,
@ -1166,7 +1091,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return new VersiaEntities.User({ return new VersiaEntities.User({
id: user.id, id: user.id,
type: "User", type: "User",
uri: this.uri.href,
bio: { bio: {
"text/html": { "text/html": {
content: user.note, content: user.note,
@ -1178,34 +1102,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
}, },
}, },
created_at: user.createdAt.toISOString(), 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, indexable: this.data.isIndexable,
username: user.username, username: user.username,
manually_approves_followers: this.data.isLocked, manually_approves_followers: this.data.isLocked,
@ -1217,11 +1113,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
>, >,
display_name: user.displayName, display_name: user.displayName,
fields: user.fields, fields: user.fields,
public_key: {
actor: new URL(`/users/${user.id}`, config.http.base_url).href,
key: user.publicKey,
algorithm: "ed25519",
},
extensions: { extensions: {
"pub.versia:custom_emojis": { "pub.versia:custom_emojis": {
emojis: user.emojis.map((emoji) => emojis: user.emojis.map((emoji) =>

View file

@ -182,27 +182,36 @@ export class InboxProcessor {
shouldCheckSignature && federationInboxLogger.debug`Signature is valid`; shouldCheckSignature && federationInboxLogger.debug`Signature is valid`;
try { try {
// TODO: Rip out bridge code so this is never null
const instance = this.sender?.instance as Instance;
await new EntitySorter(this.body) await new EntitySorter(this.body)
.on(VersiaEntities.Note, (n) => InboxProcessor.processNote(n)) .on(VersiaEntities.Note, (n) =>
InboxProcessor.processNote(n, instance),
)
.on(VersiaEntities.Follow, (f) => .on(VersiaEntities.Follow, (f) =>
InboxProcessor.processFollowRequest(f), InboxProcessor.processFollowRequest(f, instance),
) )
.on(VersiaEntities.FollowAccept, (f) => .on(VersiaEntities.FollowAccept, (f) =>
InboxProcessor.processFollowAccept(f), InboxProcessor.processFollowAccept(f, instance),
) )
.on(VersiaEntities.FollowReject, (f) => .on(VersiaEntities.FollowReject, (f) =>
InboxProcessor.processFollowReject(f), InboxProcessor.processFollowReject(f, instance),
) )
.on(VersiaEntities.Like, (l) => .on(VersiaEntities.Like, (l) =>
InboxProcessor.processLikeRequest(l), InboxProcessor.processLikeRequest(l, instance),
) )
.on(VersiaEntities.Delete, (d) => .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) => .on(VersiaEntities.Reaction, (r) =>
InboxProcessor.processReaction(r), InboxProcessor.processReaction(r, instance),
) )
.sort(() => { .sort(() => {
throw new ApiError(400, "Unknown entity type"); throw new ApiError(400, "Unknown entity type");
@ -220,9 +229,10 @@ export class InboxProcessor {
*/ */
private static async processReaction( private static async processReaction(
reaction: VersiaEntities.Reaction, reaction: VersiaEntities.Reaction,
sender: Instance,
): Promise<void> { ): Promise<void> {
const author = await User.resolve(new URL(reaction.data.author)); const author = await User.resolve(reaction.author, sender);
const note = await Note.resolve(new URL(reaction.data.object)); const note = await Note.resolve(reaction.object, sender);
if (!author) { if (!author) {
throw new ApiError(404, "Author not found"); throw new ApiError(404, "Author not found");
@ -241,7 +251,10 @@ export class InboxProcessor {
* @param {VersiaNote} note - The Note entity to process. * @param {VersiaNote} note - The Note entity to process.
* @returns {Promise<void>} * @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 note has a blocked word
if ( if (
Object.values(note.content?.data ?? {}) Object.values(note.content?.data ?? {})
@ -257,16 +270,20 @@ export class InboxProcessor {
return; return;
} }
await Note.fromVersia(note); await Note.fromVersia(note, sender);
} }
/** /**
* Handles User entity processing. * Handles User entity processing.
* *
* @param {VersiaUser} user - The User entity to process. * @param {VersiaUser} user - The User entity to process.
* @param {string} domain - The domain of the user.
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
private static async processUser(user: VersiaEntities.User): Promise<void> { private static async processUser(
user: VersiaEntities.User,
sender: Instance,
): Promise<void> {
if ( if (
config.validation.filters.username.some((filter) => config.validation.filters.username.some((filter) =>
filter.test(user.data.username), filter.test(user.data.username),
@ -294,7 +311,7 @@ export class InboxProcessor {
return; return;
} }
await User.fromVersia(user); await User.fromVersia(user, sender);
} }
/** /**
@ -305,9 +322,10 @@ export class InboxProcessor {
*/ */
private static async processFollowRequest( private static async processFollowRequest(
follow: VersiaEntities.Follow, follow: VersiaEntities.Follow,
sender: Instance,
): Promise<void> { ): Promise<void> {
const author = await User.resolve(new URL(follow.data.author)); const author = await User.resolve(follow.author, sender);
const followee = await User.resolve(new URL(follow.data.followee)); const followee = await User.resolve(follow.followee, sender);
if (!author) { if (!author) {
throw new ApiError(404, "Author not found"); throw new ApiError(404, "Author not found");
@ -353,11 +371,10 @@ export class InboxProcessor {
*/ */
private static async processFollowAccept( private static async processFollowAccept(
followAccept: VersiaEntities.FollowAccept, followAccept: VersiaEntities.FollowAccept,
sender: Instance,
): Promise<void> { ): Promise<void> {
const author = await User.resolve(new URL(followAccept.data.author)); const author = await User.resolve(followAccept.author, sender);
const follower = await User.resolve( const follower = await User.resolve(followAccept.follower, sender);
new URL(followAccept.data.follower),
);
if (!author) { if (!author) {
throw new ApiError(404, "Author not found"); throw new ApiError(404, "Author not found");
@ -390,11 +407,10 @@ export class InboxProcessor {
*/ */
private static async processFollowReject( private static async processFollowReject(
followReject: VersiaEntities.FollowReject, followReject: VersiaEntities.FollowReject,
sender: Instance,
): Promise<void> { ): Promise<void> {
const author = await User.resolve(new URL(followReject.data.author)); const author = await User.resolve(followReject.author, sender);
const follower = await User.resolve( const follower = await User.resolve(followReject.follower, sender);
new URL(followReject.data.follower),
);
if (!author) { if (!author) {
throw new ApiError(404, "Author not found"); throw new ApiError(404, "Author not found");
@ -427,9 +443,10 @@ export class InboxProcessor {
*/ */
private static async processShare( private static async processShare(
share: VersiaEntities.Share, share: VersiaEntities.Share,
sender: Instance,
): Promise<void> { ): Promise<void> {
const author = await User.resolve(new URL(share.data.author)); const author = await User.resolve(share.author, sender);
const sharedNote = await Note.resolve(new URL(share.data.shared)); const sharedNote = await Note.resolve(share.shared, sender);
if (!author) { if (!author) {
throw new ApiError(404, "Author not found"); throw new ApiError(404, "Author not found");
@ -439,7 +456,7 @@ export class InboxProcessor {
throw new ApiError(404, "Shared Note not found"); 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 */ // JS doesn't allow the use of `delete` as a variable name
public static async processDelete( public static async processDelete(
delete_: VersiaEntities.Delete, delete_: VersiaEntities.Delete,
sender: Instance,
): Promise<void> { ): Promise<void> {
const toDelete = delete_.data.deleted; const toDelete = delete_.deleted;
const author = delete_.data.author const author = await User.resolve(delete_.author, sender);
? await User.resolve(new URL(delete_.data.author))
: null;
switch (delete_.data.deleted_type) { switch (delete_.data.deleted_type) {
case "Note": { case "Note": {
const note = await Note.fromSql( const note = await Note.fromSql(
eq(Notes.uri, toDelete), eq(Notes.remoteId, toDelete.id),
author ? eq(Notes.authorId, author.id) : undefined, eq(Notes.authorId, author.id),
); );
if (!note) { if (!note) {
@ -475,7 +491,7 @@ export class InboxProcessor {
return; return;
} }
case "User": { case "User": {
const userToDelete = await User.resolve(new URL(toDelete)); const userToDelete = await User.resolve(toDelete, sender);
if (!userToDelete) { if (!userToDelete) {
throw new ApiError(404, "User to delete not found"); throw new ApiError(404, "User to delete not found");
@ -490,8 +506,8 @@ export class InboxProcessor {
} }
case "pub.versia:likes/Like": { case "pub.versia:likes/Like": {
const like = await Like.fromSql( const like = await Like.fromSql(
eq(Likes.uri, toDelete), eq(Likes.remoteId, toDelete.id),
author ? eq(Likes.likerId, author.id) : undefined, eq(Likes.likerId, author.id),
); );
if (!like) { if (!like) {
@ -525,7 +541,10 @@ export class InboxProcessor {
} }
const reblog = await Note.fromSql( 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) { if (!reblog) {
@ -567,9 +586,10 @@ export class InboxProcessor {
*/ */
private static async processLikeRequest( private static async processLikeRequest(
like: VersiaEntities.Like, like: VersiaEntities.Like,
sender: Instance,
): Promise<void> { ): Promise<void> {
const author = await User.resolve(new URL(like.data.author)); const author = await User.resolve(like.author, sender);
const likedNote = await Note.resolve(new URL(like.data.liked)); const likedNote = await Note.resolve(like.liked, sender);
if (!author) { if (!author) {
throw new ApiError(404, "Author not found"); throw new ApiError(404, "Author not found");
@ -579,7 +599,7 @@ export class InboxProcessor {
throw new ApiError(404, "Liked Note not found"); 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 { FederationRequester } from "@versia/sdk/http";
import { config } from "@versia-server/config"; import { config } from "@versia-server/config";
import { and, eq, inArray, isNull, or } from "drizzle-orm"; import { and, eq, inArray, isNull, or } from "drizzle-orm";
@ -13,6 +13,7 @@ import {
letter, letter,
} from "magic-regexp"; } from "magic-regexp";
import { sanitizeHtml, sanitizeHtmlInline } from "@/sanitization"; import { sanitizeHtml, sanitizeHtmlInline } from "@/sanitization";
import { Instance } from "./db/instance.ts";
import { User } from "./db/user.ts"; import { User } from "./db/user.ts";
import { markdownToHtml } from "./markdown.ts"; import { markdownToHtml } from "./markdown.ts";
import { mention } from "./regex.ts"; import { mention } from "./regex.ts";
@ -81,7 +82,14 @@ export const parseMentionsFromText = async (text: string): Promise<User[]> => {
); );
if (url) { 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) { if (user) {
finalList.push(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}]`); await job.log(`Fetching instance metadata from [${uri}]`);
// Check if exists // Check if exists
const host = new URL(uri).host; const host = new URL(uri).hostname;
const existingInstance = await Instance.fromSql( const existingInstance = await Instance.fromSql(
eq(Instances.baseUrl, host), eq(Instances.baseUrl, host),
@ -37,7 +37,7 @@ export const getFetchWorker = (): Worker<FetchJobData, void, FetchJobType> =>
return; return;
} }
await Instance.resolve(new URL(uri)); await Instance.resolve(host);
await job.log( await job.log(
`✔ Finished fetching instance metadata from [${uri}]`, `✔ Finished fetching instance metadata from [${uri}]`,

View file

@ -2,7 +2,6 @@ import { config } from "@versia-server/config";
import { Worker } from "bullmq"; import { Worker } from "bullmq";
import { ApiError } from "../../api-error.ts"; import { ApiError } from "../../api-error.ts";
import { Instance } from "../../db/instance.ts"; import { Instance } from "../../db/instance.ts";
import { User } from "../../db/user.ts";
import { InboxProcessor } from "../../inbox-processor.ts"; import { InboxProcessor } from "../../inbox-processor.ts";
import { connection } from "../../redis.ts"; import { connection } from "../../redis.ts";
import { type InboxJobData, InboxJobType, inboxQueue } from "./queue.ts"; import { type InboxJobData, InboxJobType, inboxQueue } from "./queue.ts";
@ -72,51 +71,29 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
"versia-signed-by": string; "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( await job.log(
`Could not resolve sender URI [${signedBy}]`, `Could not resolve sender domain [${signedBy}]`,
); );
return; 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( 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( 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( const key = await crypto.subtle.importKey(
"spki", "spki",
Buffer.from( Buffer.from(sender.data.publicKey.key, "base64"),
sender?.data.publicKey ??
remoteInstance.data.publicKey.key,
"base64",
),
"Ed25519", "Ed25519",
false, false,
["verify"], ["verify"],
@ -127,7 +104,7 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
req, req,
data, data,
{ {
instance: remoteInstance, instance: sender,
key, key,
}, },
undefined, undefined,
@ -147,10 +124,10 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
); );
await job.log( 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 [${ `Failed processing entity [${
data.uri data.uri
}] delivered to inbox. Returned error:\n\n${JSON.stringify( }] 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, "when": 1765422160004,
"tag": "0053_lively_hellfire_club", "tag": "0053_lively_hellfire_club",
"breakpoints": true "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", { export const Reactions = pgTable("Reaction", {
id: id(), id: id(),
uri: uri(), uri: uri(),
remoteId: text("remote_id"),
// Emoji ID is nullable, in which case it is a text emoji, and the emojiText field is used // Emoji ID is nullable, in which case it is a text emoji, and the emojiText field is used
emojiId: uuid("emojiId").references(() => Emojis.id, { emojiId: uuid("emojiId").references(() => Emojis.id, {
onDelete: "cascade", onDelete: "cascade",
@ -244,7 +245,7 @@ export const Markers = pgTable("Markers", {
export const Likes = pgTable("Likes", { export const Likes = pgTable("Likes", {
id: id(), id: id(),
uri: uri(), remoteId: text("remote_id"),
likerId: uuid("likerId") likerId: uuid("likerId")
.notNull() .notNull()
.references(() => Users.id, { .references(() => Users.id, {
@ -472,7 +473,7 @@ export const NotificationsRelations = relations(Notifications, ({ one }) => ({
export const Notes = pgTable("Notes", { export const Notes = pgTable("Notes", {
id: id(), id: id(),
uri: uri(), remoteId: text("remote_id"),
authorId: uuid("authorId") authorId: uuid("authorId")
.notNull() .notNull()
.references(() => Users.id, { .references(() => Users.id, {
@ -600,7 +601,7 @@ export const Users = pgTable(
"Users", "Users",
{ {
id: id(), id: id(),
uri: uri(), remoteId: text("remote_id"),
username: text("username").notNull(), username: text("username").notNull(),
displayName: text("display_name"), displayName: text("display_name"),
password: text("password"), password: text("password"),
@ -615,15 +616,6 @@ export const Users = pgTable(
value: z.infer<typeof TextContentFormatSchema>; 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>>(), source: jsonb("source").$type<z.infer<typeof Source>>(),
avatarId: uuid("avatarId").references(() => Medias.id, { avatarId: uuid("avatarId").references(() => Medias.id, {
onDelete: "set null", onDelete: "set null",
@ -646,8 +638,6 @@ export const Users = pgTable(
.notNull(), .notNull(),
isIndexable: boolean("is_indexable").default(true).notNull(), isIndexable: boolean("is_indexable").default(true).notNull(),
sanctions: text("sanctions").array(), sanctions: text("sanctions").array(),
publicKey: text("public_key").notNull(),
privateKey: text("private_key"),
instanceId: uuid("instanceId").references(() => Instances.id, { instanceId: uuid("instanceId").references(() => Instances.id, {
onDelete: "cascade", onDelete: "cascade",
onUpdate: "cascade", onUpdate: "cascade",
@ -656,11 +646,7 @@ export const Users = pgTable(
.default(false) .default(false)
.notNull(), .notNull(),
}, },
(table) => [ (table) => [index().on(table.username), uniqueIndex().on(table.email)],
uniqueIndex().on(table.uri),
index().on(table.username),
uniqueIndex().on(table.email),
],
); );
export const UsersRelations = relations(Users, ({ many, one }) => ({ 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. * Signs a request using the Ed25519 algorithm, according to the [**Versia**](https://versia.pub/signatures) specification.
* *
* @see https://versia.pub/signatures * @see https://versia.pub/signatures
* @param privateKey - Private key of the User that is signing the request. * @param privateKey - Private key of the instance that is signing the request.
* @param authorUrl - URL of the User that is signing the request. * @param instance - URL of the instance that is signing the request.
* @param req - Request to sign. * @param req - Request to sign.
* @param timestamp - (optional) Timestamp of the request. * @param timestamp - (optional) Timestamp of the request.
* @returns The signed request. * @returns The signed request.
*/ */
export const sign = async ( export const sign = async (
privateKey: CryptoKey, privateKey: CryptoKey,
authorUrl: URL, instance: URL,
req: Request, req: Request,
timestamp = new Date(), timestamp = new Date(),
): Promise<Request> => { ): Promise<Request> => {
@ -48,7 +48,7 @@ export const sign = async (
...req.headers, ...req.headers,
"Versia-Signature": signatureBase64, "Versia-Signature": signatureBase64,
"Versia-Signed-At": String(timestampSecs), "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 type { z } from "zod";
import { DeleteSchema } from "../schemas/delete.ts"; import { DeleteSchema } from "../schemas/delete.ts";
import type { JSONObject } from "../types.ts"; import type { JSONObject } from "../types.ts";
import { Entity } from "./entity.ts"; import { Entity, Reference } from "./entity.ts";
export class Delete extends Entity { export class Delete extends Entity {
public static override name = "Delete"; public static override name = "Delete";
@ -10,6 +10,14 @@ export class Delete extends Entity {
super(data); 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> { public static override fromJSON(json: JSONObject): Promise<Delete> {
return DeleteSchema.parseAsync(json).then((u) => new Delete(u)); return DeleteSchema.parseAsync(json).then((u) => new Delete(u));
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,7 +8,7 @@ export {
VideoContentFormat, VideoContentFormat,
} from "./contentformat.ts"; } from "./contentformat.ts";
export { Delete } from "./delete.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 { Dislike, Like } from "./extensions/likes.ts";
export { Vote } from "./extensions/polls.ts"; export { Vote } from "./extensions/polls.ts";
export { Reaction } from "./extensions/reactions.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 { NoteSchema } from "../schemas/note.ts";
import type { JSONObject } from "../types.ts"; import type { JSONObject } from "../types.ts";
import { NonTextContentFormat, TextContentFormat } from "./contentformat.ts"; import { NonTextContentFormat, TextContentFormat } from "./contentformat.ts";
import { Entity } from "./entity.ts"; import { Entity, Reference } from "./entity.ts";
export class Note extends Entity { export class Note extends Entity {
public static override name = "Note"; public static override name = "Note";
@ -15,6 +15,35 @@ export class Note extends Entity {
return NoteSchema.parseAsync(json).then((n) => new Note(n)); 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[] { public get attachments(): NonTextContentFormat[] {
return ( return (
this.data.attachments?.map((a) => new NonTextContentFormat(a)) ?? [] this.data.attachments?.map((a) => new NonTextContentFormat(a)) ?? []

View file

@ -1,10 +1,12 @@
import { sign } from "./crypto.ts"; import { sign } from "./crypto.ts";
import { Collection, URICollection } from "./entities/collection.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 { homepage, version } from "./package.json" with { type: "json" };
import { WebFingerSchema } from "./schemas/webfinger.ts"; import { WebFingerSchema } from "./schemas/webfinger.ts";
const DEFAULT_UA = `VersiaFederationClient/${version} (+${homepage})`; const DEFAULT_UA = `VersiaFederationClient/${version} (+${homepage})`;
const CONTENT_TYPE = "application/vnd.versia+json";
/** /**
* A class that handles fetching Versia entities * A class that handles fetching Versia entities
@ -22,22 +24,22 @@ const DEFAULT_UA = `VersiaFederationClient/${version} (+${homepage})`;
export class FederationRequester { export class FederationRequester {
public constructor( public constructor(
private readonly privateKey: CryptoKey, 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, url: URL,
expectedType: T, entityType: T,
): Promise<InstanceType<T>> { ): Promise<InstanceType<T>> {
const req = new Request(url, { const req = new Request(url, {
method: "GET", method: "GET",
headers: { headers: {
Accept: "application/json", Accept: CONTENT_TYPE,
"User-Agent": DEFAULT_UA, "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); const res = await fetch(finalReq);
@ -49,79 +51,116 @@ export class FederationRequester {
const contentType = res.headers.get("Content-Type"); 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( 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 jsonData = await res.json();
const type = jsonData.type; 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( 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>; 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, { const req = new Request(url, {
method: "POST", method: "POST",
headers: { headers: {
Accept: "application/json", Accept: CONTENT_TYPE,
"User-Agent": DEFAULT_UA, "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()), 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); return fetch(finalReq);
} }
/** /**
* Recursively go through a Collection of entities until reaching the end * Recursively go through a Collection of entities until reaching the end
* @param url URL to reach the Collection * @param reference Entity Reference
* @param expectedType * @param entityType
* @param collectionItemType
* @param options.limit Limit the number of entities to fetch * @param options.limit Limit the number of entities to fetch
*/ */
public async resolveCollection<T extends typeof Entity>( public async resolveCollection<
url: URL, E extends typeof Entity,
expectedType: T, T extends typeof Entity,
>(
reference: Reference,
collectionName: string,
entityType: E,
collectionItemType: T,
options?: { options?: {
limit?: number; limit?: number;
}, },
): Promise<InstanceType<T>[]> { ): 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>[] = []; const entities: InstanceType<T>[] = [];
let nextUrl: URL | null = url;
let limit = options?.limit ?? Number.POSITIVE_INFINITY; let limit = options?.limit ?? Number.POSITIVE_INFINITY;
while (nextUrl && limit > 0) { let collection = await this.fetchSigned(url, Collection);
const collection: Collection = await this.fetchEntity( const total = collection.data.total;
nextUrl,
Collection,
);
for (const entity of collection.data.items) { while (collection && limit > 0) {
if (entity.type === expectedType.name) {
entities.push( entities.push(
(await expectedType.fromJSON( ...collection.data.items.map(
entity, (item) =>
)) as InstanceType<T>, collectionItemType.fromJSON(item) as InstanceType<T>,
),
); );
} limit -= collection.data.items.length;
if (entities.length >= total) {
break;
} }
nextUrl = collection.data.next url.searchParams.set("offset", entities.length.toString());
? new URL(collection.data.next) collection = await this.fetchSigned(url, Collection);
: null;
limit -= collection.data.items.length;
} }
return entities; return entities;
@ -129,33 +168,46 @@ export class FederationRequester {
/** /**
* Recursively go through a URICollection of entities until reaching the end * 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 * @param options.limit Limit the number of entities to fetch
*/ */
public async resolveURICollection( public async resolveURICollection<E extends typeof Entity>(
url: URL, reference: Reference,
collectionName: string,
entityType: E,
options?: { options?: {
limit?: number; limit?: number;
}, },
): Promise<URL[]> { ): Promise<string[]> {
const entities: string[] = []; const url = new URL(
let nextUrl: URL | null = url; `/.versia/v0.6/entities/${encodeURIComponent(
let limit = options?.limit ?? Number.POSITIVE_INFINITY; entityType.name,
)}/${encodeURIComponent(reference.id)}/collections/${encodeURIComponent(
while (nextUrl && limit > 0) { collectionName,
const collection: URICollection = await this.fetchEntity( )}`,
nextUrl, `https://${reference.domain}`,
URICollection,
); );
entities.push(...collection.data.items); const uris: string[] = [];
nextUrl = collection.data.next let limit = options?.limit ?? Number.POSITIVE_INFINITY;
? new URL(collection.data.next)
: null; let collection = await this.fetchSigned(url, URICollection);
const total = collection.data.total;
while (collection && limit > 0) {
uris.push(...collection.data.items);
limit -= collection.data.items.length; limit -= collection.data.items.length;
if (uris.length >= total) {
break;
} }
return entities.map((u) => new URL(u)); url.searchParams.set("offset", uris.length.toString());
collection = await this.fetchSigned(url, URICollection);
}
return uris;
} }
/** /**
@ -164,21 +216,21 @@ export class FederationRequester {
*/ */
public static async resolveWebFinger( public static async resolveWebFinger(
username: string, username: string,
hostname: string, domain: string,
contentType = "application/json", contentType = "application/vnd.versia+json",
serverUrl = `https://${hostname}`, serverUrl = `https://${domain}`,
): Promise<URL | null> { ): Promise<URL | null> {
const res = await fetch( const res = await fetch(
new URL( new URL(
`/.well-known/webfinger?${new URLSearchParams({ `/.well-known/webfinger?${new URLSearchParams({
resource: `acct:${username}@${hostname}`, resource: `acct:${username}@${domain}`,
})}`, })}`,
serverUrl, serverUrl,
), ),
{ {
method: "GET", method: "GET",
headers: { headers: {
Accept: "application/json", Accept: "application/jrd+json, application/json",
"User-Agent": DEFAULT_UA, "User-Agent": DEFAULT_UA,
}, },
}, },
@ -204,4 +256,57 @@ export class FederationRequester {
return new URL(selfLink.href); 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", ".": "./inbox-processor.ts",
"./http": "./http.ts", "./http": "./http.ts",
"./crypto": "./crypto.ts", "./crypto": "./crypto.ts",
"./entities": "./entities.ts", "./entities": "./entities/index.ts",
"./schemas": "./schemas.ts" "./schemas": "./schemas/index.ts"
} }
} }

View file

@ -1,16 +1,13 @@
import { z } from "zod"; 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({ export const CollectionSchema = z.strictObject({
author: url.nullable(), author: ReferenceSchema.nullable(),
first: url,
last: url,
total: u64, total: u64,
next: url.nullable(),
previous: url.nullable(),
items: z.array(z.any()), items: z.array(z.any()),
}); });
export const URICollectionSchema = CollectionSchema.extend({ 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 { z } from "zod";
import { f64, u64 } from "./common.ts"; 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 allMimeTypes = Object.values(types) as [string, ...string[]];
const textMimeTypes = Object.values(types).filter((v) => const textMimeTypes = Object.values(types).filter((v) =>
v.startsWith("text/"), v.startsWith("text/"),
@ -46,16 +26,7 @@ export const ContentFormatSchema = z.partialRecord(
remote: z.boolean(), remote: z.boolean(),
description: z.string().nullish(), description: z.string().nullish(),
size: u64.nullish(), size: u64.nullish(),
hash: z hash: z.hash("sha256").nullish(),
.strictObject(
Object.fromEntries(
Object.entries(hashSizes).map(([k, v]) => [
k,
z.string().length(v).nullish(),
]),
),
)
.nullish(),
thumbhash: z.string().nullish(), thumbhash: z.string().nullish(),
width: u64.nullish(), width: u64.nullish(),
height: u64.nullish(), height: u64.nullish(),

View file

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

View file

@ -1,6 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { isISOString } from "../regex.ts"; import { isISOString } from "../regex.ts";
import { url } from "./common.ts";
import { CustomEmojiExtensionSchema } from "./extensions/emojis.ts"; import { CustomEmojiExtensionSchema } from "./extensions/emojis.ts";
export const ExtensionPropertySchema = z export const ExtensionPropertySchema = z
@ -10,14 +9,26 @@ export const ExtensionPropertySchema = z
}) })
.catchall(z.any()); .catchall(z.any());
export const ReferenceSchema = z.string();
export const EntitySchema = z.strictObject({ export const EntitySchema = z.strictObject({
// biome-ignore lint/style/useNamingConvention: required for JSON schema // biome-ignore lint/style/useNamingConvention: required for JSON schema
$schema: z.url().nullish(), $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 created_at: z
.string() .string()
.refine((v) => isISOString(v), "must be a valid ISO8601 datetime"), .refine((v) => isISOString(v), "must be a valid RFC 3339 datetime"),
uri: url,
type: z.string(), type: z.string(),
extensions: ExtensionPropertySchema.nullish(), extensions: ExtensionPropertySchema.nullish(),
}); });
export const TransientEntitySchema = EntitySchema.extend({
id: z.null().optional(),
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,7 +8,11 @@ export {
VideoContentFormatSchema, VideoContentFormatSchema,
} from "./contentformat.ts"; } from "./contentformat.ts";
export { DeleteSchema } from "./delete.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 { DislikeSchema, LikeSchema } from "./extensions/likes.ts";
export { VoteSchema } from "./extensions/polls.ts"; export { VoteSchema } from "./extensions/polls.ts";
export { ReactionSchema } from "./extensions/reactions.ts"; export { ReactionSchema } from "./extensions/reactions.ts";

View file

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

View file

@ -4,13 +4,13 @@ import {
NonTextContentFormatSchema, NonTextContentFormatSchema,
TextContentFormatSchema, TextContentFormatSchema,
} from "./contentformat.ts"; } from "./contentformat.ts";
import { EntitySchema } from "./entity.ts"; import { EntitySchema, ReferenceSchema } from "./entity.ts";
import { PollExtensionSchema } from "./extensions/polls.ts"; import { PollExtensionSchema } from "./extensions/polls.ts";
export const NoteSchema = EntitySchema.extend({ export const NoteSchema = EntitySchema.extend({
type: z.literal("Note"), type: z.literal("Note"),
attachments: z.array(NonTextContentFormatSchema).nullish(), attachments: z.array(NonTextContentFormatSchema),
author: url, author: ReferenceSchema,
category: z category: z
.enum([ .enum([
"microblog", "microblog",
@ -23,16 +23,6 @@ export const NoteSchema = EntitySchema.extend({
]) ])
.nullish(), .nullish(),
content: TextContentFormatSchema.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 device: z
.strictObject({ .strictObject({
name: z.string(), name: z.string(),
@ -40,11 +30,10 @@ export const NoteSchema = EntitySchema.extend({
url: url.nullish(), url: url.nullish(),
}) })
.nullish(), .nullish(),
group: url.or(z.enum(["public", "followers"])).nullish(), group: ReferenceSchema.or(z.enum(["public", "followers"])).nullish(),
is_sensitive: z.boolean().nullish(), is_sensitive: z.boolean(),
mentions: z.array(url).nullish(), mentions: z.array(ReferenceSchema),
previews: z previews: z.array(
.array(
z.strictObject({ z.strictObject({
link: url, link: url,
title: z.string(), title: z.string(),
@ -52,10 +41,9 @@ export const NoteSchema = EntitySchema.extend({
image: url.nullish(), image: url.nullish(),
icon: url.nullish(), icon: url.nullish(),
}), }),
) ),
.nullish(), quotes: ReferenceSchema.nullish(),
quotes: url.nullish(), replies_to: ReferenceSchema.nullish(),
replies_to: url.nullish(),
subject: z.string().nullish(), subject: z.string().nullish(),
extensions: EntitySchema.shape.extensions extensions: EntitySchema.shape.extensions
.unwrap() .unwrap()

View file

@ -1,5 +1,4 @@
import { z } from "zod"; import { z } from "zod";
import { url } from "./common.ts";
import { import {
ImageContentFormatSchema, ImageContentFormatSchema,
TextContentFormatSchema, TextContentFormatSchema,
@ -8,25 +7,17 @@ import { EntitySchema } from "./entity.ts";
import { MigrationExtensionSchema } from "./extensions/migration.ts"; import { MigrationExtensionSchema } from "./extensions/migration.ts";
import { VanityExtensionSchema } from "./extensions/vanity.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({ export const UserSchema = EntitySchema.extend({
type: z.literal("User"), type: z.literal("User"),
avatar: ImageContentFormatSchema.nullish(), avatar: ImageContentFormatSchema.nullish(),
bio: TextContentFormatSchema.nullish(), bio: TextContentFormatSchema.nullish(),
display_name: z.string().nullish(), display_name: z.string().nullish(),
fields: z fields: z.array(
.array(
z.strictObject({ z.strictObject({
key: TextContentFormatSchema, key: TextContentFormatSchema,
value: TextContentFormatSchema, value: TextContentFormatSchema,
}), }),
) ),
.nullish(),
username: z username: z
.string() .string()
.min(1) .min(1)
@ -35,20 +26,8 @@ export const UserSchema = EntitySchema.extend({
"must be alphanumeric, and may contain _ or -", "must be alphanumeric, and may contain _ or -",
), ),
header: ImageContentFormatSchema.nullish(), header: ImageContentFormatSchema.nullish(),
public_key: PublicKeyDataSchema, manually_approves_followers: z.boolean(),
manually_approves_followers: z.boolean().nullish(), indexable: z.boolean(),
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),
extensions: EntitySchema.shape.extensions extensions: EntitySchema.shape.extensions
.unwrap() .unwrap()
.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 = export type KnownEntity =
| VersiaEntities.Entity
| VersiaEntities.Note | VersiaEntities.Note
| VersiaEntities.InstanceMetadata | VersiaEntities.InstanceMetadata
| VersiaEntities.User | VersiaEntities.User

View file

@ -57,7 +57,12 @@ export const applyToHono = (app: Hono<HonoEnv>): void => {
throw new ApiError(401, "Missing JWT cookie"); 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; const { sub } = result;