Merge pull request #36 from versia-pub/refactor/federation
Some checks failed
CodeQL Scan / Analyze (javascript-typescript) (push) Failing after 54s
Build Docker Images / lint (push) Successful in 28s
Build Docker Images / check (push) Successful in 53s
Build Docker Images / tests (push) Failing after 6s
Build Docker Images / build (server, Dockerfile, ${{ github.repository_owner }}/server) (push) Has been skipped
Build Docker Images / build (worker, Worker.Dockerfile, ${{ github.repository_owner }}/worker) (push) Has been skipped
Deploy Docs to GitHub Pages / build (push) Failing after 12s
Mirror to Codeberg / Mirror (push) Failing after 0s
Deploy Docs to GitHub Pages / Deploy (push) Has been skipped
Nix Build / check (push) Failing after 32m28s

Refactor/federation
This commit is contained in:
Gaspard Wierzbinski 2025-04-09 02:18:14 +02:00 committed by GitHub
commit 7e44e55b3f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
85 changed files with 2753 additions and 1249 deletions

View file

@ -3,6 +3,7 @@ import { Account as AccountSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { User } from "~/classes/database/user";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) =>
@ -44,11 +45,11 @@ export default apiRoute((app) =>
async (context) => {
const otherUser = context.get("user");
if (otherUser.isLocal()) {
if (otherUser.local) {
throw new ApiError(400, "Cannot refetch a local user");
}
const newUser = await otherUser.updateFromRemote();
const newUser = await User.fromVersia(otherUser.uri);
return context.json(newUser.toApi(false), 200);
},

View file

@ -353,8 +353,7 @@ export default apiRoute((app) =>
);
}
await User.fromDataLocal({
username,
await User.register(username, {
password,
email,
});

View file

@ -49,7 +49,6 @@ export default apiRoute((app) =>
),
async (context) => {
const { acct } = context.req.valid("query");
const { user } = context.get("auth");
// Check if acct is matching format username@domain.com or @username@domain.com
const { username, domain } = parseUserAddress(acct);
@ -93,9 +92,7 @@ export default apiRoute((app) =>
}
// Fetch from remote instance
const manager = await (user ?? User).getFederationRequester();
const uri = await User.webFinger(manager, username, domain);
const uri = await User.webFinger(username, domain);
if (!uri) {
throw ApiError.accountNotFound();

View file

@ -91,9 +91,7 @@ export default apiRoute((app) =>
const accounts: User[] = [];
if (resolve && domain) {
const manager = await (user ?? User).getFederationRequester();
const uri = await User.webFinger(manager, username, domain);
const uri = await User.webFinger(username, domain);
if (uri) {
const resolvedUser = await User.resolve(uri);

View file

@ -13,6 +13,7 @@ import { ApiError } from "~/classes/errors/api-error";
import { contentToHtml } from "~/classes/functions/status";
import { config } from "~/config.ts";
import { rateLimit } from "~/middlewares/rate-limit";
import * as VersiaEntities from "~/packages/sdk/entities";
export default apiRoute((app) =>
app.patch(
@ -175,6 +176,15 @@ export default apiRoute((app) =>
} = context.req.valid("json");
const self = user.data;
if (!self.source) {
self.source = {
fields: [],
privacy: "public",
language: "en",
sensitive: false,
note: "",
};
}
const sanitizedDisplayName = await sanitizedHtmlStrip(
display_name ?? "",
@ -184,26 +194,25 @@ export default apiRoute((app) =>
self.displayName = sanitizedDisplayName;
}
if (note && self.source) {
if (note) {
self.source.note = note;
self.note = await contentToHtml({
"text/markdown": {
content: note,
remote: false,
},
});
self.note = await contentToHtml(
new VersiaEntities.TextContentFormat({
"text/markdown": {
content: note,
remote: false,
},
}),
);
}
if (source?.privacy) {
self.source.privacy = source.privacy;
}
if (source?.sensitive) {
self.source.sensitive = source.sensitive;
}
if (source?.language) {
self.source.language = source.language;
if (source) {
self.source = {
...self.source,
privacy: source.privacy ?? self.source.privacy,
sensitive: source.sensitive ?? self.source.sensitive,
language: source.language ?? self.source.language,
};
}
if (username) {
@ -275,23 +284,23 @@ export default apiRoute((app) =>
for (const field of fields_attributes) {
// Can be Markdown or plaintext, also has emojis
const parsedName = await contentToHtml(
{
new VersiaEntities.TextContentFormat({
"text/markdown": {
content: field.name,
remote: false,
},
},
}),
undefined,
true,
);
const parsedValue = await contentToHtml(
{
new VersiaEntities.TextContentFormat({
"text/markdown": {
content: field.value,
remote: false,
},
},
}),
undefined,
true,
);

View file

@ -28,7 +28,7 @@ describe("/api/v1/accounts/verify_credentials", () => {
expect(data.id).toBe(users[0].id);
expect(data.username).toBe(users[0].data.username);
expect(data.acct).toBe(users[0].data.username);
expect(data.display_name).toBe(users[0].data.displayName);
expect(data.display_name).toBe(users[0].data.displayName ?? "");
expect(data.note).toBe(users[0].data.note);
expect(data.url).toBe(
new URL(

View file

@ -71,9 +71,9 @@ export default apiRoute((app) =>
);
// Check if accepting remote follow
if (account.isRemote()) {
if (account.remote) {
// Federate follow accept
await user.sendFollowAccept(account);
await user.acceptFollowRequest(account);
}
return context.json(foundRelationship.toApi(), 200);

View file

@ -72,9 +72,9 @@ export default apiRoute((app) =>
);
// Check if rejecting remote follow
if (account.isRemote()) {
if (account.remote) {
// Federate follow reject
await user.sendFollowReject(account);
await user.rejectFollowRequest(account);
}
return context.json(foundRelationship.toApi(), 200);

View file

@ -5,6 +5,7 @@ import {
jsonOrForm,
withNoteParam,
} from "@/api";
import { sanitizedHtmlStrip } from "@/sanitization";
import {
Attachment as AttachmentSchema,
PollOption,
@ -13,12 +14,14 @@ import {
zBoolean,
} from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Media } from "@versia/kit/db";
import { Emoji, Media } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import { contentToHtml, parseTextMentions } from "~/classes/functions/status";
import { config } from "~/config.ts";
import * as VersiaEntities from "~/packages/sdk/entities";
const schema = z
.object({
@ -225,22 +228,50 @@ export default apiRoute((app) => {
);
}
const newNote = await note.updateFromData({
author: user,
content: statusText
? {
[content_type]: {
content: statusText,
remote: false,
},
}
const sanitizedSpoilerText = spoiler_text
? await sanitizedHtmlStrip(spoiler_text)
: undefined;
const content = statusText
? new VersiaEntities.TextContentFormat({
[content_type]: {
content: statusText,
remote: false,
},
})
: undefined;
const parsedMentions = statusText
? await parseTextMentions(statusText)
: [];
const parsedEmojis = statusText
? await Emoji.parseFromText(statusText)
: [];
await note.update({
spoilerText: sanitizedSpoilerText,
sensitive,
content: content
? await contentToHtml(content, parsedMentions)
: undefined,
isSensitive: sensitive,
spoilerText: spoiler_text,
mediaAttachments: foundAttachments,
});
return context.json(await newNote.toApi(user), 200);
// Emojis, mentions, and attachments are stored in a different table, so update them there too
await note.updateEmojis(parsedEmojis);
await note.updateMentions(parsedMentions);
await note.updateAttachments(foundAttachments);
await note.reload();
// Send notifications for mentioned local users
for (const mentioned of parsedMentions) {
if (mentioned.local) {
await mentioned.notify("mention", user, note);
}
}
return context.json(await note.toApi(user), 200);
},
);
});

View file

@ -83,7 +83,7 @@ export default apiRoute((app) =>
throw new Error("Failed to reblog");
}
if (note.author.isLocal() && user.isLocal()) {
if (note.author.local && user.local) {
await note.author.notify("reblog", user, newReblog);
}

View file

@ -218,7 +218,7 @@ describe("/api/v1/statuses", () => {
expect(ok).toBe(true);
expect(data).toMatchObject({
content: `<p>Hello, <a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${users[1].getUri()}">@${users[1].data.username}</a>!</p>`,
content: `<p>Hello, <a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${users[1].uri.href}">@${users[1].data.username}</a>!</p>`,
});
expect((data as z.infer<typeof Status>).mentions).toBeArrayOfSize(
1,
@ -241,7 +241,7 @@ describe("/api/v1/statuses", () => {
expect(ok).toBe(true);
expect(data).toMatchObject({
content: `<p>Hello, <a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${users[1].getUri()}">@${users[1].data.username}</a>!</p>`,
content: `<p>Hello, <a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${users[1].uri.href}">@${users[1].data.username}</a>!</p>`,
});
expect((data as z.infer<typeof Status>).mentions).toBeArrayOfSize(
1,

View file

@ -1,4 +1,5 @@
import { apiRoute, auth, handleZodError, jsonOrForm } from "@/api";
import { sanitizedHtmlStrip } from "@/sanitization";
import {
Attachment as AttachmentSchema,
PollOption,
@ -7,12 +8,15 @@ import {
zBoolean,
} from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Media, Note } from "@versia/kit/db";
import { Emoji, Media, Note } from "@versia/kit/db";
import { randomUUIDv7 } from "bun";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import { contentToHtml, parseTextMentions } from "~/classes/functions/status";
import { config } from "~/config.ts";
import * as VersiaEntities from "~/packages/sdk/entities";
const schema = z
.object({
@ -174,27 +178,59 @@ export default apiRoute((app) =>
);
}
const newNote = await Note.fromData({
author: user,
content: {
[content_type]: {
content: status ?? "",
remote: false,
},
},
const sanitizedSpoilerText = spoiler_text
? await sanitizedHtmlStrip(spoiler_text)
: undefined;
const content = status
? new VersiaEntities.TextContentFormat({
[content_type]: {
content: status,
remote: false,
},
})
: undefined;
const parsedMentions = status
? await parseTextMentions(status)
: [];
const parsedEmojis = status
? await Emoji.parseFromText(status)
: [];
const newNote = await Note.insert({
id: randomUUIDv7(),
authorId: user.id,
visibility,
isSensitive: sensitive ?? false,
spoilerText: spoiler_text ?? "",
mediaAttachments: foundAttachments,
content: content
? await contentToHtml(content, parsedMentions)
: undefined,
sensitive,
spoilerText: sanitizedSpoilerText,
replyId: in_reply_to_id ?? undefined,
quoteId: quote_id ?? undefined,
application: application ?? undefined,
quotingId: quote_id ?? undefined,
applicationId: application?.id,
});
// Emojis, mentions, and attachments are stored in a different table, so update them there too
await newNote.updateEmojis(parsedEmojis);
await newNote.updateMentions(parsedMentions);
await newNote.updateAttachments(foundAttachments);
await newNote.reload();
if (!local_only) {
await newNote.federateToUsers();
}
// Send notifications for mentioned local users
for (const mentioned of parsedMentions) {
if (mentioned.local) {
await mentioned.notify("mention", user, newNote);
}
}
return context.json(await newNote.toApi(user), 200);
},
),

View file

@ -198,15 +198,7 @@ export default apiRoute((app) =>
}
if (resolve && domain) {
const manager = await (
user ?? User
).getFederationRequester();
const uri = await User.webFinger(
manager,
username,
domain,
);
const uri = await User.webFinger(username, domain);
if (uri) {
const newUser = await User.resolve(uri);

View file

@ -1,5 +1,4 @@
import { apiRoute, handleZodError } from "@/api";
import type { Entity } from "@versia/federation/types";
import { describeRoute } from "hono-openapi";
import { validator } from "hono-openapi/zod";
import { z } from "zod";
@ -33,7 +32,7 @@ export default apiRoute((app) =>
handleZodError,
),
async (context) => {
const body: Entity = await context.req.valid("json");
const body = await context.req.valid("json");
await inboxQueue.add(InboxJobType.ProcessEntity, {
data: body,

View file

@ -1,6 +1,5 @@
import { apiRoute, handleZodError } from "@/api";
import { Status as StatusSchema } from "@versia/client/schemas";
import { LikeExtension as LikeSchema } from "@versia/federation/schemas";
import { Like, User } from "@versia/kit/db";
import { Likes } from "@versia/kit/tables";
import { and, eq, sql } from "drizzle-orm";
@ -9,6 +8,7 @@ import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts";
import { LikeSchema } from "~/packages/sdk/schemas";
export default apiRoute((app) =>
app.get(
@ -59,7 +59,7 @@ export default apiRoute((app) =>
const liker = await User.fromId(like.data.likerId);
if (!liker || liker.isRemote()) {
if (!liker || liker.remote) {
throw ApiError.accountNotFound();
}

View file

@ -1,6 +1,5 @@
import { apiRoute, handleZodError } from "@/api";
import { Status as StatusSchema } from "@versia/client/schemas";
import { Note as NoteSchema } from "@versia/federation/schemas";
import { Note } from "@versia/kit/db";
import { Notes } from "@versia/kit/tables";
import { and, eq, inArray } from "drizzle-orm";
@ -9,6 +8,7 @@ import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts";
import { NoteSchema } from "~/packages/sdk/schemas";
export default apiRoute((app) =>
app.get(
@ -53,10 +53,7 @@ export default apiRoute((app) =>
),
);
if (
!(note && (await note.isViewableByUser(null))) ||
note.isRemote()
) {
if (!(note && (await note.isViewableByUser(null))) || note.remote) {
throw ApiError.noteNotFound();
}

View file

@ -1,7 +1,5 @@
import { apiRoute, handleZodError } from "@/api";
import { Status as StatusSchema } from "@versia/client/schemas";
import { URICollection as URICollectionSchema } from "@versia/federation/schemas";
import type { URICollection } from "@versia/federation/types";
import { Note, db } from "@versia/kit/db";
import { Notes } from "@versia/kit/tables";
import { and, eq, inArray } from "drizzle-orm";
@ -10,6 +8,8 @@ import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts";
import * as VersiaEntities from "~/packages/sdk/entities";
import { URICollectionSchema } from "~/packages/sdk/schemas";
export default apiRoute((app) =>
app.get(
@ -63,10 +63,7 @@ export default apiRoute((app) =>
),
);
if (
!(note && (await note.isViewableByUser(null))) ||
note.isRemote()
) {
if (!(note && (await note.isViewableByUser(null))) || note.remote) {
throw ApiError.noteNotFound();
}
@ -88,39 +85,45 @@ export default apiRoute((app) =>
),
);
const uriCollection = {
author: note.author.getUri().href,
const uriCollection = new VersiaEntities.URICollection({
author: note.author.uri,
first: new URL(
`/notes/${note.id}/quotes?offset=0`,
config.http.base_url,
).href,
),
last:
replyCount > limit
? new URL(
`/notes/${note.id}/quotes?offset=${replyCount - limit}`,
`/notes/${note.id}/quotes?offset=${
replyCount - limit
}`,
config.http.base_url,
).href
)
: new URL(
`/notes/${note.id}/quotes`,
config.http.base_url,
).href,
),
next:
offset + limit < replyCount
? new URL(
`/notes/${note.id}/quotes?offset=${offset + limit}`,
`/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}`,
`/notes/${note.id}/quotes?offset=${
offset - limit
}`,
config.http.base_url,
).href
)
: null,
total: replyCount,
items: replies.map((reply) => reply.getUri().href),
} satisfies URICollection;
items: replies.map((reply) => reply.getUri()),
});
// If base_url uses https and request uses http, rewrite request to use https
// This fixes reverse proxy errors

View file

@ -1,7 +1,5 @@
import { apiRoute, handleZodError } from "@/api";
import { Status as StatusSchema } from "@versia/client/schemas";
import { URICollection as URICollectionSchema } from "@versia/federation/schemas";
import type { URICollection } from "@versia/federation/types";
import { Note, db } from "@versia/kit/db";
import { Notes } from "@versia/kit/tables";
import { and, eq, inArray } from "drizzle-orm";
@ -10,6 +8,8 @@ import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts";
import * as VersiaEntities from "~/packages/sdk/entities";
import { URICollectionSchema } from "~/packages/sdk/schemas";
export default apiRoute((app) =>
app.get(
@ -61,10 +61,7 @@ export default apiRoute((app) =>
),
);
if (
!(note && (await note.isViewableByUser(null))) ||
note.isRemote()
) {
if (!(note && (await note.isViewableByUser(null))) || note.remote) {
throw ApiError.noteNotFound();
}
@ -86,39 +83,45 @@ export default apiRoute((app) =>
),
);
const uriCollection = {
author: note.author.getUri().href,
const uriCollection = new VersiaEntities.URICollection({
author: note.author.uri,
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}`,
`/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}`,
`/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}`,
`/notes/${note.id}/replies?offset=${
offset - limit
}`,
config.http.base_url,
).href
)
: null,
total: replyCount,
items: replies.map((reply) => reply.getUri().href),
} satisfies URICollection;
items: replies.map((reply) => reply.getUri()),
});
// If base_url uses https and request uses http, rewrite request to use https
// This fixes reverse proxy errors

View file

@ -1,10 +1,10 @@
import { apiRoute, handleZodError } from "@/api";
import type { Entity } from "@versia/federation/types";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import { InboxJobType, inboxQueue } from "~/classes/queues/inbox";
import type { JSONObject } from "~/packages/sdk/types";
export default apiRoute((app) =>
app.post(
@ -89,7 +89,7 @@ export default apiRoute((app) =>
),
validator("json", z.any(), handleZodError),
async (context) => {
const body: Entity = await context.req.valid("json");
const body: JSONObject = await context.req.valid("json");
await inboxQueue.add(InboxJobType.ProcessEntity, {
data: body,

View file

@ -1,10 +1,10 @@
import { apiRoute, handleZodError } from "@/api";
import { User as UserSchema } from "@versia/federation/schemas";
import { User } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import { UserSchema } from "~/packages/sdk/schemas";
export default apiRoute((app) =>
app.get(
@ -43,6 +43,7 @@ export default apiRoute((app) =>
}),
handleZodError,
),
// @ts-expect-error idk why this is happening and I don't care
async (context) => {
const { uuid } = context.req.valid("param");
@ -52,7 +53,7 @@ export default apiRoute((app) =>
throw ApiError.accountNotFound();
}
if (user.isRemote()) {
if (user.remote) {
throw new ApiError(403, "User is not on this instance");
}

View file

@ -1,8 +1,4 @@
import { apiRoute, handleZodError } from "@/api";
import {
Collection as CollectionSchema,
Note as NoteSchema,
} from "@versia/federation/schemas";
import { Note, User, db } from "@versia/kit/db";
import { Notes } from "@versia/kit/tables";
import { and, eq, inArray } from "drizzle-orm";
@ -11,6 +7,8 @@ import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts";
import * as VersiaEntities from "~/packages/sdk/entities";
import { CollectionSchema, NoteSchema } from "~/packages/sdk/schemas";
const NOTES_PER_PAGE = 20;
@ -72,7 +70,7 @@ export default apiRoute((app) =>
throw new ApiError(404, "User not found");
}
if (author.isRemote()) {
if (author.remote) {
throw new ApiError(403, "User is not on this instance");
}
@ -96,35 +94,35 @@ export default apiRoute((app) =>
),
);
const json = {
const json = new VersiaEntities.Collection({
first: new URL(
`/users/${uuid}/outbox?page=1`,
config.http.base_url,
).toString(),
),
last: new URL(
`/users/${uuid}/outbox?page=${Math.ceil(
totalNotes / NOTES_PER_PAGE,
)}`,
config.http.base_url,
).toString(),
),
total: totalNotes,
author: author.getUri().toString(),
author: author.uri,
next:
notes.length === NOTES_PER_PAGE
? new URL(
`/users/${uuid}/outbox?page=${pageNumber + 1}`,
config.http.base_url,
).toString()
)
: null,
previous:
pageNumber > 1
? new URL(
`/users/${uuid}/outbox?page=${pageNumber - 1}`,
config.http.base_url,
).toString()
)
: null,
items: notes.map((note) => note.toVersia()),
};
});
const { headers } = await author.sign(
json,

View file

@ -1,6 +1,5 @@
import { apiRoute } from "@/api";
import { urlToContentFormat } from "@/content_types";
import { InstanceMetadata as InstanceMetadataSchema } from "@versia/federation/schemas";
import { User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { asc } from "drizzle-orm";
@ -8,6 +7,7 @@ import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { config } from "~/config.ts";
import pkg from "~/package.json";
import { InstanceMetadataSchema } from "~/packages/sdk/schemas";
export default apiRoute((app) =>
app.get(

View file

@ -6,16 +6,16 @@ import {
webfingerMention,
} from "@/api";
import { getLogger } from "@logtape/logtape";
import type { ResponseError } from "@versia/federation";
import { WebFinger } from "@versia/federation/schemas";
import { User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { FederationRequester } from "@versia/sdk/http";
import { and, eq, isNull } from "drizzle-orm";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts";
import { WebFingerSchema } from "~/packages/sdk/schemas";
export default apiRoute((app) =>
app.get(
@ -28,7 +28,7 @@ export default apiRoute((app) =>
description: "User information",
content: {
"application/json": {
schema: resolver(WebFinger),
schema: resolver(WebFingerSchema),
},
},
},
@ -81,29 +81,29 @@ export default apiRoute((app) =>
throw ApiError.accountNotFound();
}
let activityPubUrl = "";
let activityPubUrl: URL | null = null;
if (config.federation.bridge) {
const manager = await User.getFederationRequester();
try {
activityPubUrl = await manager.webFinger(
activityPubUrl = await FederationRequester.resolveWebFinger(
user.data.username,
config.http.base_url.host,
"application/activity+json",
config.federation.bridge.url.origin,
);
} catch (e) {
const error = e as ResponseError;
const error = e as ApiError;
getLogger(["federation", "bridge"])
.error`Error from bridge: ${await error.response.data}`;
.error`Error from bridge: ${error.message}`;
}
}
return context.json(
{
subject: `acct:${isUuid ? user.id : user.data.username}@${host}`,
subject: `acct:${
isUuid ? user.id : user.data.username
}@${host}`,
links: [
// Keep the ActivityPub link first, because Misskey only searches
@ -112,7 +112,7 @@ export default apiRoute((app) =>
? {
rel: "self",
type: "application/activity+json",
href: activityPubUrl,
href: activityPubUrl.href,
}
: undefined,
{

View file

@ -20,8 +20,8 @@
"@scalar/hono-api-reference": "^0.8.0",
"@sentry/bun": "^9.11.0",
"@versia/client": "workspace:*",
"@versia/federation": "^0.2.1",
"@versia/kit": "workspace:*",
"@versia/sdk": "workspace:*",
"altcha-lib": "^1.2.0",
"blurhash": "^2.0.5",
"bullmq": "^5.47.2",
@ -105,6 +105,10 @@
"zod-validation-error": "^3.3.0",
},
},
"packages/sdk": {
"name": "@versia/sdk",
"version": "0.0.1",
},
},
"trustedDependencies": [
"sharp",
@ -568,10 +572,10 @@
"@versia/client": ["@versia/client@workspace:packages/client"],
"@versia/federation": ["@versia/federation@0.2.1", "", { "dependencies": { "magic-regexp": "^0.8.0", "mime-types": "^2.1.35", "zod": "^3.24.1", "zod-validation-error": "^3.4.0" } }, "sha512-FTo3VGNJBGmCi0ZEQMzqFZBbcfbX81kmg0UgY4cKamr1dJWgEf72IAZnEDgrBffFjYtreLGdEjFkkcq3JfS8oQ=="],
"@versia/kit": ["@versia/kit@workspace:packages/plugin-kit"],
"@versia/sdk": ["@versia/sdk@workspace:packages/sdk"],
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.3", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-IYSLEQj4LgZZuoVpdSUCw3dIynTWQgPlaRP6iAvMle4My0HdYwr5g5wQAfwOeHQBmYwEkqF70nRpSilr6PoUDg=="],
"@vue/compiler-core": ["@vue/compiler-core@3.5.13", "", { "dependencies": { "@babel/parser": "^7.25.3", "@vue/shared": "3.5.13", "entities": "^4.5.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.0" } }, "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q=="],
@ -1412,8 +1416,6 @@
"@ts-morph/common/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"@versia/federation/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
@ -1532,8 +1534,6 @@
"@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"@versia/federation/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"cheerio-select/domutils/dom-serializer": ["dom-serializer@1.4.1", "", { "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", "entities": "^2.0.0" } }, "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag=="],
"cheerio/dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],

View file

@ -1,6 +1,5 @@
import { emojiValidatorWithColons, emojiValidatorWithIdentifiers } from "@/api";
import type { CustomEmoji } from "@versia/client/schemas";
import type { CustomEmojiExtension } from "@versia/federation/types";
import { type Instance, Media, db } from "@versia/kit/db";
import { Emojis, type Instances, type Medias } from "@versia/kit/tables";
import { randomUUIDv7 } from "bun";
@ -15,6 +14,8 @@ import {
isNull,
} from "drizzle-orm";
import type { z } from "zod";
import * as VersiaEntities from "~/packages/sdk/entities/index.ts";
import type { ImageContentFormatSchema } from "~/packages/sdk/schemas/index.ts";
import { BaseInterface } from "./base.ts";
type EmojiType = InferSelectModel<typeof Emojis> & {
@ -130,7 +131,10 @@ export class Emoji extends BaseInterface<typeof Emojis, EmojiType> {
}
public static async fetchFromRemote(
emojiToFetch: CustomEmojiExtension["emojis"][0],
emojiToFetch: {
name: string;
url: z.infer<typeof ImageContentFormatSchema>;
},
instance: Instance,
): Promise<Emoji> {
const existingEmoji = await Emoji.fromSql(
@ -189,15 +193,23 @@ export class Emoji extends BaseInterface<typeof Emojis, EmojiType> {
};
}
public toVersia(): CustomEmojiExtension["emojis"][0] {
public toVersia(): {
name: string;
url: z.infer<typeof ImageContentFormatSchema>;
} {
return {
name: `:${this.data.shortcode}:`,
url: this.media.toVersia(),
url: this.media.toVersia().data as z.infer<
typeof ImageContentFormatSchema
>,
};
}
public static async fromVersia(
emoji: CustomEmojiExtension["emojis"][0],
emoji: {
name: string;
url: z.infer<typeof ImageContentFormatSchema>;
},
instance: Instance,
): Promise<Emoji> {
// Extracts the shortcode from the emoji name (e.g. :shortcode: -> shortcode)
@ -209,7 +221,9 @@ export class Emoji extends BaseInterface<typeof Emojis, EmojiType> {
throw new Error("Could not extract shortcode from emoji name");
}
const media = await Media.fromVersia(emoji.url);
const media = await Media.fromVersia(
new VersiaEntities.ImageContentFormat(emoji.url),
);
return Emoji.insert({
id: randomUUIDv7(),

View file

@ -1,6 +1,4 @@
import { getLogger } from "@logtape/logtape";
import { EntityValidator, type ResponseError } from "@versia/federation";
import type { InstanceMetadata } from "@versia/federation/types";
import { db } from "@versia/kit/db";
import { Instances } from "@versia/kit/tables";
import { randomUUIDv7 } from "bun";
@ -14,6 +12,7 @@ import {
inArray,
} from "drizzle-orm";
import { config } from "~/config.ts";
import * as VersiaEntities from "~/packages/sdk/entities/index.ts";
import { ApiError } from "../errors/api-error.ts";
import { BaseInterface } from "./base.ts";
import { User } from "./user.ts";
@ -137,24 +136,20 @@ export class Instance extends BaseInterface<typeof Instances> {
}
public static async fetchMetadata(url: URL): Promise<{
metadata: InstanceMetadata;
metadata: VersiaEntities.InstanceMetadata;
protocol: "versia" | "activitypub";
}> {
const origin = new URL(url).origin;
const wellKnownUrl = new URL("/.well-known/versia", origin);
const requester = await User.getFederationRequester();
try {
const metadata = await User.federationRequester.fetchEntity(
wellKnownUrl,
VersiaEntities.InstanceMetadata,
);
const { ok, raw, data } = await requester
.get(wellKnownUrl, {
// @ts-expect-error Bun extension
proxy: config.http.proxy_address,
})
.catch((e) => ({
...(e as ResponseError).response,
}));
if (!(ok && raw.headers.get("content-type")?.includes("json"))) {
return { metadata, protocol: "versia" };
} catch {
// If the server doesn't have a Versia well-known endpoint, it's not a Versia instance
// Try to resolve ActivityPub metadata instead
const data = await Instance.fetchActivityPubMetadata(url);
@ -171,57 +166,35 @@ export class Instance extends BaseInterface<typeof Instances> {
protocol: "activitypub",
};
}
try {
const metadata = await new EntityValidator().InstanceMetadata(data);
return { metadata, protocol: "versia" };
} catch {
throw new ApiError(
404,
`Instance at ${origin} has invalid metadata`,
);
}
}
private static async fetchActivityPubMetadata(
url: URL,
): Promise<InstanceMetadata | null> {
): Promise<VersiaEntities.InstanceMetadata | null> {
const origin = new URL(url).origin;
const wellKnownUrl = new URL("/.well-known/nodeinfo", origin);
// Go to endpoint, then follow the links to the actual metadata
const logger = getLogger(["federation", "resolvers"]);
const requester = await User.getFederationRequester();
try {
const {
raw: response,
ok,
data: wellKnown,
} = await requester
.get<{
links: { rel: string; href: string }[];
}>(wellKnownUrl, {
// @ts-expect-error Bun extension
proxy: config.http.proxy_address,
})
.catch((e) => ({
...(
e as ResponseError<{
links: { rel: string; href: string }[];
}>
).response,
}));
const { json, ok, status } = await fetch(wellKnownUrl, {
// @ts-expect-error Bun extension
proxy: config.http.proxy_address,
});
if (!ok) {
logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
origin,
)} - HTTP ${response.status}`;
)} - HTTP ${status}`;
return null;
}
const wellKnown = (await json()) as {
links: { rel: string; href: string }[];
};
if (!wellKnown.links) {
logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
origin,
@ -243,44 +216,32 @@ export class Instance extends BaseInterface<typeof Instances> {
}
const {
raw: metadataResponse,
json: json2,
ok: ok2,
data: metadata,
} = await requester
.get<{
metadata: {
nodeName?: string;
title?: string;
nodeDescription?: string;
description?: string;
};
software: { version: string };
}>(metadataUrl.href, {
// @ts-expect-error Bun extension
proxy: config.http.proxy_address,
})
.catch((e) => ({
...(
e as ResponseError<{
metadata: {
nodeName?: string;
title?: string;
nodeDescription?: string;
description?: string;
};
software: { version: string };
}>
).response,
}));
status: status2,
} = await fetch(metadataUrl.href, {
// @ts-expect-error Bun extension
proxy: config.http.proxy_address,
});
if (!ok2) {
logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
origin,
)} - HTTP ${metadataResponse.status}`;
)} - HTTP ${status2}`;
return null;
}
return {
const metadata = (await json2()) as {
metadata: {
nodeName?: string;
title?: string;
nodeDescription?: string;
description?: string;
};
software: { version: string };
};
return new VersiaEntities.InstanceMetadata({
name:
metadata.metadata.nodeName || metadata.metadata.title || "",
description:
@ -301,7 +262,7 @@ export class Instance extends BaseInterface<typeof Instances> {
extensions: [],
versions: [],
},
};
});
} catch (error) {
logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
origin,
@ -340,13 +301,13 @@ export class Instance extends BaseInterface<typeof Instances> {
return Instance.insert({
id: randomUUIDv7(),
baseUrl: host,
name: metadata.name,
version: metadata.software.version,
logo: metadata.logo,
name: metadata.data.name,
version: metadata.data.software.version,
logo: metadata.data.logo,
protocol,
publicKey: metadata.public_key,
inbox: metadata.shared_inbox ?? null,
extensions: metadata.extensions ?? null,
publicKey: metadata.data.public_key,
inbox: metadata.data.shared_inbox?.href ?? null,
extensions: metadata.data.extensions ?? null,
});
}
@ -358,20 +319,22 @@ export class Instance extends BaseInterface<typeof Instances> {
);
if (!output) {
logger.error`Failed to update instance ${chalk.bold(this.data.baseUrl)}`;
logger.error`Failed to update instance ${chalk.bold(
this.data.baseUrl,
)}`;
throw new Error("Failed to update instance");
}
const { metadata, protocol } = output;
await this.update({
name: metadata.name,
version: metadata.software.version,
logo: metadata.logo,
name: metadata.data.name,
version: metadata.data.software.version,
logo: metadata.data.logo,
protocol,
publicKey: metadata.public_key,
inbox: metadata.shared_inbox ?? null,
extensions: metadata.extensions ?? null,
publicKey: metadata.data.public_key,
inbox: metadata.data.shared_inbox?.href ?? null,
extensions: metadata.data.extensions ?? null,
});
return this;

View file

@ -1,4 +1,3 @@
import type { Delete, LikeExtension } from "@versia/federation/types";
import { db } from "@versia/kit/db";
import {
Likes,
@ -16,6 +15,7 @@ import {
inArray,
} from "drizzle-orm";
import { config } from "~/config.ts";
import * as VersiaEntities from "~/packages/sdk/entities/index.ts";
import { BaseInterface } from "./base.ts";
import { User } from "./user.ts";
@ -149,25 +149,24 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
return new URL(`/likes/${this.data.id}`, config.http.base_url);
}
public toVersia(): LikeExtension {
return {
public toVersia(): VersiaEntities.Like {
return new VersiaEntities.Like({
id: this.data.id,
author: User.getUri(
this.data.liker.id,
this.data.liker.uri ? new URL(this.data.liker.uri) : null,
).toString(),
),
type: "pub.versia:likes/Like",
created_at: new Date(this.data.createdAt).toISOString(),
liked:
this.data.liked.uri ??
new URL(`/notes/${this.data.liked.id}`, config.http.base_url)
.href,
uri: this.getUri().toString(),
};
liked: this.data.liked.uri
? new URL(this.data.liked.uri)
: new URL(`/notes/${this.data.liked.id}`, config.http.base_url),
uri: this.getUri(),
});
}
public unlikeToVersia(unliker?: User): Delete {
return {
public unlikeToVersia(unliker?: User): VersiaEntities.Delete {
return new VersiaEntities.Delete({
type: "Delete",
id: crypto.randomUUID(),
created_at: new Date().toISOString(),
@ -178,9 +177,9 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
: this.data.liker.uri
? new URL(this.data.liker.uri)
: null,
).toString(),
),
deleted_type: "pub.versia:likes/Like",
deleted: this.getUri().toString(),
};
deleted: this.getUri(),
});
}
}

View file

@ -1,7 +1,6 @@
import { join } from "node:path";
import { mimeLookup } from "@/content_types.ts";
import type { Attachment as AttachmentSchema } from "@versia/client/schemas";
import type { ContentFormat } from "@versia/federation/types";
import { db } from "@versia/kit/db";
import { Medias } from "@versia/kit/tables";
import { S3Client, SHA256, randomUUIDv7, write } from "bun";
@ -17,6 +16,11 @@ import sharp from "sharp";
import type { z } from "zod";
import { MediaBackendType } from "~/classes/config/schema.ts";
import { config } from "~/config.ts";
import * as VersiaEntities from "~/packages/sdk/entities/index.ts";
import type {
ContentFormatSchema,
ImageContentFormatSchema,
} from "~/packages/sdk/schemas/index.ts";
import { ApiError } from "../errors/api-error.ts";
import { getMediaHash } from "../media/media-hasher.ts";
import { ProxiableUrl } from "../media/url.ts";
@ -202,7 +206,9 @@ export class Media extends BaseInterface<typeof Medias> {
const newAttachment = await Media.insert({
id: randomUUIDv7(),
content,
thumbnail: thumbnailContent,
thumbnail: thumbnailContent as z.infer<
typeof ImageContentFormatSchema
>,
});
if (config.media.conversion.convert_images) {
@ -234,7 +240,7 @@ export class Media extends BaseInterface<typeof Medias> {
): Promise<Media> {
const mimeType = await mimeLookup(uri);
const content: ContentFormat = {
const content: z.infer<typeof ContentFormatSchema> = {
[mimeType]: {
content: uri.toString(),
remote: true,
@ -272,7 +278,9 @@ export class Media extends BaseInterface<typeof Medias> {
throw new ApiError(
415,
`File type ${file.type} is not allowed`,
`Allowed types: ${config.validation.media.allowed_mime_types.join(", ")}`,
`Allowed types: ${config.validation.media.allowed_mime_types.join(
", ",
)}`,
);
}
}
@ -303,7 +311,7 @@ export class Media extends BaseInterface<typeof Medias> {
public async updateFromUrl(uri: URL): Promise<void> {
const mimeType = await mimeLookup(uri);
const content: ContentFormat = {
const content: z.infer<typeof ContentFormatSchema> = {
[mimeType]: {
content: uri.toString(),
remote: true,
@ -333,12 +341,19 @@ export class Media extends BaseInterface<typeof Medias> {
const content = await Media.fileToContentFormat(file, url);
await this.update({
thumbnail: content,
thumbnail: content as z.infer<typeof ImageContentFormatSchema>,
});
}
public async updateMetadata(
metadata: Partial<Omit<ContentFormat[keyof ContentFormat], "content">>,
metadata: Partial<
Omit<
z.infer<typeof ContentFormatSchema>[keyof z.infer<
typeof ContentFormatSchema
>],
"content"
>
>,
): Promise<void> {
const content = this.data.content;
@ -447,7 +462,7 @@ export class Media extends BaseInterface<typeof Medias> {
options?: Partial<{
description: string;
}>,
): Promise<ContentFormat> {
): Promise<z.infer<typeof ContentFormatSchema>> {
const buffer = await file.arrayBuffer();
const isImage = file.type.startsWith("image/");
const { width, height } = isImage ? await sharp(buffer).metadata() : {};
@ -521,15 +536,17 @@ export class Media extends BaseInterface<typeof Medias> {
};
}
public toVersia(): ContentFormat {
return this.data.content;
public toVersia(): VersiaEntities.ContentFormat {
return new VersiaEntities.ContentFormat(this.data.content);
}
public static fromVersia(contentFormat: ContentFormat): Promise<Media> {
public static fromVersia(
contentFormat: VersiaEntities.ContentFormat,
): Promise<Media> {
return Media.insert({
id: randomUUIDv7(),
content: contentFormat,
originalContent: contentFormat,
content: contentFormat.data,
originalContent: contentFormat.data,
});
}
}

View file

@ -1,15 +1,7 @@
import { idValidator } from "@/api";
import { mergeAndDeduplicate } from "@/lib.ts";
import { sanitizedHtmlStrip } from "@/sanitization";
import { sentry } from "@/sentry";
import { getLogger } from "@logtape/logtape";
import type { Status, Status as StatusSchema } from "@versia/client/schemas";
import { EntityValidator } from "@versia/federation";
import type {
ContentFormat,
Delete as VersiaDelete,
Note as VersiaNote,
} from "@versia/federation/types";
import type { Status } from "@versia/client/schemas";
import { Instance, db } from "@versia/kit/db";
import {
EmojiToNote,
@ -33,12 +25,10 @@ import {
import { htmlToText } from "html-to-text";
import { createRegExp, exactly, global } from "magic-regexp";
import type { z } from "zod";
import {
contentToHtml,
findManyNotes,
parseTextMentions,
} from "~/classes/functions/status";
import { contentToHtml, findManyNotes } from "~/classes/functions/status";
import { config } from "~/config.ts";
import * as VersiaEntities from "~/packages/sdk/entities/index.ts";
import type { NonTextContentFormatSchema } from "~/packages/sdk/schemas/contentformat.ts";
import { DeliveryJobType, deliveryQueue } from "../queues/delivery.ts";
import { Application } from "./application.ts";
import { BaseInterface } from "./base.ts";
@ -222,7 +212,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
await deliveryQueue.addBulk(
users.map((user) => ({
data: {
entity: this.toVersia(),
entity: this.toVersia().toJSON(),
recipientId: user.id,
senderId: this.author.id,
},
@ -310,173 +300,12 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
);
}
public isRemote(): boolean {
return this.author.isRemote();
public get remote(): boolean {
return this.author.remote;
}
/**
* Update a note from remote federated servers
* @returns The updated note
*/
public async updateFromRemote(): Promise<Note> {
if (!this.isRemote()) {
throw new Error("Cannot refetch a local note (it is not remote)");
}
const updated = await Note.fetchFromRemote(this.getUri());
if (!updated) {
throw new Error("Note not found after update");
}
this.data = updated.data;
return this;
}
/**
* Create a new note from user input
* @param data - The data to create the note from
* @returns The created note
*/
public static async fromData(data: {
author: User;
content: ContentFormat;
visibility: z.infer<typeof StatusSchema.shape.visibility>;
isSensitive: boolean;
spoilerText: string;
emojis?: Emoji[];
uri?: string;
mentions?: User[];
/** List of IDs of database Attachment objects */
mediaAttachments?: Media[];
replyId?: string;
quoteId?: string;
application?: Application;
}): Promise<Note> {
const plaintextContent =
data.content["text/plain"]?.content ??
Object.entries(data.content)[0][1].content;
const parsedMentions = mergeAndDeduplicate(
data.mentions ?? [],
await parseTextMentions(plaintextContent, data.author),
);
const parsedEmojis = mergeAndDeduplicate(
data.emojis ?? [],
await Emoji.parseFromText(plaintextContent),
);
const htmlContent = await contentToHtml(data.content, parsedMentions);
const newNote = await Note.insert({
id: randomUUIDv7(),
authorId: data.author.id,
content: htmlContent,
contentSource:
data.content["text/plain"]?.content ||
data.content["text/markdown"]?.content ||
Object.entries(data.content)[0][1].content ||
"",
contentType: "text/html",
visibility: data.visibility,
sensitive: data.isSensitive,
spoilerText: await sanitizedHtmlStrip(data.spoilerText),
uri: data.uri || null,
replyId: data.replyId ?? null,
quotingId: data.quoteId ?? null,
applicationId: data.application?.id ?? null,
});
// Connect emojis
await newNote.updateEmojis(parsedEmojis);
// Connect mentions
await newNote.updateMentions(parsedMentions);
// Set attachment parents
await newNote.updateAttachments(data.mediaAttachments ?? []);
// Send notifications for mentioned local users
for (const mention of parsedMentions) {
if (mention.isLocal()) {
await mention.notify("mention", data.author, newNote);
}
}
await newNote.reload(data.author.id);
return newNote;
}
/**
* Update a note from user input
* @param data - The data to update the note from
* @returns The updated note
*/
public async updateFromData(data: {
author: User;
content?: ContentFormat;
visibility?: z.infer<typeof StatusSchema.shape.visibility>;
isSensitive?: boolean;
spoilerText?: string;
emojis?: Emoji[];
uri?: string;
mentions?: User[];
mediaAttachments?: Media[];
replyId?: string;
quoteId?: string;
application?: Application;
}): Promise<Note> {
const plaintextContent = data.content
? (data.content["text/plain"]?.content ??
Object.entries(data.content)[0][1].content)
: undefined;
const parsedMentions = mergeAndDeduplicate(
data.mentions ?? [],
plaintextContent
? await parseTextMentions(plaintextContent, data.author)
: [],
);
const parsedEmojis = mergeAndDeduplicate(
data.emojis ?? [],
plaintextContent ? await Emoji.parseFromText(plaintextContent) : [],
);
const htmlContent = data.content
? await contentToHtml(data.content, parsedMentions)
: undefined;
await this.update({
content: htmlContent,
contentSource: data.content
? data.content["text/plain"]?.content ||
data.content["text/markdown"]?.content ||
Object.entries(data.content)[0][1].content ||
""
: undefined,
contentType: "text/html",
visibility: data.visibility,
sensitive: data.isSensitive,
spoilerText: data.spoilerText,
replyId: data.replyId,
quotingId: data.quoteId,
applicationId: data.application?.id,
});
// Connect emojis
await this.updateEmojis(parsedEmojis);
// Connect mentions
await this.updateMentions(parsedMentions);
// Set attachment parents
await this.updateAttachments(data.mediaAttachments ?? []);
await this.reload(data.author.id);
return this;
public get local(): boolean {
return this.author.local;
}
/**
@ -556,7 +385,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
*/
public static async resolve(uri: URL): Promise<Note | null> {
// Check if note not already in database
const foundNote = await Note.fromSql(eq(Notes.uri, uri.toString()));
const foundNote = await Note.fromSql(eq(Notes.uri, uri.href));
if (foundNote) {
return foundNote;
@ -575,137 +404,124 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
return await Note.fromId(uuid[0]);
}
return await Note.fetchFromRemote(uri);
return Note.fromVersia(uri);
}
/**
* Save a note from a remote server
* @param uri - The URI of the note to save
* @returns The saved note, or null if the note could not be fetched
* Tries to fetch a Versia Note from the given URL.
*
* @param url The URL to fetch the note from
*/
public static async fetchFromRemote(uri: URL): Promise<Note | null> {
const instance = await Instance.resolve(uri);
if (!instance) {
return null;
}
const requester = await User.getFederationRequester();
const { data } = await requester.get(uri, {
// @ts-expect-error Bun extension
proxy: config.http.proxy_address,
});
const note = await new EntityValidator().Note(data);
const author = await User.resolve(new URL(note.author));
if (!author) {
throw new Error("Invalid object author");
}
return await Note.fromVersia(note, author, instance);
}
public static async fromVersia(url: URL): Promise<Note>;
/**
* Turns a Versia Note into a database note (saved)
* @param note Versia Note
* @param author Author of the note
* @param instance Instance of the note
* @returns The saved note
* Takes a Versia Note representation, and serializes it to the database.
*
* If the note already exists, it will update it.
* @param versiaNote
*/
public static async fromVersia(
note: VersiaNote,
author: User,
instance: Instance,
versiaNote: VersiaEntities.Note,
): Promise<Note>;
public static async fromVersia(
versiaNote: VersiaEntities.Note | URL,
): Promise<Note> {
const emojis: Emoji[] = [];
const logger = getLogger(["federation", "resolvers"]);
for (const emoji of note.extensions?.["pub.versia:custom_emojis"]
?.emojis ?? []) {
const resolvedEmoji = await Emoji.fetchFromRemote(
emoji,
instance,
).catch((e) => {
logger.error`${e}`;
sentry?.captureException(e);
return null;
});
if (resolvedEmoji) {
emojis.push(resolvedEmoji);
}
}
const attachments: Media[] = [];
for (const attachment of note.attachments ?? []) {
const resolvedAttachment = await Media.fromVersia(attachment).catch(
(e) => {
logger.error`${e}`;
sentry?.captureException(e);
return null;
},
if (versiaNote instanceof URL) {
// No bridge support for notes yet
const note = await User.federationRequester.fetchEntity(
versiaNote,
VersiaEntities.Note,
);
if (resolvedAttachment) {
attachments.push(resolvedAttachment);
return Note.fromVersia(note);
}
const {
author: authorUrl,
created_at,
uri,
extensions,
group,
is_sensitive,
mentions: noteMentions,
quotes,
replies_to,
subject,
} = versiaNote.data;
const instance = await Instance.resolve(authorUrl);
const author = await User.resolve(authorUrl);
if (!author) {
throw new Error("Entity author could not be resolved");
}
const existingNote = await Note.fromSql(eq(Notes.uri, uri.href));
const note =
existingNote ??
(await Note.insert({
id: randomUUIDv7(),
authorId: author.id,
visibility: "public",
uri: uri.href,
createdAt: new Date(created_at).toISOString(),
}));
const attachments = await Promise.all(
versiaNote.attachments.map((a) => Media.fromVersia(a)),
);
const emojis = await Promise.all(
extensions?.["pub.versia:custom_emojis"]?.emojis.map((emoji) =>
Emoji.fetchFromRemote(emoji, instance),
) ?? [],
);
const mentions = (
await Promise.all(
noteMentions?.map((mention) => User.resolve(mention)) ?? [],
)
).filter((m) => m !== null);
// TODO: Implement groups
const visibility = !group || group instanceof URL ? "direct" : group;
const reply = replies_to ? await Note.resolve(replies_to) : null;
const quote = quotes ? await Note.resolve(quotes) : null;
const spoiler = subject ? await sanitizedHtmlStrip(subject) : undefined;
await note.update({
content: versiaNote.content
? await contentToHtml(versiaNote.content, mentions)
: undefined,
contentSource: versiaNote.content
? versiaNote.content.data["text/plain"]?.content ||
versiaNote.content.data["text/markdown"]?.content
: undefined,
contentType: "text/html",
visibility: visibility === "followers" ? "private" : visibility,
sensitive: is_sensitive ?? false,
spoilerText: spoiler,
replyId: reply?.id,
quotingId: quote?.id,
});
// Emojis, mentions, and attachments are stored in a different table, so update them there too
await note.updateEmojis(emojis);
await note.updateMentions(mentions);
await note.updateAttachments(attachments);
await note.reload(author.id);
// Send notifications for mentioned local users
for (const mentioned of mentions) {
if (mentioned.local) {
await mentioned.notify("mention", author, note);
}
}
let visibility = note.group
? ["public", "followers"].includes(note.group)
? (note.group as "public" | "private")
: ("url" as const)
: ("direct" as const);
if (visibility === "url") {
// TODO: Implement groups
visibility = "direct";
}
const newData = {
author,
content: note.content ?? {
"text/plain": {
content: "",
remote: false,
},
},
visibility,
isSensitive: note.is_sensitive ?? false,
spoilerText: note.subject ?? "",
emojis,
uri: note.uri,
mentions: await Promise.all(
(note.mentions ?? [])
.map((mention) => User.resolve(new URL(mention)))
.filter((mention) => mention !== null) as Promise<User>[],
),
mediaAttachments: attachments,
replyId: note.replies_to
? (await Note.resolve(new URL(note.replies_to)))?.data.id
: undefined,
quoteId: note.quotes
? (await Note.resolve(new URL(note.quotes)))?.data.id
: undefined,
};
// Check if new note already exists
const foundNote = await Note.fromSql(eq(Notes.uri, note.uri));
// If it exists, simply update it
if (foundNote) {
await foundNote.updateFromData(newData);
return foundNote;
}
// Else, create a new note
return await Note.fromData(newData);
return note;
}
public async delete(ids?: string[]): Promise<void> {
@ -872,31 +688,31 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
);
}
public deleteToVersia(): VersiaDelete {
public deleteToVersia(): VersiaEntities.Delete {
const id = crypto.randomUUID();
return {
return new VersiaEntities.Delete({
type: "Delete",
id,
author: this.author.getUri().toString(),
author: this.author.uri,
deleted_type: "Note",
deleted: this.getUri().toString(),
deleted: this.getUri(),
created_at: new Date().toISOString(),
};
});
}
/**
* Convert a note to the Versia format
* @returns The note in the Versia format
*/
public toVersia(): VersiaNote {
public toVersia(): VersiaEntities.Note {
const status = this.data;
return {
return new VersiaEntities.Note({
type: "Note",
created_at: new Date(status.createdAt).toISOString(),
id: status.id,
author: this.author.getUri().toString(),
uri: this.getUri().toString(),
author: this.author.uri,
uri: this.getUri(),
content: {
"text/html": {
content: status.content,
@ -908,28 +724,37 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
},
},
collections: {
replies: `/notes/${status.id}/replies`,
quotes: `/notes/${status.id}/quotes`,
replies: new URL(
`/notes/${status.id}/replies`,
config.http.base_url,
),
quotes: new URL(
`/notes/${status.id}/quotes`,
config.http.base_url,
),
},
attachments: (status.attachments ?? []).map((attachment) =>
new Media(attachment).toVersia(),
attachments: status.attachments.map(
(attachment) =>
new Media(attachment).toVersia().data as z.infer<
typeof NonTextContentFormatSchema
>,
),
is_sensitive: status.sensitive,
mentions: status.mentions.map((mention) =>
User.getUri(
mention.id,
mention.uri ? new URL(mention.uri) : null,
).toString(),
),
),
quotes: status.quote
? (status.quote.uri ??
new URL(`/notes/${status.quote.id}`, config.http.base_url)
.href)
? status.quote.uri
? new URL(status.quote.uri)
: new URL(`/notes/${status.quote.id}`, config.http.base_url)
: null,
replies_to: status.reply
? (status.reply.uri ??
new URL(`/notes/${status.reply.id}`, config.http.base_url)
.href)
? status.reply.uri
? new URL(status.reply.uri)
: new URL(`/notes/${status.reply.id}`, config.http.base_url)
: null,
subject: status.spoilerText,
// TODO: Refactor as part of groups
@ -942,7 +767,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
},
// TODO: Add polls and reactions
},
};
});
}
/**

View file

@ -1,4 +1,3 @@
import type { ReactionExtension } from "@versia/federation/types";
import { Emoji, Instance, type Note, User, db } from "@versia/kit/db";
import { type Notes, Reactions, type Users } from "@versia/kit/tables";
import { randomUUIDv7 } from "bun";
@ -11,6 +10,7 @@ import {
inArray,
} from "drizzle-orm";
import { config } from "~/config.ts";
import * as VersiaEntities from "~/packages/sdk/entities/index.ts";
import { BaseInterface } from "./base.ts";
type ReactionType = InferSelectModel<typeof Reactions> & {
@ -165,7 +165,7 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
);
}
public isLocal(): boolean {
public get local(): boolean {
return this.data.author.instanceId === null;
}
@ -173,24 +173,23 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
return !!this.data.emoji || !this.data.emojiText;
}
public toVersia(): ReactionExtension {
if (!this.isLocal()) {
public toVersia(): VersiaEntities.Reaction {
if (!this.local) {
throw new Error("Cannot convert a non-local reaction to Versia");
}
return {
uri: this.getUri(config.http.base_url).toString(),
return new VersiaEntities.Reaction({
uri: this.getUri(config.http.base_url),
type: "pub.versia:reactions/Reaction",
author: User.getUri(
this.data.authorId,
this.data.author.uri ? new URL(this.data.author.uri) : null,
).toString(),
),
created_at: new Date(this.data.createdAt).toISOString(),
id: this.id,
object:
this.data.note.uri ??
new URL(`/notes/${this.data.noteId}`, config.http.base_url)
.href,
object: this.data.note.uri
? new URL(this.data.note.uri)
: new URL(`/notes/${this.data.noteId}`, config.http.base_url),
content: this.hasCustomEmoji()
? `:${this.data.emoji?.shortcode}:`
: this.data.emojiText || "",
@ -205,20 +204,20 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
},
}
: undefined,
};
});
}
public static async fromVersia(
reactionToConvert: ReactionExtension,
reactionToConvert: VersiaEntities.Reaction,
author: User,
note: Note,
): Promise<Reaction> {
if (author.isLocal()) {
if (author.local) {
throw new Error("Cannot process a reaction from a local user");
}
const emojiEntity =
reactionToConvert.extensions?.["pub.versia:custom_emojis"]
reactionToConvert.data.extensions?.["pub.versia:custom_emojis"]
?.emojis[0];
const emoji = emojiEntity
? await Emoji.fetchFromRemote(
@ -233,11 +232,11 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
return Reaction.insert({
id: randomUUIDv7(),
uri: reactionToConvert.uri,
uri: reactionToConvert.data.uri.href,
authorId: author.id,
noteId: note.id,
emojiId: emoji ? emoji.id : null,
emojiText: emoji ? null : reactionToConvert.content,
emojiText: emoji ? null : reactionToConvert.data.content,
});
}
}

View file

@ -9,19 +9,6 @@ import type {
Source,
} from "@versia/client/schemas";
import type { RolePermission } from "@versia/client/schemas";
import {
EntityValidator,
FederationRequester,
type HttpVerb,
SignatureConstructor,
} from "@versia/federation";
import type {
Collection,
Unfollow,
FollowAccept as VersiaFollowAccept,
FollowReject as VersiaFollowReject,
User as VersiaUser,
} from "@versia/federation/types";
import { Media, Notification, PushSubscription, db } from "@versia/kit/db";
import {
EmojiToUser,
@ -54,7 +41,11 @@ import type { z } from "zod";
import { findManyUsers } from "~/classes/functions/user";
import { searchManager } from "~/classes/search/search-manager";
import { config } from "~/config.ts";
import type { KnownEntity } from "~/types/api.ts";
import { sign } from "~/packages/sdk/crypto.ts";
import * as VersiaEntities from "~/packages/sdk/entities/index.ts";
import { FederationRequester } from "~/packages/sdk/http.ts";
import type { ImageContentFormatSchema } from "~/packages/sdk/schemas/index.ts";
import type { HttpVerb, KnownEntity } from "~/types/api.ts";
import { ProxiableUrl } from "../media/url.ts";
import { DeliveryJobType, deliveryQueue } from "../queues/delivery.ts";
import { PushJobType, pushQueue } from "../queues/push.ts";
@ -157,15 +148,15 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return this.data.id;
}
public isLocal(): boolean {
public get local(): boolean {
return this.data.instanceId === null;
}
public isRemote(): boolean {
return !this.isLocal();
public get remote(): boolean {
return !this.local;
}
public getUri(): URL {
public get uri(): URL {
return this.data.uri
? new URL(this.data.uri)
: new URL(`/users/${this.data.id}`, config.http.base_url);
@ -205,20 +196,20 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
);
await foundRelationship.update({
following: otherUser.isRemote() ? false : !otherUser.data.isLocked,
requested: otherUser.isRemote() ? true : otherUser.data.isLocked,
following: otherUser.remote ? false : !otherUser.data.isLocked,
requested: otherUser.remote ? true : otherUser.data.isLocked,
showingReblogs: options?.reblogs,
notifying: options?.notify,
languages: options?.languages,
});
if (otherUser.isRemote()) {
if (otherUser.remote) {
await deliveryQueue.add(DeliveryJobType.FederateEntity, {
entity: {
type: "Follow",
id: crypto.randomUUID(),
author: this.getUri().toString(),
followee: otherUser.getUri().toString(),
author: this.uri.href,
followee: otherUser.uri.href,
created_at: new Date().toISOString(),
},
recipientId: otherUser.id,
@ -238,9 +229,9 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
followee: User,
relationship: Relationship,
): Promise<void> {
if (followee.isRemote()) {
if (followee.remote) {
await deliveryQueue.add(DeliveryJobType.FederateEntity, {
entity: this.unfollowToVersia(followee),
entity: this.unfollowToVersia(followee).toJSON(),
recipientId: followee.id,
senderId: this.id,
});
@ -251,87 +242,118 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
});
}
private unfollowToVersia(followee: User): Unfollow {
private unfollowToVersia(followee: User): VersiaEntities.Unfollow {
const id = crypto.randomUUID();
return {
return new VersiaEntities.Unfollow({
type: "Unfollow",
id,
author: this.getUri().toString(),
author: this.uri,
created_at: new Date().toISOString(),
followee: followee.getUri().toString(),
};
followee: followee.uri,
});
}
public async sendFollowAccept(follower: User): Promise<void> {
if (!follower.isRemote()) {
public async acceptFollowRequest(follower: User): Promise<void> {
if (!follower.remote) {
throw new Error("Follower must be a remote user");
}
if (this.isRemote()) {
if (this.remote) {
throw new Error("Followee must be a local user");
}
const entity: VersiaFollowAccept = {
const entity = new VersiaEntities.FollowAccept({
type: "FollowAccept",
id: crypto.randomUUID(),
author: this.getUri().toString(),
author: this.uri,
created_at: new Date().toISOString(),
follower: follower.getUri().toString(),
};
follower: follower.uri,
});
await deliveryQueue.add(DeliveryJobType.FederateEntity, {
entity,
entity: entity.toJSON(),
recipientId: follower.id,
senderId: this.id,
});
}
public async sendFollowReject(follower: User): Promise<void> {
if (!follower.isRemote()) {
public async rejectFollowRequest(follower: User): Promise<void> {
if (!follower.remote) {
throw new Error("Follower must be a remote user");
}
if (this.isRemote()) {
if (this.remote) {
throw new Error("Followee must be a local user");
}
const entity: VersiaFollowReject = {
const entity = new VersiaEntities.FollowReject({
type: "FollowReject",
id: crypto.randomUUID(),
author: this.getUri().toString(),
author: this.uri,
created_at: new Date().toISOString(),
follower: follower.getUri().toString(),
};
follower: follower.uri,
});
await deliveryQueue.add(DeliveryJobType.FederateEntity, {
entity,
entity: entity.toJSON(),
recipientId: follower.id,
senderId: this.id,
});
}
/**
* Signs a Versia entity with that user's private key
*
* @param entity Entity to sign
* @param signatureUrl URL to embed in signature (must be the same URI of queries made with this signature)
* @param signatureMethod HTTP method to embed in signature (default: POST)
* @returns The signed string and headers to send with the request
*/
public async sign(
entity: KnownEntity | VersiaEntities.Collection,
signatureUrl: URL,
signatureMethod: HttpVerb = "POST",
): Promise<{
headers: Headers;
}> {
const privateKey = await crypto.subtle.importKey(
"pkcs8",
Buffer.from(this.data.privateKey ?? "", "base64"),
"Ed25519",
false,
["sign"],
);
const { headers } = await sign(
privateKey,
this.uri,
new Request(signatureUrl, {
method: signatureMethod,
body: JSON.stringify(entity),
}),
);
return { headers };
}
/**
* Perform a WebFinger lookup to find a user's URI
* @param manager
* @param username
* @param hostname
* @returns URI, or null if not found
*/
public static async webFinger(
manager: FederationRequester,
public static webFinger(
username: string,
hostname: string,
): Promise<URL | null> {
try {
return new URL(await manager.webFinger(username, hostname));
return FederationRequester.resolveWebFinger(username, hostname);
} catch {
try {
return new URL(
await manager.webFinger(
username,
hostname,
"application/activity+json",
),
return FederationRequester.resolveWebFinger(
username,
hostname,
"application/activity+json",
);
} catch {
return Promise.resolve(null);
@ -455,7 +477,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
* @param uri The URI of the like, if it is remote
* @returns The like object created or the existing like
*/
public async like(note: Note, uri?: string): Promise<Like> {
public async like(note: Note, uri?: URL): Promise<Like> {
// Check if the user has already liked the note
const existingLike = await Like.fromSql(
and(eq(Likes.likerId, this.id), eq(Likes.likedId, note.id)),
@ -469,13 +491,13 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
id: randomUUIDv7(),
likerId: this.id,
likedId: note.id,
uri,
uri: uri?.href,
});
if (this.isLocal() && note.author.isLocal()) {
if (this.local && note.author.local) {
// Notify the user that their post has been favourited
await note.author.notify("favourite", this, note);
} else if (this.isLocal() && note.author.isRemote()) {
} else if (this.local && note.author.remote) {
// Federate the like
this.federateToFollowers(newLike.toVersia());
}
@ -501,10 +523,10 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
await likeToDelete.delete();
if (this.isLocal() && note.author.isLocal()) {
if (this.local && note.author.local) {
// Remove any eventual notifications for this like
await likeToDelete.clearRelatedNotifications();
} else if (this.isLocal() && note.author.isRemote()) {
} else if (this.local && note.author.remote) {
// User is local, federate the delete
this.federateToFollowers(likeToDelete.unlikeToVersia(this));
}
@ -575,75 +597,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
);
}
public async updateFromRemote(): Promise<User> {
if (!this.isRemote()) {
throw new Error(
"Cannot refetch a local user (they are not remote)",
);
}
const updated = await User.fetchFromRemote(this.getUri());
if (!updated) {
throw new Error("Failed to update user from remote");
}
this.data = updated.data;
return this;
}
public static async fetchFromRemote(uri: URL): Promise<User | null> {
const instance = await Instance.resolve(uri);
if (!instance) {
return null;
}
if (instance.data.protocol === "versia") {
return await User.saveFromVersia(uri, instance);
}
if (instance.data.protocol === "activitypub") {
if (!config.federation.bridge) {
throw new Error("ActivityPub bridge is not enabled");
}
const bridgeUri = new URL(
`/apbridge/versia/query?${new URLSearchParams({
user_url: uri.toString(),
})}`,
config.federation.bridge.url,
);
return await User.saveFromVersia(bridgeUri, instance);
}
throw new Error(`Unsupported protocol: ${instance.data.protocol}`);
}
private static async saveFromVersia(
uri: URL,
instance: Instance,
): Promise<User> {
const requester = await User.getFederationRequester();
const output = await requester.get<Partial<VersiaUser>>(uri, {
// @ts-expect-error Bun extension
proxy: config.http.proxy_address,
});
const { data: json } = output;
const validator = new EntityValidator();
const data = await validator.User(json);
const user = await User.fromVersia(data, instance);
await searchManager.addUser(user);
return user;
}
/**
* Change the emojis linked to this user in database
* @param emojis
@ -663,118 +616,142 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
);
}
/**
* Tries to fetch a Versia user from the given URL.
*
* @param url The URL to fetch the user from
*/
public static async fromVersia(url: URL): Promise<User>;
/**
* Takes a Versia User representation, and serializes it to the database.
*
* If the user already exists, it will update it.
* @param versiaUser
*/
public static async fromVersia(
user: VersiaUser,
instance: Instance,
versiaUser: VersiaEntities.User,
): Promise<User>;
public static async fromVersia(
versiaUser: VersiaEntities.User | URL,
): Promise<User> {
const data = {
username: user.username,
uri: user.uri,
createdAt: new Date(user.created_at).toISOString(),
endpoints: {
dislikes:
user.collections["pub.versia:likes/Dislikes"] ?? undefined,
featured: user.collections.featured,
likes: user.collections["pub.versia:likes/Likes"] ?? undefined,
followers: user.collections.followers,
following: user.collections.following,
inbox: user.inbox,
outbox: user.collections.outbox,
},
fields: user.fields ?? [],
updatedAt: new Date(user.created_at).toISOString(),
instanceId: instance.id,
displayName: user.display_name ?? "",
note: getBestContentType(user.bio).content,
publicKey: user.public_key.key,
source: {
language: "en",
note: "",
privacy: "public",
sensitive: false,
fields: [],
} as z.infer<typeof Source>,
};
if (versiaUser instanceof URL) {
let uri = versiaUser;
const instance = await Instance.resolve(uri);
const userEmojis =
user.extensions?.["pub.versia:custom_emojis"]?.emojis ?? [];
const emojis = await Promise.all(
userEmojis.map((emoji) => Emoji.fromVersia(emoji, instance)),
);
// Check if new user already exists
const foundUser = await User.fromSql(eq(Users.uri, user.uri));
// If it exists, simply update it
if (foundUser) {
let avatar: Media | null = null;
let header: Media | null = null;
if (user.avatar) {
if (foundUser.avatar) {
avatar = new Media(
await foundUser.avatar.update({
content: user.avatar,
}),
);
} else {
avatar = await Media.insert({
id: randomUUIDv7(),
content: user.avatar,
});
if (instance.data.protocol === "activitypub") {
if (!config.federation.bridge) {
throw new Error("ActivityPub bridge is not enabled");
}
uri = new URL(
`/apbridge/versia/query?${new URLSearchParams({
user_url: uri.href,
})}`,
config.federation.bridge.url,
);
}
if (user.header) {
if (foundUser.header) {
header = new Media(
await foundUser.header.update({
content: user.header,
}),
);
} else {
header = await Media.insert({
id: randomUUIDv7(),
content: user.header,
});
}
}
const user = await User.federationRequester.fetchEntity(
uri,
VersiaEntities.User,
);
await foundUser.update({
...data,
avatarId: avatar?.id,
headerId: header?.id,
});
await foundUser.updateEmojis(emojis);
return foundUser;
return User.fromVersia(user);
}
// Else, create a new user
const avatar = user.avatar
? await Media.insert({
id: randomUUIDv7(),
content: user.avatar,
})
: null;
const {
username,
inbox,
avatar,
header,
display_name,
fields,
collections,
created_at,
bio,
public_key,
uri,
extensions,
} = versiaUser.data;
const instance = await Instance.resolve(versiaUser.data.uri);
const existingUser = await User.fromSql(
eq(Users.uri, versiaUser.data.uri.href),
);
const header = user.header
? await Media.insert({
id: randomUUIDv7(),
content: user.header,
})
: null;
const user =
existingUser ??
(await User.insert({
username,
id: randomUUIDv7(),
publicKey: public_key.key,
uri: uri.href,
instanceId: instance.id,
}));
const newUser = await User.insert({
id: randomUUIDv7(),
...data,
avatarId: avatar?.id,
headerId: header?.id,
// Avatars and headers are stored in a separate table, so we need to update them separately
let userAvatar: Media | null = null;
let userHeader: Media | null = null;
if (avatar) {
if (user.avatar) {
userAvatar = new Media(
await user.avatar.update({
content: avatar,
}),
);
} else {
userAvatar = await Media.insert({
id: randomUUIDv7(),
content: avatar,
});
}
}
if (header) {
if (user.header) {
userHeader = new Media(
await user.header.update({
content: header,
}),
);
} else {
userHeader = await Media.insert({
id: randomUUIDv7(),
content: header,
});
}
}
await user.update({
createdAt: new Date(created_at).toISOString(),
endpoints: {
inbox: inbox.href,
outbox: collections.outbox.href,
followers: collections.followers.href,
following: collections.following.href,
featured: collections.featured.href,
likes: collections["pub.versia:likes/Likes"]?.href,
dislikes: collections["pub.versia:likes/Dislikes"]?.href,
},
avatarId: userAvatar?.id,
headerId: userHeader?.id,
fields: fields ?? [],
displayName: display_name,
note: getBestContentType(bio).content,
});
await newUser.updateEmojis(emojis);
return newUser;
// Emojis are stored in a separate table, so we need to update them separately
const emojis = await Promise.all(
extensions?.["pub.versia:custom_emojis"]?.emojis.map((e) =>
Emoji.fromVersia(e, instance),
) ?? [],
);
await user.updateEmojis(emojis);
return user;
}
public static async insert(
@ -795,7 +772,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
getLogger(["federation", "resolvers"])
.debug`Resolving user ${chalk.gray(uri)}`;
// Check if user not already in database
const foundUser = await User.fromSql(eq(Users.uri, uri.toString()));
const foundUser = await User.fromSql(eq(Users.uri, uri.href));
if (foundUser) {
return foundUser;
@ -817,7 +794,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
getLogger(["federation", "resolvers"])
.debug`User not found in database, fetching from remote`;
return await User.fetchFromRemote(uri);
return User.fromVersia(uri);
}
/**
@ -861,60 +838,45 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
};
}
public static async fromDataLocal(data: {
username: string;
display_name?: string;
password: string | undefined;
email: string | undefined;
bio?: string;
avatar?: Media;
header?: Media;
admin?: boolean;
skipPasswordHash?: boolean;
}): Promise<User> {
public static async register(
username: string,
options?: Partial<{
email: string;
password: string;
avatar: Media;
isAdmin: boolean;
}>,
): Promise<User> {
const keys = await User.generateKeys();
const newUser = (
await db
.insert(Users)
.values({
id: randomUUIDv7(),
username: data.username,
displayName: data.display_name ?? data.username,
password:
data.skipPasswordHash || !data.password
? data.password
: await bunPassword.hash(data.password),
email: data.email,
note: data.bio ?? "",
avatarId: data.avatar?.id,
headerId: data.header?.id,
isAdmin: data.admin ?? false,
publicKey: keys.public_key,
fields: [],
privateKey: keys.private_key,
updatedAt: new Date().toISOString(),
source: {
language: "en",
note: "",
privacy: "public",
sensitive: false,
fields: [],
} as z.infer<typeof Source>,
})
.returning()
)[0];
const finalUser = await User.fromId(newUser.id);
if (!finalUser) {
throw new Error("Failed to create user");
}
const user = await User.insert({
id: randomUUIDv7(),
username,
displayName: username,
password: options?.password
? await bunPassword.hash(options.password)
: null,
email: options?.email,
note: "",
avatarId: options?.avatar?.id,
isAdmin: options?.isAdmin,
publicKey: keys.public_key,
fields: [],
privateKey: keys.private_key,
updatedAt: new Date().toISOString(),
source: {
language: "en",
note: "",
privacy: "public",
sensitive: false,
fields: [],
} as z.infer<typeof Source>,
});
// Add to search index
await searchManager.addUser(finalUser);
await searchManager.addUser(user);
return finalUser;
return user;
}
/**
@ -930,7 +892,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
}
public getAcct(): string {
return this.isLocal()
return this.local
? this.data.username
: `${this.data.username}@${this.data.instance?.baseUrl}`;
}
@ -956,7 +918,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
// If something important is updated, federate it
if (
this.isLocal() &&
this.local &&
(newUser.username ||
newUser.displayName ||
newUser.note ||
@ -977,72 +939,25 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return updated.data;
}
/**
* 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 | Collection,
signatureUrl: URL,
signatureMethod: HttpVerb = "POST",
): Promise<{
headers: Headers;
signedString: string;
}> {
const signatureConstructor = await SignatureConstructor.fromStringKey(
this.data.privateKey ?? "",
this.getUri(),
);
const output = await signatureConstructor.sign(
signatureMethod,
signatureUrl,
JSON.stringify(entity),
);
if (config.debug?.federation) {
const logger = getLogger("federation");
// Log public key
logger.debug`Sender public key: ${this.data.publicKey}`;
// Log signed string
logger.debug`Signed string:\n${output.signedString}`;
}
return output;
}
/**
* Helper to get the appropriate Versia SDK requester with the instance's private key
*
* @returns The requester
*/
public static getFederationRequester(): FederationRequester {
const signatureConstructor = new SignatureConstructor(
public static get federationRequester(): FederationRequester {
return new FederationRequester(
config.instance.keys.private,
config.http.base_url,
);
return new FederationRequester(signatureConstructor);
}
/**
* Helper to get the appropriate Versia SDK requester with this user's private key
*
* @returns The requester
*/
public async getFederationRequester(): Promise<FederationRequester> {
const signatureConstructor = await SignatureConstructor.fromStringKey(
this.data.privateKey ?? "",
this.getUri(),
);
return new FederationRequester(signatureConstructor);
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);
});
}
/**
@ -1071,7 +986,8 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
followers.map((follower) => ({
name: DeliveryJobType.FederateEntity,
data: {
entity,
entity: entity.toJSON(),
type: entity.data.type,
recipientId: follower.id,
senderId: this.id,
},
@ -1094,24 +1010,19 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
if (!inbox) {
throw new Error(
`User ${chalk.gray(user.getUri())} does not have an inbox endpoint`,
`User ${chalk.gray(user.uri)} does not have an inbox endpoint`,
);
}
const { headers } = await this.sign(entity, new URL(inbox));
try {
await new FederationRequester().post(inbox, entity, {
// @ts-expect-error Bun extension
proxy: config.http.proxy_address,
headers: {
...headers.toJSON(),
"Content-Type": "application/json; charset=utf-8",
},
});
await (await this.federationRequester).postEntity(
new URL(inbox),
entity,
);
} catch (e) {
getLogger(["federation", "delivery"])
.error`Federating ${chalk.gray(entity.type)} to ${user.getUri()} ${chalk.bold.red("failed")}`;
getLogger(["federation", "delivery"]).error`Federating ${chalk.gray(
entity.data.type,
)} to ${user.uri} ${chalk.bold.red("failed")}`;
getLogger(["federation", "delivery"]).error`${e}`;
sentry?.captureException(e);
@ -1127,12 +1038,12 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return {
id: user.id,
username: user.username,
display_name: user.displayName,
display_name: user.displayName || user.username,
note: user.note,
uri: this.getUri().toString(),
uri: this.uri.href,
url:
user.uri ||
new URL(`/@${user.username}`, config.http.base_url).toString(),
new URL(`/@${user.username}`, config.http.base_url).href,
avatar: this.getAvatarUrl().proxied,
header: this.getHeaderUrl()?.proxied ?? "",
locked: user.isLocked,
@ -1153,7 +1064,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
verified_at: null,
})),
bot: user.isBot,
source: isOwnAccount ? user.source : undefined,
source: isOwnAccount ? (user.source ?? undefined) : undefined,
// TODO: Add static avatar and header
avatar_static: this.getAvatarUrl().proxied,
header_static: this.getHeaderUrl()?.proxied ?? "",
@ -1176,17 +1087,17 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
};
}
public toVersia(): VersiaUser {
if (this.isRemote()) {
public toVersia(): VersiaEntities.User {
if (this.remote) {
throw new Error("Cannot convert remote user to Versia format");
}
const user = this.data;
return {
return new VersiaEntities.User({
id: user.id,
type: "User",
uri: this.getUri().toString(),
uri: this.uri,
bio: {
"text/html": {
content: user.note,
@ -1202,44 +1113,42 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
featured: new URL(
`/users/${user.id}/featured`,
config.http.base_url,
).toString(),
),
"pub.versia:likes/Likes": new URL(
`/users/${user.id}/likes`,
config.http.base_url,
).toString(),
),
"pub.versia:likes/Dislikes": new URL(
`/users/${user.id}/dislikes`,
config.http.base_url,
).toString(),
),
followers: new URL(
`/users/${user.id}/followers`,
config.http.base_url,
).toString(),
),
following: new URL(
`/users/${user.id}/following`,
config.http.base_url,
).toString(),
),
outbox: new URL(
`/users/${user.id}/outbox`,
config.http.base_url,
).toString(),
),
},
inbox: new URL(
`/users/${user.id}/inbox`,
config.http.base_url,
).toString(),
inbox: new URL(`/users/${user.id}/inbox`, config.http.base_url),
indexable: this.data.isIndexable,
username: user.username,
manually_approves_followers: this.data.isLocked,
avatar: this.avatar?.toVersia(),
header: this.header?.toVersia(),
avatar: this.avatar?.toVersia().data as z.infer<
typeof ImageContentFormatSchema
>,
header: this.header?.toVersia().data as z.infer<
typeof ImageContentFormatSchema
>,
display_name: user.displayName,
fields: user.fields,
public_key: {
actor: new URL(
`/users/${user.id}`,
config.http.base_url,
).toString(),
actor: new URL(`/users/${user.id}`, config.http.base_url),
key: user.publicKey,
algorithm: "ed25519",
},
@ -1250,12 +1159,12 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
),
},
},
};
});
}
public toMention(): z.infer<typeof MentionSchema> {
return {
url: this.getUri().toString(),
url: this.uri.href,
username: this.data.username,
acct: this.getAcct(),
id: this.id,

View file

@ -1,9 +1,9 @@
import { mentionValidator } from "@/api";
import { sanitizeHtml, sanitizeHtmlInline } from "@/sanitization";
import markdownItTaskLists from "@hackmd/markdown-it-task-lists";
import type { ContentFormat } from "@versia/federation/types";
import { type Note, User, db } from "@versia/kit/db";
import { Instances, Users } from "@versia/kit/tables";
import { FederationRequester } from "@versia/sdk/http";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import linkifyHtml from "linkify-html";
import {
@ -19,6 +19,7 @@ import MarkdownIt from "markdown-it";
import markdownItContainer from "markdown-it-container";
import markdownItTocDoneRight from "markdown-it-toc-done-right";
import { config } from "~/config.ts";
import type * as VersiaEntities from "~/packages/sdk/entities/index.ts";
import {
transformOutputToUserWithRelations,
userExtrasTemplate,
@ -222,10 +223,7 @@ export const findManyNotes = async (
* @param text The text to parse mentions from.
* @returns An array of users mentioned in the text.
*/
export const parseTextMentions = async (
text: string,
author: User,
): Promise<User[]> => {
export const parseTextMentions = async (text: string): Promise<User[]> => {
const mentionedPeople = [...text.matchAll(mentionValidator)];
if (mentionedPeople.length === 0) {
return [];
@ -276,21 +274,17 @@ export const parseTextMentions = async (
// Resolve remote mentions not in database
for (const person of notFoundRemoteUsers) {
const manager = await author.getFederationRequester();
const uri = await User.webFinger(
manager,
const url = await FederationRequester.resolveWebFinger(
person[1] ?? "",
person[2] ?? "",
);
if (!uri) {
continue;
}
if (url) {
const user = await User.resolve(url);
const user = await User.resolve(uri);
if (user) {
finalList.push(user);
if (user) {
finalList.push(user);
}
}
}
@ -300,12 +294,12 @@ export const parseTextMentions = async (
export const replaceTextMentions = (text: string, mentions: User[]): string => {
return mentions.reduce((finalText, mention) => {
const { username, instance } = mention.data;
const uri = mention.getUri();
const { uri } = mention;
const baseHost = config.http.base_url.host;
const linkTemplate = (displayText: string): string =>
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${uri}">${displayText}</a>`;
if (mention.isRemote()) {
if (mention.remote) {
return finalText.replaceAll(
`@${username}@${instance?.baseUrl}`,
linkTemplate(`@${username}@${instance?.baseUrl}`),
@ -327,21 +321,21 @@ export const replaceTextMentions = (text: string, mentions: User[]): string => {
};
export const contentToHtml = async (
content: ContentFormat,
content: VersiaEntities.TextContentFormat,
mentions: User[] = [],
inline = false,
): Promise<string> => {
const sanitizer = inline ? sanitizeHtmlInline : sanitizeHtml;
let htmlContent = "";
if (content["text/html"]) {
htmlContent = await sanitizer(content["text/html"].content);
} else if (content["text/markdown"]) {
if (content.data["text/html"]) {
htmlContent = await sanitizer(content.data["text/html"].content);
} else if (content.data["text/markdown"]) {
htmlContent = await sanitizer(
await markdownParse(content["text/markdown"].content),
await markdownParse(content.data["text/markdown"].content),
);
} else if (content["text/plain"]?.content) {
htmlContent = (await sanitizer(content["text/plain"].content))
} else if (content.data["text/plain"]?.content) {
htmlContent = (await sanitizer(content.data["text/plain"].content))
.split("\n")
.map((line) => `<p>${line}</p>`)
.join("\n");

View file

@ -1,21 +1,6 @@
import { sentry } from "@/sentry";
import { type Logger, getLogger } from "@logtape/logtape";
import {
EntityValidator,
RequestParserHandler,
SignatureValidator,
} from "@versia/federation";
import type {
Entity,
Delete as VersiaDelete,
Follow as VersiaFollow,
FollowAccept as VersiaFollowAccept,
FollowReject as VersiaFollowReject,
LikeExtension as VersiaLikeExtension,
Note as VersiaNote,
User as VersiaUser,
} from "@versia/federation/types";
import { Instance, Like, Note, Relationship, User } from "@versia/kit/db";
import { type Instance, Like, Note, Relationship, User } from "@versia/kit/db";
import { Likes, Notes } from "@versia/kit/tables";
import type { SocketAddress } from "bun";
import { Glob } from "bun";
@ -24,6 +9,10 @@ import { eq } from "drizzle-orm";
import { matches } from "ip-matching";
import { isValidationError } from "zod-validation-error";
import { config } from "~/config.ts";
import { verify } from "~/packages/sdk/crypto.ts";
import * as VersiaEntities from "~/packages/sdk/entities/index.ts";
import { EntitySorter } from "~/packages/sdk/inbox-processor.ts";
import type { JSONObject } from "~/packages/sdk/types.ts";
import { ApiError } from "../errors/api-error.ts";
/**
@ -63,21 +52,13 @@ export class InboxProcessor {
* @param requestIp Request IP address. Grabs it from the Hono context if not provided.
*/
public constructor(
private request: {
url: URL;
method: string;
body: string;
},
private body: Entity,
private request: Request,
private body: JSONObject,
private sender: {
instance: Instance;
key: string;
key: CryptoKey;
} | null,
private headers: {
signature?: string;
signedAt?: Date;
authorization?: string;
},
private authorizationHeader?: string,
private logger: Logger = getLogger(["federation", "inbox"]),
private requestIp: SocketAddress | null = null,
) {}
@ -87,40 +68,12 @@ export class InboxProcessor {
*
* @returns {Promise<boolean>} - Whether the signature is valid.
*/
private async isSignatureValid(): Promise<boolean> {
private isSignatureValid(): Promise<boolean> {
if (!this.sender) {
throw new Error("Sender is not defined");
}
if (config.debug?.federation) {
this.logger.debug`Sender public key: ${chalk.gray(
this.sender.key,
)}`;
}
const validator = await SignatureValidator.fromStringKey(
this.sender.key,
);
if (!(this.headers.signature && this.headers.signedAt)) {
throw new Error("Missing signature or signature timestamp");
}
// HACK: Making a fake Request object instead of passing the values directly is necessary because otherwise the validation breaks for some unknown reason
const isValid = await validator.validate(
new Request(this.request.url, {
method: this.request.method,
headers: {
"Versia-Signature": this.headers.signature,
"Versia-Signed-At": (
this.headers.signedAt.getTime() / 1000
).toString(),
},
body: this.request.body,
}),
);
return isValid;
return verify(this.sender.key, this.request);
}
/**
@ -131,7 +84,7 @@ export class InboxProcessor {
*/
private shouldCheckSignature(): boolean {
if (config.federation.bridge) {
const token = this.headers.authorization?.split("Bearer ")[1];
const token = this.authorizationHeader?.split("Bearer ")[1];
if (token) {
return this.isRequestFromBridge(token);
@ -226,58 +179,48 @@ export class InboxProcessor {
shouldCheckSignature && this.logger.debug`Signature is valid`;
const validator = new EntityValidator();
const handler = new RequestParserHandler(this.body, validator);
try {
return await handler.parseBody<void>({
note: (): Promise<void> => this.processNote(),
follow: (): Promise<void> => this.processFollowRequest(),
followAccept: (): Promise<void> => this.processFollowAccept(),
followReject: (): Promise<void> => this.processFollowReject(),
"pub.versia:likes/Like": (): Promise<void> =>
this.processLikeRequest(),
delete: (): Promise<void> => this.processDelete(),
user: (): Promise<void> => this.processUserRequest(),
unknown: (): void => {
new EntitySorter(this.body)
.on(VersiaEntities.Note, async (n) => {
await Note.fromVersia(n);
})
.on(VersiaEntities.Follow, (f) => {
InboxProcessor.processFollowRequest(f);
})
.on(VersiaEntities.FollowAccept, (f) => {
InboxProcessor.processFollowAccept(f);
})
.on(VersiaEntities.FollowReject, (f) => {
InboxProcessor.processFollowReject(f);
})
.on(VersiaEntities.Like, (l) => {
InboxProcessor.processLikeRequest(l);
})
.on(VersiaEntities.Delete, (d) => {
InboxProcessor.processDelete(d);
})
.on(VersiaEntities.User, async (u) => {
await User.fromVersia(u);
})
.sort(() => {
throw new ApiError(400, "Unknown entity type");
},
});
});
} catch (e) {
return this.handleError(e as Error);
}
}
/**
* Handles Note entity processing.
*
* @returns {Promise<void>}
*/
private async processNote(): Promise<void> {
const note = this.body as VersiaNote;
const author = await User.resolve(new URL(note.author));
const instance = await Instance.resolve(new URL(note.uri));
if (!instance) {
throw new ApiError(404, "Instance not found");
}
if (!author) {
throw new ApiError(404, "Author not found");
}
await Note.fromVersia(note, author, instance);
}
/**
* Handles Follow entity processing.
*
* @param {VersiaFollow} follow - The Follow entity to process.
* @returns {Promise<void>}
*/
private async processFollowRequest(): Promise<void> {
const follow = this.body as unknown as VersiaFollow;
const author = await User.resolve(new URL(follow.author));
const followee = await User.resolve(new URL(follow.followee));
private static async processFollowRequest(
follow: VersiaEntities.Follow,
): Promise<void> {
const author = await User.resolve(follow.data.author);
const followee = await User.resolve(follow.data.followee);
if (!author) {
throw new ApiError(404, "Author not found");
@ -311,19 +254,21 @@ export class InboxProcessor {
);
if (!followee.data.isLocked) {
await followee.sendFollowAccept(author);
await followee.acceptFollowRequest(author);
}
}
/**
* Handles FollowAccept entity processing
*
* @param {VersiaFollowAccept} followAccept - The FollowAccept entity to process.
* @returns {Promise<void>}
*/
private async processFollowAccept(): Promise<void> {
const followAccept = this.body as unknown as VersiaFollowAccept;
const author = await User.resolve(new URL(followAccept.author));
const follower = await User.resolve(new URL(followAccept.follower));
private static async processFollowAccept(
followAccept: VersiaEntities.FollowAccept,
): Promise<void> {
const author = await User.resolve(followAccept.data.author);
const follower = await User.resolve(followAccept.data.follower);
if (!author) {
throw new ApiError(404, "Author not found");
@ -351,12 +296,14 @@ export class InboxProcessor {
/**
* Handles FollowReject entity processing
*
* @param {VersiaFollowReject} followReject - The FollowReject entity to process.
* @returns {Promise<void>}
*/
private async processFollowReject(): Promise<void> {
const followReject = this.body as unknown as VersiaFollowReject;
const author = await User.resolve(new URL(followReject.author));
const follower = await User.resolve(new URL(followReject.follower));
private static async processFollowReject(
followReject: VersiaEntities.FollowReject,
): Promise<void> {
const author = await User.resolve(followReject.data.author);
const follower = await User.resolve(followReject.data.follower);
if (!author) {
throw new ApiError(404, "Author not found");
@ -384,21 +331,22 @@ export class InboxProcessor {
/**
* Handles Delete entity processing.
*
* @param {VersiaDelete} delete_ - The Delete entity to process.
* @returns {Promise<void>}
*/
public async processDelete(): Promise<void> {
// JS doesn't allow the use of `delete` as a variable name
const delete_ = this.body as unknown as VersiaDelete;
const toDelete = delete_.deleted;
*/ // JS doesn't allow the use of `delete` as a variable name
public static async processDelete(
delete_: VersiaEntities.Delete,
): Promise<void> {
const toDelete = delete_.data.deleted;
const author = delete_.author
? await User.resolve(new URL(delete_.author))
const author = delete_.data.author
? await User.resolve(delete_.data.author)
: null;
switch (delete_.deleted_type) {
switch (delete_.data.deleted_type) {
case "Note": {
const note = await Note.fromSql(
eq(Notes.uri, toDelete),
eq(Notes.uri, toDelete.href),
author ? eq(Notes.authorId, author.id) : undefined,
);
@ -413,7 +361,7 @@ export class InboxProcessor {
return;
}
case "User": {
const userToDelete = await User.resolve(new URL(toDelete));
const userToDelete = await User.resolve(toDelete);
if (!userToDelete) {
throw new ApiError(404, "User to delete not found");
@ -428,7 +376,7 @@ export class InboxProcessor {
}
case "pub.versia:likes/Like": {
const like = await Like.fromSql(
eq(Likes.uri, toDelete),
eq(Likes.uri, toDelete.href),
author ? eq(Likes.likerId, author.id) : undefined,
);
@ -445,7 +393,7 @@ export class InboxProcessor {
default: {
throw new ApiError(
400,
`Deletion of object ${toDelete} not implemented`,
`Deletion of object ${toDelete.href} not implemented`,
);
}
}
@ -454,12 +402,14 @@ export class InboxProcessor {
/**
* Handles Like entity processing.
*
* @param {VersiaLikeExtension} like - The Like entity to process.
* @returns {Promise<void>}
*/
private async processLikeRequest(): Promise<void> {
const like = this.body as unknown as VersiaLikeExtension;
const author = await User.resolve(new URL(like.author));
const likedNote = await Note.resolve(new URL(like.liked));
private static async processLikeRequest(
like: VersiaEntities.Like,
): Promise<void> {
const author = await User.resolve(like.data.author);
const likedNote = await Note.resolve(like.data.liked);
if (!author) {
throw new ApiError(404, "Author not found");
@ -469,23 +419,7 @@ export class InboxProcessor {
throw new ApiError(404, "Liked Note not found");
}
await author.like(likedNote, like.uri);
}
/**
* Handles User entity processing (profile edits).
*
* @returns {Promise<void>}
*/
private async processUserRequest(): Promise<void> {
const user = this.body as unknown as VersiaUser;
const instance = await Instance.resolve(new URL(user.uri));
if (!instance) {
throw new ApiError(404, "Instance not found");
}
await User.fromVersia(user, instance);
await author.like(likedNote, like.data.uri);
}
/**

View file

@ -3,7 +3,8 @@ import { Queue } from "bullmq";
import { Worker } from "bullmq";
import chalk from "chalk";
import { config } from "~/config.ts";
import type { KnownEntity } from "~/types/api";
import * as VersiaEntities from "~/packages/sdk/entities";
import type { JSONObject } from "~/packages/sdk/types";
import { connection } from "~/utils/redis.ts";
export enum DeliveryJobType {
@ -11,7 +12,7 @@ export enum DeliveryJobType {
}
export type DeliveryJobData = {
entity: KnownEntity;
entity: JSONObject;
recipientId: string;
senderId: string;
};
@ -39,7 +40,9 @@ export const getDeliveryWorker = (): Worker<
if (!sender) {
throw new Error(
`Could not resolve sender ID ${chalk.gray(senderId)}`,
`Could not resolve sender ID ${chalk.gray(
senderId,
)}`,
);
}
@ -47,15 +50,35 @@ export const getDeliveryWorker = (): Worker<
if (!recipient) {
throw new Error(
`Could not resolve recipient ID ${chalk.gray(recipientId)}`,
`Could not resolve recipient ID ${chalk.gray(
recipientId,
)}`,
);
}
await job.log(
`Federating entity [${entity.id}] from @${sender.getAcct()} to @${recipient.getAcct()}`,
`Federating entity [${
entity.id
}] from @${sender.getAcct()} to @${recipient.getAcct()}`,
);
await sender.federateToUser(entity, recipient);
const type = entity.type;
const entityCtor = Object.values(VersiaEntities).find(
(ctor) => ctor.name === type,
) as typeof VersiaEntities.Entity | undefined;
if (!entityCtor) {
throw new Error(
`Could not resolve entity type ${chalk.gray(
type,
)} for entity [${entity.id}]`,
);
}
await sender.federateToUser(
await entityCtor.fromJSON(entity),
recipient,
);
await job.log(
`✔ Finished federating entity [${entity.id}]`,

View file

@ -1,10 +1,10 @@
import { getLogger } from "@logtape/logtape";
import type { Entity } from "@versia/federation/types";
import { Instance, User } from "@versia/kit/db";
import { Queue } from "bullmq";
import { Worker } from "bullmq";
import type { SocketAddress } from "bun";
import { config } from "~/config.ts";
import type { JSONObject } from "~/packages/sdk/types.ts";
import { connection } from "~/utils/redis.ts";
import { ApiError } from "../errors/api-error.ts";
import { InboxProcessor } from "../inbox/processor.ts";
@ -14,7 +14,7 @@ export enum InboxJobType {
}
export type InboxJobData = {
data: Entity;
data: JSONObject;
headers: {
"versia-signature"?: string;
"versia-signed-at"?: number;
@ -46,18 +46,25 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
await job.log(`Processing entity [${data.id}]`);
const req = new Request(request.url, {
method: request.method,
headers: new Headers(
Object.entries(headers)
.map(([k, v]) => [k, String(v)])
.concat([
["content-type", "application/json"],
]) as [string, string][],
),
body: request.body,
});
if (headers.authorization) {
try {
const processor = new InboxProcessor(
{
...request,
url: new URL(request.url),
},
req,
data,
null,
{
authorization: headers.authorization,
},
headers.authorization,
getLogger(["federation", "inbox"]),
ip,
);
@ -91,13 +98,7 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
return;
}
const {
"versia-signature": signature,
"versia-signed-at": signedAt,
"versia-signed-by": signedBy,
} = headers as {
"versia-signature": string;
"versia-signed-at": number;
const { "versia-signed-by": signedBy } = headers as {
"versia-signed-by": string;
};
@ -111,7 +112,7 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
return;
}
if (sender?.isLocal()) {
if (sender?.local) {
throw new Error(
"Cannot process federation requests from local users",
);
@ -139,24 +140,27 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
);
}
const key = await crypto.subtle.importKey(
"spki",
Buffer.from(
sender?.data.publicKey ??
remoteInstance.data.publicKey.key,
"base64",
),
"Ed25519",
false,
["verify"],
);
try {
const processor = new InboxProcessor(
{
...request,
url: new URL(request.url),
},
req,
data,
{
instance: remoteInstance,
key:
sender?.data.publicKey ??
remoteInstance.data.publicKey.key,
},
{
signature,
signedAt: new Date(signedAt * 1000),
authorization: undefined,
key,
},
undefined,
getLogger(["federation", "inbox"]),
ip,
);
@ -178,7 +182,9 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
);
await remoteInstance.sendMessage(
`Failed processing entity [${data.uri}] delivered to inbox. Returned error:\n\n${JSON.stringify(
`Failed processing entity [${
data.uri
}] delivered to inbox. Returned error:\n\n${JSON.stringify(
e.message,
null,
4,

View file

@ -155,7 +155,7 @@ export class SonicSearchManager {
private static getNthDatabaseAccountBatch(
n: number,
batchSize = 1000,
): Promise<Record<string, string | Date>[]> {
): Promise<Record<string, string | null | Date>[]> {
return db.query.Users.findMany({
offset: n * batchSize,
limit: batchSize,

View file

@ -48,11 +48,10 @@ export const createUserCommand = defineCommand(
throw new Error(`User ${chalk.gray(username)} is taken.`);
}
const user = await User.fromDataLocal({
const user = await User.register(username, {
email,
password,
username,
admin,
isAdmin: admin,
});
if (!user) {

View file

@ -3,6 +3,7 @@ import chalk from "chalk";
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
import { type Root, defineCommand } from "clerc";
import ora from "ora";
import { User } from "~/classes/database/user.ts";
import { retrieveUser } from "../utils.ts";
export const refetchUserCommand = defineCommand(
@ -20,7 +21,7 @@ export const refetchUserCommand = defineCommand(
throw new Error(`User ${chalk.gray(handle)} not found.`);
}
if (user.isLocal()) {
if (user.local) {
throw new Error(
"This user is local and as such cannot be refetched.",
);
@ -29,7 +30,7 @@ export const refetchUserCommand = defineCommand(
const spinner = ora("Refetching user").start();
try {
await user.updateFromRemote();
await User.fromVersia(user.uri);
} catch (error) {
spinner.fail(
`Failed to refetch user ${chalk.gray(user.data.username)}`,

View file

@ -4,7 +4,6 @@ import type {
Status as StatusSchema,
} from "@versia/client/schemas";
import type { RolePermission } from "@versia/client/schemas";
import type { ContentFormat, InstanceMetadata } from "@versia/federation/types";
import type { Challenge } from "altcha-lib/types";
import { relations, sql } from "drizzle-orm";
import {
@ -20,6 +19,13 @@ import {
uuid,
} from "drizzle-orm/pg-core";
import type { z } from "zod";
import type {
ContentFormatSchema,
ImageContentFormatSchema,
InstanceMetadataSchema,
NonTextContentFormatSchema,
TextContentFormatSchema,
} from "~/packages/sdk/schemas";
// biome-ignore lint/nursery/useExplicitType: Type is too complex
const createdAt = () =>
@ -361,9 +367,13 @@ export const TokensRelations = relations(Tokens, ({ one }) => ({
export const Medias = pgTable("Medias", {
id: id(),
content: jsonb("content").notNull().$type<ContentFormat>(),
originalContent: jsonb("original_content").$type<ContentFormat>(),
thumbnail: jsonb("thumbnail").$type<ContentFormat>(),
content: jsonb("content")
.notNull()
.$type<z.infer<typeof ContentFormatSchema>>(),
originalContent:
jsonb("original_content").$type<z.infer<typeof ContentFormatSchema>>(),
thumbnail:
jsonb("thumbnail").$type<z.infer<typeof ImageContentFormatSchema>>(),
blurhash: text("blurhash"),
});
@ -448,7 +458,7 @@ export const Notes = pgTable("Notes", {
onDelete: "cascade",
onUpdate: "cascade",
}),
sensitive: boolean("sensitive").notNull(),
sensitive: boolean("sensitive").notNull().default(false),
spoilerText: text("spoiler_text").default("").notNull(),
applicationId: uuid("applicationId").references(() => Applications.id, {
onDelete: "set null",
@ -506,7 +516,7 @@ export const Instances = pgTable("Instances", {
baseUrl: text("base_url").notNull(),
name: text("name").notNull(),
version: text("version").notNull(),
logo: jsonb("logo").$type<ContentFormat>(),
logo: jsonb("logo").$type<typeof NonTextContentFormatSchema._input>(),
disableAutomoderation: boolean("disable_automoderation")
.default(false)
.notNull(),
@ -515,8 +525,14 @@ export const Instances = pgTable("Instances", {
.$type<"versia" | "activitypub">()
.default("versia"),
inbox: text("inbox"),
publicKey: jsonb("public_key").$type<InstanceMetadata["public_key"]>(),
extensions: jsonb("extensions").$type<InstanceMetadata["extensions"]>(),
publicKey:
jsonb("public_key").$type<
(typeof InstanceMetadataSchema._input)["public_key"]
>(),
extensions:
jsonb("extensions").$type<
(typeof InstanceMetadataSchema._input)["extensions"]
>(),
});
export const InstancesRelations = relations(Instances, ({ many }) => ({
@ -540,7 +556,7 @@ export const Users = pgTable(
id: id(),
uri: uri(),
username: text("username").notNull(),
displayName: text("display_name").notNull(),
displayName: text("display_name"),
password: text("password"),
email: text("email"),
note: text("note").default("").notNull(),
@ -549,8 +565,8 @@ export const Users = pgTable(
passwordResetToken: text("password_reset_token"),
fields: jsonb("fields").notNull().default("[]").$type<
{
key: ContentFormat;
value: ContentFormat;
key: z.infer<typeof TextContentFormatSchema>;
value: z.infer<typeof TextContentFormatSchema>;
}[]
>(),
endpoints: jsonb("endpoints").$type<Partial<{
@ -562,7 +578,7 @@ export const Users = pgTable(
inbox: string;
outbox: string;
}> | null>(),
source: jsonb("source").notNull().$type<z.infer<typeof Source>>(),
source: jsonb("source").$type<z.infer<typeof Source>>(),
avatarId: uuid("avatarId").references(() => Medias.id, {
onDelete: "set null",
onUpdate: "cascade",

View file

@ -92,8 +92,8 @@
"@scalar/hono-api-reference": "^0.8.0",
"@sentry/bun": "^9.11.0",
"@versia/client": "workspace:*",
"@versia/federation": "^0.2.1",
"@versia/kit": "workspace:*",
"@versia/sdk": "workspace:*",
"altcha-lib": "^1.2.0",
"blurhash": "^2.0.5",
"bullmq": "^5.47.2",

220
packages/sdk/README.md Normal file
View file

@ -0,0 +1,220 @@
<p align="center">
<a href="https://versia.pub"><img src="https://cdn.versia.pub/branding/logo-dark.svg" alt="Versia Logo" height="110"></a>
</p>
<center><h1><code>@versia/sdk</code></h1></center>
Federation types, validators and cryptography for Versia server implementations.
## Usage
## Entities
The `@versia/sdk/entities` module provides TypeScript classes for working with Versia entities. These classes provide type-safe access to entity properties and methods for serialization/deserialization.
```ts
import { Note, User } from "@versia/sdk/entities";
const note = new Note({
id: "00000000-0000-0000-0000-000000000000",
type: "Note",
});
// You can also parse from JSON, which will apply the schema validation
const invalidJson = {
id: "00000000-0000-0000-0000-000000000000",
invalid: "property",
};
// Will throw an error
const invalidNote = await Note.fromJSON(invalidJson);
const validJson = {
id: "00000000-0000-0000-0000-000000000000",
type: "Note",
};
const validNote = await Note.fromJSON(validJson);
```
Some entities like `Note` have additional properties, like `content` or `attachments`, which are automatically calculated from the relevant properties.
```ts
import { TextContentFormat, Note } from "@versia/sdk/entities";
const note = new Note({
id: "00000000-0000-0000-0000-000000000000",
type: "Note",
content: {
"text/plain": {
content: "Hello, world!",
remote: false,
},
},
});
const content = note.content;
// Is equivalent to
const content = new TextContentFormat(note.data.content);
```
## Schemas
Additionally, the [**Zod**](https://zod.dev) schemas used for validation are available in the `@versia/sdk/schemas` module. You can use these to directly validate incoming data, without using the entity classes.
```ts
import { NoteSchema, UserSchema } from "@versia/sdk/schemas";
const response = await fetch("https://example.com/notes/123");
const json = await response.json();
const noteSchema = NoteSchema.parse(json);
```
## Sorter
The `@versia/sdk/sorter` module provides a class for inbox request handling. It allows you to automatically sort and process incoming entities based on their type.
```ts
import { EntitySorter } from "@versia/sdk";
import { Note, User } from "@versia/sdk/entities";
app.post("/inbox", async (req, res) => {
const json = await req.json();
const sorter = new EntitySorter(json);
await sorter
.on(Note, (note) => {
console.log(note);
})
.on(User, (user) => {
console.log(user);
})
.sort();
});
```
## Cryptography
The `@versia/sdk/crypto` module provides functions for signing and verifying requests using the [**Ed25519**](https://en.wikipedia.org/wiki/EdDSA) algorithm.
```ts
import { sign, verify } from "@versia/sdk/crypto";
const keys = await crypto.subtle.generateKey("Ed25519", true, [
"sign",
"verify",
]);
// URI of the User that is signing the request
const authorUrl = new URL("https://example.com");
const req = new Request("https://example.com/notes/123", {
method: "POST",
body: JSON.stringify({
id: "00000000-0000-0000-0000-000000000000",
type: "Note",
}),
});
const signedReq = await sign(keys.privateKey, authorUrl, req);
const verified = await verify(keys.publicKey, signedReq);
```
### Prerequisites
#### For Usage
See the [**Compatibility**](#compatibility) section for the supported environments. Any package manager can be used to install the packages.
#### For Development
- [**Bun**](https://bun.sh) version `1.1.8` or higher.
- Either the [**Linux**](https://www.linux.org) or [**macOS**](https://www.apple.com/macos) operating systems. ([**Windows**](https://www.microsoft.com/windows) will work, but is not officially supported.)
### Compatibility
This library is built for JavaScript runtimes with the support for:
- [**ES Modules**](https://nodejs.org/api/esm.html)
- [**ECMAScript 2020**](https://www.ecma-international.org/ecma-262/11.0/index.html)
- (only required for cryptography) [**Ed25519**](https://en.wikipedia.org/wiki/EdDSA) cryptography in the [**WebCrypto API**](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API)
#### Runtimes
- **Node.js**: 14.0+ is the minimum (18.0+ for cryptography), but only Node.js 20.0+ (LTS) is officially supported.
- **Deno**: Support is unknown. 1.0+ is expected to work.
- **Bun**: Bun 1.1.8 is the minimum-supported version. As Bun is rapidly evolving, this may change. Previous versions may also work.
#### Browsers
Consequently, this library is compatible without any bundling in the following browser versions:
- **Chrome**: 80+
- **Edge**: 80+
- **Firefox**: 74+
- **Safari**: 13.1+
- **Opera**: 67+
- **Internet Explorer**: None
Cryptography functions are supported in the following browsers:
- **Safari**: 17.0+
- **Firefox**: 129.0+
- **Chrome**: 113.0+ with `#enable-experimental-web-platform-features` enabled
If you are targeting older browsers, please don't, you are doing yourself a disservice.
Transpilation to non-ES Module environments is not officially supported, but should be simple with the use of a bundler like [**Parcel**](https://parceljs.org) or [**Rollup**](https://rollupjs.org).
### Installation
Package is distributed as a scoped package on the NPM registry and [JSR](https://jsr.io).
We strongly recommend using JSR over NPM for all your packages that are available on it.
```bash
# NPM version
deno add npm:@versia/sdk # For Deno
npm install @versia/sdk # For NPM
yarn add @versia/sdk # For Yarn
pnpm add @versia/sdk # For PNPM
bun add @versia/sdk # For Bun
# JSR version
deno add @versia/sdk # For Deno
npx jsr add @versia/sdk # For JSR
yarn dlx jsr add @versia/sdk # For Yarn
pnpm dlx jsr add @versia/sdk # For PNPM
bunx jsr add @versia/sdk # For Bun
```
#### From Source
If you want to install from source, you can clone [this repository](https://github.com/versia-pub/api) and run the following commands:
```bash
bun install # Install dependencies
bun run build # Build the packages
```
The built package will be in the `sdk/dist` folder.
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Acknowledgments
### Projects
- [**Bun**](https://bun.sh): Thanks to the Bun team for creating an amazing JavaScript runtime.
- [**TypeScript**](https://www.typescriptlang.org): TypeScript is the backbone of this project.
- [**Node.js**](https://nodejs.org): Node.js created the idea of JavaScript on the server.
### People
- [**April John**](https://github.com/cutestnekoaqua): Creator and maintainer of the Versia Server ActivityPub bridge.

94
packages/sdk/crypto.ts Normal file
View file

@ -0,0 +1,94 @@
const stringToBase64Hash = async (str: string): Promise<string> => {
const buffer = new TextEncoder().encode(str);
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
const hashArray = new Uint8Array(hashBuffer);
return hashArray.toBase64();
};
const base64ToArrayBuffer = (base64: string): ArrayBuffer =>
Uint8Array.fromBase64(base64).buffer as ArrayBuffer;
/**
* Signs a request using the Ed25519 algorithm, according to the [**Versia**](https://versia.pub/signatures) specification.
*
* @see https://versia.pub/signatures
* @param privateKey - Private key of the User that is signing the request.
* @param authorUrl - URL of the User that is signing the request.
* @param req - Request to sign.
* @param timestamp - (optional) Timestamp of the request.
* @returns The signed request.
*/
export const sign = async (
privateKey: CryptoKey,
authorUrl: URL,
req: Request,
timestamp = new Date(),
): Promise<Request> => {
const body = await req.clone().text();
const url = new URL(req.url);
const digest = stringToBase64Hash(body);
const timestampSecs = Math.floor(timestamp.getTime() / 1000);
const signedString = `${req.method.toLowerCase()} ${encodeURI(
url.pathname,
)} ${timestampSecs} ${digest}`;
const signature = await crypto.subtle.sign(
"Ed25519",
privateKey,
new TextEncoder().encode(signedString),
);
const signatureBase64 = new Uint8Array(signature).toBase64();
const newReq = new Request(req, {
headers: {
...req.headers,
"Versia-Signature": signatureBase64,
"Versia-Signed-At": String(timestampSecs),
"Versia-Signed-By": authorUrl.href,
},
});
return newReq;
};
/**
* Verifies a signed request using the Ed25519 algorithm, according to the [**Versia**](https://versia.pub/signatures) specification.
*
* @see https://versia.pub/signatures
* @param publicKey - Public key of the User that is verifying the request.
* @param req - Request to verify.
* @returns Whether the request signature is valid or not.
*/
export const verify = async (
publicKey: CryptoKey,
req: Request,
): Promise<boolean> => {
const signature = req.headers.get("Versia-Signature");
const signedAt = req.headers.get("Versia-Signed-At");
const signedBy = req.headers.get("Versia-Signed-By");
if (!(signature && signedAt && signedBy)) {
return false;
}
const body = await req.clone().text();
const url = new URL(req.url);
const digest = await stringToBase64Hash(body);
const expectedSignedString = `${req.method.toLowerCase()} ${encodeURI(
url.pathname,
)} ${signedAt} ${digest}`;
// Check if this matches the signature
return crypto.subtle.verify(
"Ed25519",
publicKey,
base64ToArrayBuffer(signature),
new TextEncoder().encode(expectedSignedString),
);
};

View file

@ -0,0 +1,29 @@
import type { z } from "zod";
import {
CollectionSchema,
URICollectionSchema,
} from "../schemas/collection.ts";
import type { JSONObject } from "../types.ts";
import { Entity } from "./entity.ts";
export class Collection extends Entity {
public constructor(public data: z.infer<typeof CollectionSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<Collection> {
return CollectionSchema.parseAsync(json).then((u) => new Collection(u));
}
}
export class URICollection extends Entity {
public constructor(public data: z.infer<typeof URICollectionSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<URICollection> {
return URICollectionSchema.parseAsync(json).then(
(u) => new URICollection(u),
);
}
}

View file

@ -0,0 +1,82 @@
import type { z } from "zod";
import {
AudioContentFormatSchema,
ContentFormatSchema,
ImageContentFormatSchema,
NonTextContentFormatSchema,
TextContentFormatSchema,
VideoContentFormatSchema,
} from "../schemas/contentformat.ts";
import type { JSONObject } from "../types.ts";
export class ContentFormat {
public static fromJSON(data: JSONObject): Promise<ContentFormat> {
return ContentFormatSchema.parseAsync(data).then(
(d) => new ContentFormat(d),
);
}
public constructor(public data: z.infer<typeof ContentFormatSchema>) {}
}
export class TextContentFormat extends ContentFormat {
public static fromJSON(data: JSONObject): Promise<TextContentFormat> {
return TextContentFormatSchema.parseAsync(data).then(
(d) => new TextContentFormat(d),
);
}
public constructor(public data: z.infer<typeof TextContentFormatSchema>) {
super(data);
}
}
export class NonTextContentFormat extends ContentFormat {
public static fromJSON(data: JSONObject): Promise<NonTextContentFormat> {
return NonTextContentFormatSchema.parseAsync(data).then(
(d) => new NonTextContentFormat(d),
);
}
public constructor(
public data: z.infer<typeof NonTextContentFormatSchema>,
) {
super(data);
}
}
export class ImageContentFormat extends ContentFormat {
public static fromJSON(data: JSONObject): Promise<ImageContentFormat> {
return ImageContentFormatSchema.parseAsync(data).then(
(d) => new ImageContentFormat(d),
);
}
public constructor(public data: z.infer<typeof ImageContentFormatSchema>) {
super(data);
}
}
export class VideoContentFormat extends ContentFormat {
public static fromJSON(data: JSONObject): Promise<VideoContentFormat> {
return VideoContentFormatSchema.parseAsync(data).then(
(d) => new VideoContentFormat(d),
);
}
public constructor(public data: z.infer<typeof VideoContentFormatSchema>) {
super(data);
}
}
export class AudioContentFormat extends ContentFormat {
public static fromJSON(data: JSONObject): Promise<AudioContentFormat> {
return AudioContentFormatSchema.parseAsync(data).then(
(d) => new AudioContentFormat(d),
);
}
public constructor(public data: z.infer<typeof AudioContentFormatSchema>) {
super(data);
}
}

View file

@ -0,0 +1,16 @@
import type { z } from "zod";
import { DeleteSchema } from "../schemas/delete.ts";
import type { JSONObject } from "../types.ts";
import { Entity } from "./entity.ts";
export class Delete extends Entity {
public static name = "Delete";
public constructor(public data: z.infer<typeof DeleteSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<Delete> {
return DeleteSchema.parseAsync(json).then((u) => new Delete(u));
}
}

View file

@ -0,0 +1,17 @@
import { EntitySchema } from "../schemas/entity.ts";
import type { JSONObject } from "../types.ts";
export class Entity {
public static name = "Entity";
// biome-ignore lint/suspicious/noExplicitAny: This is a base class that is never instanciated directly
public constructor(public data: any) {}
public static fromJSON(json: JSONObject): Promise<Entity> {
return EntitySchema.parseAsync(json).then((u) => new Entity(u));
}
public toJSON(): JSONObject {
return this.data;
}
}

View file

@ -0,0 +1,28 @@
import type { z } from "zod";
import { DislikeSchema, LikeSchema } from "../../schemas/extensions/likes.ts";
import type { JSONObject } from "../../types.ts";
import { Entity } from "../entity.ts";
export class Like extends Entity {
public static name = "pub.versia:likes/Like";
public constructor(public data: z.infer<typeof LikeSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<Like> {
return LikeSchema.parseAsync(json).then((u) => new Like(u));
}
}
export class Dislike extends Entity {
public static name = "pub.versia:likes/Dislike";
public constructor(public data: z.infer<typeof DislikeSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<Dislike> {
return DislikeSchema.parseAsync(json).then((u) => new Dislike(u));
}
}

View file

@ -0,0 +1,16 @@
import type { z } from "zod";
import { VoteSchema } from "../../schemas/extensions/polls.ts";
import type { JSONObject } from "../../types.ts";
import { Entity } from "../entity.ts";
export class Vote extends Entity {
public static name = "pub.versia:polls/Vote";
public constructor(public data: z.infer<typeof VoteSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<Vote> {
return VoteSchema.parseAsync(json).then((u) => new Vote(u));
}
}

View file

@ -0,0 +1,16 @@
import type { z } from "zod";
import { ReactionSchema } from "../../schemas/extensions/reactions.ts";
import type { JSONObject } from "../../types.ts";
import { Entity } from "../entity.ts";
export class Reaction extends Entity {
public static name = "pub.versia:reactions/Reaction";
public constructor(public data: z.infer<typeof ReactionSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<Reaction> {
return ReactionSchema.parseAsync(json).then((u) => new Reaction(u));
}
}

View file

@ -0,0 +1,16 @@
import type { z } from "zod";
import { ReportSchema } from "../../schemas/extensions/reports.ts";
import type { JSONObject } from "../../types.ts";
import { Entity } from "../entity.ts";
export class Report extends Entity {
public static name = "pub.versia:reports/Report";
public constructor(public data: z.infer<typeof ReportSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<Report> {
return ReportSchema.parseAsync(json).then((u) => new Report(u));
}
}

View file

@ -0,0 +1,16 @@
import type { z } from "zod";
import { ShareSchema } from "../../schemas/extensions/share.ts";
import type { JSONObject } from "../../types.ts";
import { Entity } from "../entity.ts";
export class Share extends Entity {
public static name = "pub.versia:share/Share";
public constructor(public data: z.infer<typeof ShareSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<Share> {
return ShareSchema.parseAsync(json).then((u) => new Share(u));
}
}

View file

@ -0,0 +1,61 @@
import type { z } from "zod";
import {
FollowAcceptSchema,
FollowRejectSchema,
FollowSchema,
UnfollowSchema,
} from "../schemas/follow.ts";
import type { JSONObject } from "../types.ts";
import { Entity } from "./entity.ts";
export class Follow extends Entity {
public static name = "Follow";
public constructor(public data: z.infer<typeof FollowSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<Follow> {
return FollowSchema.parseAsync(json).then((u) => new Follow(u));
}
}
export class FollowAccept extends Entity {
public static name = "FollowAccept";
public constructor(public data: z.infer<typeof FollowAcceptSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<FollowAccept> {
return FollowAcceptSchema.parseAsync(json).then(
(u) => new FollowAccept(u),
);
}
}
export class FollowReject extends Entity {
public static name = "FollowReject";
public constructor(public data: z.infer<typeof FollowRejectSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<FollowReject> {
return FollowRejectSchema.parseAsync(json).then(
(u) => new FollowReject(u),
);
}
}
export class Unfollow extends Entity {
public static name = "Unfollow";
public constructor(public data: z.infer<typeof UnfollowSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<Unfollow> {
return UnfollowSchema.parseAsync(json).then((u) => new Unfollow(u));
}
}

View file

@ -0,0 +1,21 @@
// biome-ignore lint/performance/noBarrelFile: <explanation>
export { User } from "./user.ts";
export { Note } from "./note.ts";
export { Entity } from "./entity.ts";
export { Delete } from "./delete.ts";
export { InstanceMetadata } from "./instancemetadata.ts";
export {
ImageContentFormat,
AudioContentFormat,
NonTextContentFormat,
TextContentFormat,
ContentFormat,
VideoContentFormat,
} from "./contentformat.ts";
export { Follow, FollowAccept, FollowReject, Unfollow } from "./follow.ts";
export { Collection, URICollection } from "./collection.ts";
export { Like, Dislike } from "./extensions/likes.ts";
export { Vote } from "./extensions/polls.ts";
export { Reaction } from "./extensions/reactions.ts";
export { Report } from "./extensions/reports.ts";
export { Share } from "./extensions/share.ts";

View file

@ -0,0 +1,31 @@
import type { z } from "zod";
import { InstanceMetadataSchema } from "../schemas/instance.ts";
import type { JSONObject } from "../types.ts";
import { ImageContentFormat } from "./contentformat.ts";
import { Entity } from "./entity.ts";
export class InstanceMetadata extends Entity {
public static name = "InstanceMetadata";
public constructor(public data: z.infer<typeof InstanceMetadataSchema>) {
super(data);
}
public get logo(): ImageContentFormat | undefined {
return this.data.logo
? new ImageContentFormat(this.data.logo)
: undefined;
}
public get banner(): ImageContentFormat | undefined {
return this.data.banner
? new ImageContentFormat(this.data.banner)
: undefined;
}
public static fromJSON(json: JSONObject): Promise<InstanceMetadata> {
return InstanceMetadataSchema.parseAsync(json).then(
(u) => new InstanceMetadata(u),
);
}
}

View file

@ -0,0 +1,29 @@
import type { z } from "zod";
import { NoteSchema } from "../schemas/note.ts";
import type { JSONObject } from "../types.ts";
import { NonTextContentFormat, TextContentFormat } from "./contentformat.ts";
import { Entity } from "./entity.ts";
export class Note extends Entity {
public static name = "Note";
public constructor(public data: z.infer<typeof NoteSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<Note> {
return NoteSchema.parseAsync(json).then((n) => new Note(n));
}
public get attachments(): NonTextContentFormat[] {
return (
this.data.attachments?.map((a) => new NonTextContentFormat(a)) ?? []
);
}
public get content(): TextContentFormat | undefined {
return this.data.content
? new TextContentFormat(this.data.content)
: undefined;
}
}

View file

@ -0,0 +1,33 @@
import type { z } from "zod";
import { UserSchema } from "../schemas/user.ts";
import type { JSONObject } from "../types.ts";
import { ImageContentFormat, TextContentFormat } from "./contentformat.ts";
import { Entity } from "./entity.ts";
export class User extends Entity {
public static name = "User";
public constructor(public data: z.infer<typeof UserSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<User> {
return UserSchema.parseAsync(json).then((u) => new User(u));
}
public get avatar(): ImageContentFormat | undefined {
return this.data.avatar
? new ImageContentFormat(this.data.avatar)
: undefined;
}
public get header(): ImageContentFormat | undefined {
return this.data.header
? new ImageContentFormat(this.data.header)
: undefined;
}
public get bio(): TextContentFormat | undefined {
return this.data.bio ? new TextContentFormat(this.data.bio) : undefined;
}
}

203
packages/sdk/http.ts Normal file
View file

@ -0,0 +1,203 @@
import { sign } from "./crypto.ts";
import { Collection, URICollection } from "./entities/collection.ts";
import type { Entity } from "./entities/entity.ts";
import { homepage, version } from "./package.json";
import { WebFingerSchema } from "./schemas/webfinger.ts";
const DEFAULT_UA = `VersiaFederationClient/${version} (+${homepage})`;
/**
* A class that handles fetching Versia entities
*
* @example
* const requester = new FederationRequester(privateKey, authorUrl);
*
* const user = await requester.fetchEntity(
* new URL("https://example.com/users/1"),
* User,
* );
*
* console.log(user); // => User { ... }
*/
export class FederationRequester {
public constructor(
private readonly privateKey: CryptoKey,
private readonly authorUrl: URL,
) {}
public async fetchEntity<T extends typeof Entity>(
url: URL,
expectedType: T,
): Promise<InstanceType<T>> {
const req = new Request(url, {
method: "GET",
headers: {
Accept: "application/json",
"User-Agent": DEFAULT_UA,
},
});
const finalReq = await sign(this.privateKey, this.authorUrl, req);
const { ok, json, text, headers, status } = await fetch(finalReq);
if (!ok) {
throw new Error(
`Failed to fetch entity from ${url.toString()}: got HTTP code ${status} with body "${await text()}"`,
);
}
const contentType = headers.get("Content-Type");
if (!contentType?.includes("application/json")) {
throw new Error(
`Expected JSON response from ${url.toString()}, got "${contentType}"`,
);
}
const jsonData = await json();
const type = jsonData.type;
if (type && type !== expectedType.name) {
throw new Error(
`Expected entity type "${expectedType.name}", got "${type}"`,
);
}
const entity = await expectedType.fromJSON(jsonData);
return entity as InstanceType<T>;
}
public async postEntity(url: URL, entity: Entity): Promise<Response> {
const req = new Request(url, {
method: "POST",
headers: {
Accept: "application/json",
"User-Agent": DEFAULT_UA,
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify(entity.toJSON()),
});
const finalReq = await sign(this.privateKey, this.authorUrl, req);
return fetch(finalReq);
}
/**
* Recursively go through a Collection of entities until reaching the end
* @param url URL to reach the Collection
* @param expectedType
* @param options.limit Limit the number of entities to fetch
*/
public async resolveCollection<T extends typeof Entity>(
url: URL,
expectedType: T,
options?: {
limit?: number;
},
): Promise<InstanceType<T>[]> {
const entities: InstanceType<T>[] = [];
let nextUrl: URL | null = url;
let limit = options?.limit ?? Number.POSITIVE_INFINITY;
while (nextUrl && limit > 0) {
const collection: Collection = await this.fetchEntity(
nextUrl,
Collection,
);
for (const entity of collection.data.items) {
if (entity.type === expectedType.name) {
entities.push(
(await expectedType.fromJSON(
entity,
)) as InstanceType<T>,
);
}
}
nextUrl = collection.data.next;
limit -= collection.data.items.length;
}
return entities;
}
/**
* Recursively go through a URICollection of entities until reaching the end
* @param url URL to reach the Collection
* @param options.limit Limit the number of entities to fetch
*/
public async resolveURICollection(
url: URL,
options?: {
limit?: number;
},
): Promise<URL[]> {
const entities: URL[] = [];
let nextUrl: URL | null = url;
let limit = options?.limit ?? Number.POSITIVE_INFINITY;
while (nextUrl && limit > 0) {
const collection: URICollection = await this.fetchEntity(
nextUrl,
URICollection,
);
entities.push(...collection.data.items);
nextUrl = collection.data.next;
limit -= collection.data.items.length;
}
return entities;
}
/**
* Attempt to resolve a webfinger URL to a User
* @returns {Promise<User | null>} The resolved User or null if not found
*/
public static async resolveWebFinger(
username: string,
hostname: string,
contentType = "application/json",
serverUrl = `https://${hostname}`,
): Promise<URL | null> {
const { ok, json, text } = await fetch(
new URL(
`/.well-known/webfinger?${new URLSearchParams({
resource: `acct:${username}@${hostname}`,
})}`,
serverUrl,
),
{
method: "GET",
headers: {
Accept: "application/json",
"User-Agent": DEFAULT_UA,
},
},
);
if (!ok) {
throw new Error(
`Failed to fetch webfinger from ${serverUrl}: got HTTP code ${ok} with body "${await text()}"`,
);
}
// Validate the response
const data = await WebFingerSchema.parseAsync(await json());
// Get the first link with a rel of "self"
const selfLink = data.links?.find(
(link) => link.rel === "self" && link.type === contentType,
);
if (!selfLink?.href) {
return null;
}
return new URL(selfLink.href);
}
}

View file

@ -0,0 +1,54 @@
import type { Entity } from "./entities/entity.ts";
import type { JSONObject } from "./types.ts";
type EntitySorterHandlers = Map<
typeof Entity,
(entity: Entity) => MaybePromise<void>
>;
type MaybePromise<T> = T | Promise<T>;
/**
* @example
* const jsonData = { ... };
* const processor = await new EntitySorter(jsonData)
* .on(User, async (user) => {
* // Do something with the user
* })
* .sort();
*/
export class EntitySorter {
private handlers: EntitySorterHandlers = new Map();
public constructor(private jsonData: JSONObject) {}
public on<T extends typeof Entity>(
entity: T,
handler: (entity: InstanceType<T>) => MaybePromise<void>,
): EntitySorter {
this.handlers.set(
entity,
handler as (entity: Entity) => MaybePromise<void>,
);
return this;
}
/**
* Sorts the entity based on the provided JSON data.
* @param {() => MaybePromise<void>} defaultHandler - A default handler to call if no specific handler is found.
* @throws {Error} If no handler is found for the entity type
*/
public async sort(
defaultHandler?: () => MaybePromise<void>,
): Promise<void> {
const type = this.jsonData.type;
const entity = this.handlers.keys().find((key) => key.name === type);
if (entity) {
await this.handlers.get(entity)?.(
await entity.fromJSON(this.jsonData),
);
} else {
await defaultHandler?.();
}
}
}

69
packages/sdk/package.json Normal file
View file

@ -0,0 +1,69 @@
{
"name": "@versia/sdk",
"displayName": "Versia SDK",
"version": "0.0.1",
"author": {
"email": "jesse.wierzbinski@lysand.org",
"name": "Jesse Wierzbinski (CPlusPatch)",
"url": "https://cpluspatch.com"
},
"readme": "README.md",
"repository": {
"type": "git",
"url": "https://github.com/versia-pub/server.git",
"directory": "packages/federation"
},
"bugs": {
"url": "https://github.com/versia-pub/server/issues"
},
"license": "MIT",
"contributors": [
{
"name": "Jesse Wierzbinski",
"email": "jesse.wierzbinski@lysand.org",
"url": "https://cpluspatch.com"
}
],
"maintainers": [
{
"name": "Jesse Wierzbinski",
"email": "jesse.wierzbinski@lysand.org",
"url": "https://cpluspatch.com"
}
],
"description": "Versia Federation SDK",
"categories": ["Other"],
"type": "module",
"engines": {
"bun": ">=1.2.5"
},
"exports": {
".": {
"import": "./inbox-processor.ts",
"default": "./inbox-processor.ts"
},
"./http": {
"import": "./http.ts",
"default": "./http.ts"
},
"./crypto": {
"import": "./crypto.ts",
"default": "./crypto.ts"
},
"./entities": {
"import": "./entities/index.ts",
"default": "./entities/index.ts"
},
"./schemas": {
"import": "./schemas/index.ts",
"default": "./schemas/index.ts"
}
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/lysand"
},
"homepage": "https://versia.pub",
"keywords": ["versia", "typescript", "sdk"],
"packageManager": "bun@1.2.5"
}

64
packages/sdk/regex.ts Normal file
View file

@ -0,0 +1,64 @@
import {
charIn,
charNotIn,
createRegExp,
digit,
exactly,
global,
letter,
not,
oneOrMore,
} from "magic-regexp";
export const semverRegex = new RegExp(
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/gm,
);
/**
* Regular expression for matching an extension_type
* @example pub.versia:custom_emojis/Emoji
*/
export const extensionTypeRegex: RegExp = createRegExp(
// org namespace, then colon, then alphanumeric/_/-, then extension name
exactly(
oneOrMore(exactly(letter.lowercase.or(digit).or(charIn("_-.")))),
exactly(":"),
oneOrMore(exactly(letter.lowercase.or(digit).or(charIn("_-")))),
exactly("/"),
oneOrMore(exactly(letter.or(digit).or(charIn("_-")))),
),
);
/**
* Regular expression for matching an extension
* @example pub.versia:custom_emojis
*/
export const extensionRegex: RegExp = createRegExp(
// org namespace, then colon, then alphanumeric/_/-, then extension name
exactly(
oneOrMore(exactly(letter.lowercase.or(digit).or(charIn("_-.")))),
exactly(":"),
oneOrMore(exactly(letter.lowercase.or(digit).or(charIn("_-")))),
),
);
/**
* Regular expression for matching emojis.
*/
export const emojiRegex: RegExp = createRegExp(
exactly(
exactly(not.letter.or(not.digit).or(charNotIn("_-"))).times(1),
oneOrMore(letter.or(digit).or(charIn("_-"))),
exactly(not.letter.or(not.digit).or(charNotIn("_-"))).times(1),
),
[global],
);
// This will accept a lot of stuff that isn't an ISO string
// but ISO validation is incredibly complex so fuck it
export const isISOString = (val: string | Date): boolean => {
const date = new Date(val);
return !Number.isNaN(date.valueOf());
};
export const ianaTimezoneRegex = /^(?:[A-Za-z]+(?:\/[A-Za-z_]+)+|UTC)$/;

View file

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

View file

@ -0,0 +1,17 @@
import { z } from "zod";
export const f64 = z
.number()
.nonnegative()
.max(2 ** 64 - 1);
export const u64 = z
.number()
.int()
.nonnegative()
.max(2 ** 64 - 1);
export const url = z
.string()
.url()
.transform((z) => new URL(z));

View file

@ -0,0 +1,117 @@
import { types } from "mime-types";
import { z } from "zod";
import { f64, u64 } from "./common.ts";
const hashSizes = {
sha256: 64,
sha512: 128,
"sha3-256": 64,
"sha3-512": 128,
"blake2b-256": 64,
"blake2b-512": 128,
"blake3-256": 64,
"blake3-512": 128,
md5: 32,
sha1: 40,
sha224: 56,
sha384: 96,
"sha3-224": 56,
"sha3-384": 96,
"blake2s-256": 64,
"blake2s-512": 128,
"blake3-224": 56,
"blake3-384": 96,
};
const allMimeTypes = Object.values(types) as [string, ...string[]];
const textMimeTypes = Object.values(types).filter((v) =>
v.startsWith("text/"),
) as [string, ...string[]];
const nonTextMimeTypes = Object.values(types).filter(
(v) => !v.startsWith("text/"),
) as [string, ...string[]];
const imageMimeTypes = Object.values(types).filter((v) =>
v.startsWith("image/"),
) as [string, ...string[]];
const videoMimeTypes = Object.values(types).filter((v) =>
v.startsWith("video/"),
) as [string, ...string[]];
const audioMimeTypes = Object.values(types).filter((v) =>
v.startsWith("audio/"),
) as [string, ...string[]];
export const ContentFormatSchema = z.record(
z.enum(allMimeTypes),
z.strictObject({
content: z.string().or(z.string().url()),
remote: z.boolean(),
description: z.string().nullish(),
size: u64.nullish(),
hash: z
.strictObject(
Object.fromEntries(
Object.entries(hashSizes).map(([k, v]) => [
k,
z.string().length(v).nullish(),
]),
),
)
.nullish(),
thumbhash: z.string().nullish(),
width: u64.nullish(),
height: u64.nullish(),
duration: f64.nullish(),
fps: u64.nullish(),
}),
);
export const TextContentFormatSchema = z.record(
z.enum(textMimeTypes),
ContentFormatSchema.valueSchema
.pick({
content: true,
remote: true,
})
.extend({
content: z.string(),
remote: z.literal(false),
}),
);
export const NonTextContentFormatSchema = z.record(
z.enum(nonTextMimeTypes),
ContentFormatSchema.valueSchema
.pick({
content: true,
remote: true,
description: true,
size: true,
hash: true,
thumbhash: true,
width: true,
height: true,
})
.extend({
content: z.string().url(),
remote: z.literal(true),
}),
);
export const ImageContentFormatSchema = z.record(
z.enum(imageMimeTypes),
NonTextContentFormatSchema.valueSchema,
);
export const VideoContentFormatSchema = z.record(
z.enum(videoMimeTypes),
NonTextContentFormatSchema.valueSchema.extend({
duration: ContentFormatSchema.valueSchema.shape.duration,
fps: ContentFormatSchema.valueSchema.shape.fps,
}),
);
export const AudioContentFormatSchema = z.record(
z.enum(audioMimeTypes),
NonTextContentFormatSchema.valueSchema.extend({
duration: ContentFormatSchema.valueSchema.shape.duration,
}),
);

View file

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

View file

@ -0,0 +1,23 @@
import { z } from "zod";
import { isISOString } from "../regex.ts";
import { url } from "./common.ts";
import { CustomEmojiExtensionSchema } from "./extensions/emojis.ts";
export const ExtensionPropertySchema = z
.object({
"pub.versia:custom_emojis":
CustomEmojiExtensionSchema.optional().nullable(),
})
.catchall(z.any());
export const EntitySchema = z.strictObject({
// biome-ignore lint/style/useNamingConvention:
$schema: z.string().url().nullish(),
id: z.string().max(512),
created_at: z
.string()
.refine((v) => isISOString(v), "must be a valid ISO8601 datetime"),
uri: url,
type: z.string(),
extensions: ExtensionPropertySchema.nullish(),
});

View file

@ -0,0 +1,25 @@
/**
* Custom emojis extension.
* @module federation/schemas/extensions/custom_emojis
* @see module:federation/schemas/base
* @see https://versia.pub/extensions/custom-emojis
*/
import { z } from "zod";
import { emojiRegex } from "../../regex.ts";
import { ImageContentFormatSchema } from "../contentformat.ts";
export const CustomEmojiExtensionSchema = z.strictObject({
emojis: z.array(
z.strictObject({
name: z
.string()
.min(1)
.max(256)
.regex(
emojiRegex,
"Emoji name must be alphanumeric, underscores, or dashes, and surrounded by identifiers",
),
url: ImageContentFormatSchema,
}),
),
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,46 @@
/**
* Vanity extension schema.
* @module federation/schemas/extensions/vanity
* @see module:federation/schemas/base
* @see https://versia.pub/extensions/vanity
*/
import { z } from "zod";
import { ianaTimezoneRegex, isISOString } from "../../regex.ts";
import { url } from "../common.ts";
import {
AudioContentFormatSchema,
ImageContentFormatSchema,
} from "../contentformat.ts";
export const VanityExtensionSchema = z.strictObject({
avatar_overlays: z.array(ImageContentFormatSchema).nullish(),
avatar_mask: ImageContentFormatSchema.nullish(),
background: ImageContentFormatSchema.nullish(),
audio: AudioContentFormatSchema.nullish(),
pronouns: z.record(
z.string(),
z.array(
z.union([
z.strictObject({
subject: z.string(),
object: z.string(),
dependent_possessive: z.string(),
independent_possessive: z.string(),
reflexive: z.string(),
}),
z.string(),
]),
),
),
birthday: z
.string()
.refine((v) => isISOString(v), "must be a valid ISO8601 datetime")
.nullish(),
location: z.string().nullish(),
aliases: z.array(url).nullish(),
timezone: z
.string()
.regex(ianaTimezoneRegex, "must be a valid IANA timezone")
.nullish(),
});

View file

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

View file

@ -0,0 +1,27 @@
// biome-ignore lint/performance/noBarrelFile: <explanation>
export { UserSchema } from "./user.ts";
export { NoteSchema } from "./note.ts";
export { EntitySchema } from "./entity.ts";
export { DeleteSchema } from "./delete.ts";
export { InstanceMetadataSchema } from "./instance.ts";
export {
ContentFormatSchema,
ImageContentFormatSchema,
AudioContentFormatSchema,
NonTextContentFormatSchema,
TextContentFormatSchema,
VideoContentFormatSchema,
} from "./contentformat.ts";
export {
FollowSchema,
FollowAcceptSchema,
FollowRejectSchema,
UnfollowSchema,
} from "./follow.ts";
export { CollectionSchema, URICollectionSchema } from "./collection.ts";
export { LikeSchema, DislikeSchema } from "./extensions/likes.ts";
export { VoteSchema } from "./extensions/polls.ts";
export { ReactionSchema } from "./extensions/reactions.ts";
export { ReportSchema } from "./extensions/reports.ts";
export { ShareSchema } from "./extensions/share.ts";
export { WebFingerSchema } from "./webfinger.ts";

View file

@ -0,0 +1,41 @@
import { z } from "zod";
import { extensionRegex, semverRegex } from "../regex.ts";
import { url } from "./common.ts";
import { ImageContentFormatSchema } from "./contentformat.ts";
import { EntitySchema } from "./entity.ts";
export const InstanceMetadataSchema = EntitySchema.extend({
type: z.literal("InstanceMetadata"),
id: z.null().optional(),
uri: z.null().optional(),
name: z.string().min(1),
software: z.strictObject({
name: z.string().min(1),
version: z.string().min(1),
}),
compatibility: z.strictObject({
versions: z.array(
z.string().regex(semverRegex, "must be a valid SemVer version"),
),
extensions: z.array(
z
.string()
.min(1)
.regex(
extensionRegex,
"must be in the format 'namespaced_url:extension_name', e.g. 'pub.versia:reactions'",
),
),
}),
description: z.string().nullish(),
host: z.string(),
shared_inbox: url.nullish(),
public_key: z.strictObject({
key: z.string().min(1),
algorithm: z.literal("ed25519"),
}),
moderators: url.nullish(),
admins: url.nullish(),
logo: ImageContentFormatSchema.nullish(),
banner: ImageContentFormatSchema.nullish(),
});

View file

@ -0,0 +1,67 @@
import { z } from "zod";
import { url } from "./common.ts";
import {
NonTextContentFormatSchema,
TextContentFormatSchema,
} from "./contentformat.ts";
import { EntitySchema } from "./entity.ts";
import { PollExtensionSchema } from "./extensions/polls.ts";
export const NoteSchema = EntitySchema.extend({
type: z.literal("Note"),
attachments: z.array(NonTextContentFormatSchema).nullish(),
author: url,
category: z
.enum([
"microblog",
"forum",
"blog",
"image",
"video",
"audio",
"messaging",
])
.nullish(),
content: TextContentFormatSchema.nullish(),
collections: z
.strictObject({
replies: url,
quotes: url,
"pub.versia:reactions/Reactions": url.nullish(),
"pub.versia:share/Shares": url.nullish(),
"pub.versia:likes/Likes": url.nullish(),
"pub.versia:likes/Dislikes": url.nullish(),
})
.catchall(url),
device: z
.strictObject({
name: z.string(),
version: z.string().nullish(),
url: url.nullish(),
})
.nullish(),
group: url.or(z.enum(["public", "followers"])).nullish(),
is_sensitive: z.boolean().nullish(),
mentions: z.array(url).nullish(),
previews: z
.array(
z.strictObject({
link: url,
title: z.string(),
description: z.string().nullish(),
image: url.nullish(),
icon: url.nullish(),
}),
)
.nullish(),
quotes: url.nullish(),
replies_to: url.nullish(),
subject: z.string().nullish(),
extensions: EntitySchema.shape.extensions
.unwrap()
.unwrap()
.extend({
"pub.versia:polls": PollExtensionSchema.nullish(),
})
.nullish(),
});

View file

@ -0,0 +1,60 @@
import { z } from "zod";
import { url } from "./common.ts";
import {
ImageContentFormatSchema,
TextContentFormatSchema,
} from "./contentformat.ts";
import { EntitySchema } from "./entity.ts";
import { MigrationExtensionSchema } from "./extensions/migration.ts";
import { VanityExtensionSchema } from "./extensions/vanity.ts";
export const PublicKeyDataSchema = z.strictObject({
key: z.string().min(1),
actor: url,
algorithm: z.literal("ed25519"),
});
export const UserSchema = EntitySchema.extend({
type: z.literal("User"),
avatar: ImageContentFormatSchema.nullish(),
bio: TextContentFormatSchema.nullish(),
display_name: z.string().nullish(),
fields: z
.array(
z.strictObject({
key: TextContentFormatSchema,
value: TextContentFormatSchema,
}),
)
.nullish(),
username: z
.string()
.min(1)
.regex(
/^[a-zA-Z0-9_-]+$/,
"must be alphanumeric, and may contain _ or -",
),
header: ImageContentFormatSchema.nullish(),
public_key: PublicKeyDataSchema,
manually_approves_followers: z.boolean().nullish(),
indexable: z.boolean().nullish(),
inbox: url,
collections: z
.object({
featured: url,
followers: url,
following: url,
outbox: url,
"pub.versia:likes/Likes": url.nullish(),
"pub.versia:likes/Dislikes": url.nullish(),
})
.catchall(url),
extensions: EntitySchema.shape.extensions
.unwrap()
.unwrap()
.extend({
"pub.versia:vanity": VanityExtensionSchema.nullish(),
"pub.versia:migration": MigrationExtensionSchema.nullish(),
})
.nullish(),
});

View file

@ -0,0 +1,19 @@
import { z } from "zod";
import { url } from "./common.ts";
export const WebFingerSchema = z.object({
subject: url,
aliases: z.array(url).optional(),
properties: z.record(url, z.string().or(z.null())).optional(),
links: z
.array(
z.object({
rel: z.string(),
type: z.string().optional(),
href: url.optional(),
titles: z.record(z.string(), z.string()).optional(),
properties: z.record(url, z.string().or(z.null())).optional(),
}),
)
.optional(),
});

11
packages/sdk/types.ts Normal file
View file

@ -0,0 +1,11 @@
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [key: string]: JSONValue };
export interface JSONObject {
[k: string]: JSONValue;
}

View file

@ -235,11 +235,9 @@ export default (plugin: PluginType): void => {
: null;
// Create new user
const user = await User.fromDataLocal({
const user = await User.register(username, {
email: doesEmailExist ? undefined : email,
username,
avatar: avatar ?? undefined,
password: undefined,
});
// Link account

View file

@ -103,8 +103,7 @@ export const getTestUsers = async (
for (let i = 0; i < count; i++) {
const password = randomString(32, "hex");
const user = await User.fromDataLocal({
username: `test-${randomString(8, "hex")}`,
const user = await User.register(`test-${randomString(8, "hex")}`, {
email: `${randomString(16, "hex")}@test.com`,
password,
});

View file

@ -1,20 +1,10 @@
import type {
Delete,
Follow,
FollowAccept,
FollowReject,
InstanceMetadata,
LikeExtension,
Note,
Unfollow,
User,
} from "@versia/federation/types";
import type { SocketAddress } from "bun";
import type { Hono } from "hono";
import type { RouterRoute } from "hono/types";
import type { z } from "zod";
import type { ConfigSchema } from "~/classes/config/schema";
import type { AuthData } from "~/classes/functions/user";
import type * as VersiaEntities from "~/packages/sdk/entities";
export type HttpVerb = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS";
@ -33,12 +23,12 @@ export interface ApiRouteExports {
}
export type KnownEntity =
| Note
| InstanceMetadata
| User
| Follow
| FollowAccept
| FollowReject
| Unfollow
| Delete
| LikeExtension;
| VersiaEntities.Note
| VersiaEntities.InstanceMetadata
| VersiaEntities.User
| VersiaEntities.Follow
| VersiaEntities.FollowAccept
| VersiaEntities.FollowReject
| VersiaEntities.Unfollow
| VersiaEntities.Delete
| VersiaEntities.Like;

View file

@ -1,10 +1,11 @@
import type { ContentFormat } from "@versia/federation/types";
import { htmlToText as htmlToTextLib } from "html-to-text";
import { lookup } from "mime-types";
import type { z } from "zod";
import { config } from "~/config.ts";
import type { ContentFormatSchema } from "~/packages/sdk/schemas";
export const getBestContentType = (
content?: ContentFormat | null,
content?: z.infer<typeof ContentFormatSchema> | null,
): {
content: string;
format: string;
@ -32,7 +33,7 @@ export const getBestContentType = (
export const urlToContentFormat = (
url: URL,
contentType?: string,
): ContentFormat | null => {
): z.infer<typeof ContentFormatSchema> | null => {
if (url.href.startsWith("https://api.dicebear.com/")) {
return {
"image/svg+xml": {