diff --git a/api/api/v1/accounts/[id]/refetch.ts b/api/api/v1/accounts/[id]/refetch.ts
index 954e4577..d096d53e 100644
--- a/api/api/v1/accounts/[id]/refetch.ts
+++ b/api/api/v1/accounts/[id]/refetch.ts
@@ -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);
},
diff --git a/api/api/v1/accounts/index.ts b/api/api/v1/accounts/index.ts
index 31a38585..3eac0054 100644
--- a/api/api/v1/accounts/index.ts
+++ b/api/api/v1/accounts/index.ts
@@ -353,8 +353,7 @@ export default apiRoute((app) =>
);
}
- await User.fromDataLocal({
- username,
+ await User.register(username, {
password,
email,
});
diff --git a/api/api/v1/accounts/lookup/index.ts b/api/api/v1/accounts/lookup/index.ts
index 7dd68a72..c2d13a39 100644
--- a/api/api/v1/accounts/lookup/index.ts
+++ b/api/api/v1/accounts/lookup/index.ts
@@ -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();
diff --git a/api/api/v1/accounts/search/index.ts b/api/api/v1/accounts/search/index.ts
index 30536892..dfe3f421 100644
--- a/api/api/v1/accounts/search/index.ts
+++ b/api/api/v1/accounts/search/index.ts
@@ -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);
diff --git a/api/api/v1/accounts/update_credentials/index.ts b/api/api/v1/accounts/update_credentials/index.ts
index 11d2057f..496c0ae9 100644
--- a/api/api/v1/accounts/update_credentials/index.ts
+++ b/api/api/v1/accounts/update_credentials/index.ts
@@ -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,
);
diff --git a/api/api/v1/accounts/verify_credentials/index.test.ts b/api/api/v1/accounts/verify_credentials/index.test.ts
index cadc7498..79b015f4 100644
--- a/api/api/v1/accounts/verify_credentials/index.test.ts
+++ b/api/api/v1/accounts/verify_credentials/index.test.ts
@@ -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(
diff --git a/api/api/v1/follow_requests/[account_id]/authorize.ts b/api/api/v1/follow_requests/[account_id]/authorize.ts
index 901e261c..29abd259 100644
--- a/api/api/v1/follow_requests/[account_id]/authorize.ts
+++ b/api/api/v1/follow_requests/[account_id]/authorize.ts
@@ -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);
diff --git a/api/api/v1/follow_requests/[account_id]/reject.ts b/api/api/v1/follow_requests/[account_id]/reject.ts
index 1ee1b858..a7e0bf7d 100644
--- a/api/api/v1/follow_requests/[account_id]/reject.ts
+++ b/api/api/v1/follow_requests/[account_id]/reject.ts
@@ -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);
diff --git a/api/api/v1/statuses/[id]/index.ts b/api/api/v1/statuses/[id]/index.ts
index b706ce54..a6fa0873 100644
--- a/api/api/v1/statuses/[id]/index.ts
+++ b/api/api/v1/statuses/[id]/index.ts
@@ -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);
},
);
});
diff --git a/api/api/v1/statuses/[id]/reblog.ts b/api/api/v1/statuses/[id]/reblog.ts
index 433419e5..45253d0a 100644
--- a/api/api/v1/statuses/[id]/reblog.ts
+++ b/api/api/v1/statuses/[id]/reblog.ts
@@ -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);
}
diff --git a/api/api/v1/statuses/index.test.ts b/api/api/v1/statuses/index.test.ts
index 6f6cc6e2..d2571b9e 100644
--- a/api/api/v1/statuses/index.test.ts
+++ b/api/api/v1/statuses/index.test.ts
@@ -218,7 +218,7 @@ describe("/api/v1/statuses", () => {
expect(ok).toBe(true);
expect(data).toMatchObject({
- content: `
Hello, @${users[1].data.username}!
`,
+ content: `Hello, @${users[1].data.username}!
`,
});
expect((data as z.infer).mentions).toBeArrayOfSize(
1,
@@ -241,7 +241,7 @@ describe("/api/v1/statuses", () => {
expect(ok).toBe(true);
expect(data).toMatchObject({
- content: `Hello, @${users[1].data.username}!
`,
+ content: `Hello, @${users[1].data.username}!
`,
});
expect((data as z.infer).mentions).toBeArrayOfSize(
1,
diff --git a/api/api/v1/statuses/index.ts b/api/api/v1/statuses/index.ts
index b0a529a5..a65b893f 100644
--- a/api/api/v1/statuses/index.ts
+++ b/api/api/v1/statuses/index.ts
@@ -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);
},
),
diff --git a/api/api/v2/search/index.ts b/api/api/v2/search/index.ts
index eab6a163..1bc0c01c 100644
--- a/api/api/v2/search/index.ts
+++ b/api/api/v2/search/index.ts
@@ -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);
diff --git a/api/inbox/index.ts b/api/inbox/index.ts
index 4d2c6663..e7f8ad56 100644
--- a/api/inbox/index.ts
+++ b/api/inbox/index.ts
@@ -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,
diff --git a/api/likes/[uuid]/index.ts b/api/likes/[uuid]/index.ts
index d64136b4..82871d25 100644
--- a/api/likes/[uuid]/index.ts
+++ b/api/likes/[uuid]/index.ts
@@ -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();
}
diff --git a/api/notes/[uuid]/index.ts b/api/notes/[uuid]/index.ts
index 677dacf2..2057abda 100644
--- a/api/notes/[uuid]/index.ts
+++ b/api/notes/[uuid]/index.ts
@@ -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();
}
diff --git a/api/notes/[uuid]/quotes.ts b/api/notes/[uuid]/quotes.ts
index 0a53e5c9..b9de0f7c 100644
--- a/api/notes/[uuid]/quotes.ts
+++ b/api/notes/[uuid]/quotes.ts
@@ -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
diff --git a/api/notes/[uuid]/replies.ts b/api/notes/[uuid]/replies.ts
index bc04e127..2bdd377e 100644
--- a/api/notes/[uuid]/replies.ts
+++ b/api/notes/[uuid]/replies.ts
@@ -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
diff --git a/api/users/[uuid]/inbox/index.ts b/api/users/[uuid]/inbox/index.ts
index 024dcfa8..4becb342 100644
--- a/api/users/[uuid]/inbox/index.ts
+++ b/api/users/[uuid]/inbox/index.ts
@@ -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,
diff --git a/api/users/[uuid]/index.ts b/api/users/[uuid]/index.ts
index c0f483a7..c4c2ad22 100644
--- a/api/users/[uuid]/index.ts
+++ b/api/users/[uuid]/index.ts
@@ -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");
}
diff --git a/api/users/[uuid]/outbox/index.ts b/api/users/[uuid]/outbox/index.ts
index a15a6127..1eb452dd 100644
--- a/api/users/[uuid]/outbox/index.ts
+++ b/api/users/[uuid]/outbox/index.ts
@@ -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,
diff --git a/api/well-known/versia.ts b/api/well-known/versia.ts
index 8e01a684..a36cd128 100644
--- a/api/well-known/versia.ts
+++ b/api/well-known/versia.ts
@@ -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(
diff --git a/api/well-known/webfinger/index.ts b/api/well-known/webfinger/index.ts
index b8b34dce..158164b5 100644
--- a/api/well-known/webfinger/index.ts
+++ b/api/well-known/webfinger/index.ts
@@ -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,
{
diff --git a/bun.lock b/bun.lock
index 4e38a200..6d699e09 100644
--- a/bun.lock
+++ b/bun.lock
@@ -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=="],
diff --git a/classes/database/emoji.ts b/classes/database/emoji.ts
index bc0580a1..b7e2c857 100644
--- a/classes/database/emoji.ts
+++ b/classes/database/emoji.ts
@@ -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 & {
@@ -130,7 +131,10 @@ export class Emoji extends BaseInterface {
}
public static async fetchFromRemote(
- emojiToFetch: CustomEmojiExtension["emojis"][0],
+ emojiToFetch: {
+ name: string;
+ url: z.infer;
+ },
instance: Instance,
): Promise {
const existingEmoji = await Emoji.fromSql(
@@ -189,15 +193,23 @@ export class Emoji extends BaseInterface {
};
}
- public toVersia(): CustomEmojiExtension["emojis"][0] {
+ public toVersia(): {
+ name: string;
+ url: z.infer;
+ } {
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;
+ },
instance: Instance,
): Promise {
// Extracts the shortcode from the emoji name (e.g. :shortcode: -> shortcode)
@@ -209,7 +221,9 @@ export class Emoji extends BaseInterface {
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(),
diff --git a/classes/database/instance.ts b/classes/database/instance.ts
index 6302aaf2..1cd26abe 100644
--- a/classes/database/instance.ts
+++ b/classes/database/instance.ts
@@ -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 {
}
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 {
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 {
+ ): Promise {
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 {
}
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 {
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 {
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 {
);
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;
diff --git a/classes/database/like.ts b/classes/database/like.ts
index df473e24..e681eb61 100644
--- a/classes/database/like.ts
+++ b/classes/database/like.ts
@@ -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 {
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 {
: 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(),
+ });
}
}
diff --git a/classes/database/media.ts b/classes/database/media.ts
index 0cef287c..9bce13f9 100644
--- a/classes/database/media.ts
+++ b/classes/database/media.ts
@@ -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 {
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 {
): Promise {
const mimeType = await mimeLookup(uri);
- const content: ContentFormat = {
+ const content: z.infer = {
[mimeType]: {
content: uri.toString(),
remote: true,
@@ -272,7 +278,9 @@ export class Media extends BaseInterface {
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 {
public async updateFromUrl(uri: URL): Promise {
const mimeType = await mimeLookup(uri);
- const content: ContentFormat = {
+ const content: z.infer = {
[mimeType]: {
content: uri.toString(),
remote: true,
@@ -333,12 +341,19 @@ export class Media extends BaseInterface {
const content = await Media.fileToContentFormat(file, url);
await this.update({
- thumbnail: content,
+ thumbnail: content as z.infer,
});
}
public async updateMetadata(
- metadata: Partial>,
+ metadata: Partial<
+ Omit<
+ z.infer[keyof z.infer<
+ typeof ContentFormatSchema
+ >],
+ "content"
+ >
+ >,
): Promise {
const content = this.data.content;
@@ -447,7 +462,7 @@ export class Media extends BaseInterface {
options?: Partial<{
description: string;
}>,
- ): Promise {
+ ): Promise> {
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 {
};
}
- public toVersia(): ContentFormat {
- return this.data.content;
+ public toVersia(): VersiaEntities.ContentFormat {
+ return new VersiaEntities.ContentFormat(this.data.content);
}
- public static fromVersia(contentFormat: ContentFormat): Promise {
+ public static fromVersia(
+ contentFormat: VersiaEntities.ContentFormat,
+ ): Promise {
return Media.insert({
id: randomUUIDv7(),
- content: contentFormat,
- originalContent: contentFormat,
+ content: contentFormat.data,
+ originalContent: contentFormat.data,
});
}
}
diff --git a/classes/database/note.ts b/classes/database/note.ts
index 66adc099..402df3f0 100644
--- a/classes/database/note.ts
+++ b/classes/database/note.ts
@@ -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 {
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 {
);
}
- 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 {
- 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;
- 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 {
- 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;
- isSensitive?: boolean;
- spoilerText?: string;
- emojis?: Emoji[];
- uri?: string;
- mentions?: User[];
- mediaAttachments?: Media[];
- replyId?: string;
- quoteId?: string;
- application?: Application;
- }): Promise {
- 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 {
*/
public static async resolve(uri: URL): Promise {
// 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 {
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 {
- 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;
/**
- * 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;
+
+ public static async fromVersia(
+ versiaNote: VersiaEntities.Note | URL,
): Promise {
- 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[],
- ),
- 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 {
@@ -872,31 +688,31 @@ export class Note extends BaseInterface {
);
}
- 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 {
},
},
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 {
},
// TODO: Add polls and reactions
},
- };
+ });
}
/**
diff --git a/classes/database/reaction.ts b/classes/database/reaction.ts
index 63d4fc2d..491cc3b7 100644
--- a/classes/database/reaction.ts
+++ b/classes/database/reaction.ts
@@ -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 & {
@@ -165,7 +165,7 @@ export class Reaction extends BaseInterface {
);
}
- public isLocal(): boolean {
+ public get local(): boolean {
return this.data.author.instanceId === null;
}
@@ -173,24 +173,23 @@ export class Reaction extends BaseInterface {
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 {
},
}
: undefined,
- };
+ });
}
public static async fromVersia(
- reactionToConvert: ReactionExtension,
+ reactionToConvert: VersiaEntities.Reaction,
author: User,
note: Note,
): Promise {
- 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 {
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,
});
}
}
diff --git a/classes/database/user.ts b/classes/database/user.ts
index 172d49cc..d57f6236 100644
--- a/classes/database/user.ts
+++ b/classes/database/user.ts
@@ -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 {
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 {
);
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 {
followee: User,
relationship: Relationship,
): Promise {
- 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 {
});
}
- 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 {
- if (!follower.isRemote()) {
+ public async acceptFollowRequest(follower: User): Promise {
+ 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 {
- if (!follower.isRemote()) {
+ public async rejectFollowRequest(follower: User): Promise {
+ 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 {
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 {
* @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 {
+ public async like(note: Note, uri?: URL): Promise {
// 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 {
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 {
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 {
);
}
- public async updateFromRemote(): Promise {
- 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 {
- 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 {
- const requester = await User.getFederationRequester();
- const output = await requester.get>(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 {
);
}
+ /**
+ * 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;
+
+ /**
+ * 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;
+
+ public static async fromVersia(
+ versiaUser: VersiaEntities.User | URL,
): Promise {
- 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,
- };
+ 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 {
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 {
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 {
};
}
- 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 {
+ public static async register(
+ username: string,
+ options?: Partial<{
+ email: string;
+ password: string;
+ avatar: Media;
+ isAdmin: boolean;
+ }>,
+ ): Promise {
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,
- })
- .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,
+ });
// 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 {
}
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 {
// 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 {
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 {
- const signatureConstructor = await SignatureConstructor.fromStringKey(
- this.data.privateKey ?? "",
- this.getUri(),
- );
-
- return new FederationRequester(signatureConstructor);
+ public get federationRequester(): Promise {
+ 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 {
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 {
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 {
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 {
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 {
};
}
- 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 {
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 {
),
},
},
- };
+ });
}
public toMention(): z.infer {
return {
- url: this.getUri().toString(),
+ url: this.uri.href,
username: this.data.username,
acct: this.getAcct(),
id: this.id,
diff --git a/classes/functions/status.ts b/classes/functions/status.ts
index 8ed9380a..68827dc5 100644
--- a/classes/functions/status.ts
+++ b/classes/functions/status.ts
@@ -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 => {
+export const parseTextMentions = async (text: string): Promise => {
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 =>
`${displayText}`;
- 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 => {
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) => `${line}
`)
.join("\n");
diff --git a/classes/inbox/processor.ts b/classes/inbox/processor.ts
index 0c5f93d7..3baaf383 100644
--- a/classes/inbox/processor.ts
+++ b/classes/inbox/processor.ts
@@ -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} - Whether the signature is valid.
*/
- private async isSignatureValid(): Promise {
+ private isSignatureValid(): Promise {
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({
- note: (): Promise => this.processNote(),
- follow: (): Promise => this.processFollowRequest(),
- followAccept: (): Promise => this.processFollowAccept(),
- followReject: (): Promise => this.processFollowReject(),
- "pub.versia:likes/Like": (): Promise =>
- this.processLikeRequest(),
- delete: (): Promise => this.processDelete(),
- user: (): Promise => 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}
- */
- private async processNote(): Promise {
- 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}
*/
- private async processFollowRequest(): Promise {
- 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 {
+ 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}
*/
- private async processFollowAccept(): Promise {
- 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 {
+ 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}
*/
- private async processFollowReject(): Promise {
- 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 {
+ 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}
- */
- public async processDelete(): Promise {
- // 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 {
+ 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}
*/
- private async processLikeRequest(): Promise {
- 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 {
+ 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}
- */
- private async processUserRequest(): Promise {
- 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);
}
/**
diff --git a/classes/queues/delivery.ts b/classes/queues/delivery.ts
index 0ddc9f8a..d8605649 100644
--- a/classes/queues/delivery.ts
+++ b/classes/queues/delivery.ts
@@ -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}]`,
diff --git a/classes/queues/inbox.ts b/classes/queues/inbox.ts
index fb378507..31be17ae 100644
--- a/classes/queues/inbox.ts
+++ b/classes/queues/inbox.ts
@@ -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 =>
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 =>
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 =>
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 =>
);
}
+ 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 =>
);
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,
diff --git a/classes/search/search-manager.ts b/classes/search/search-manager.ts
index 25689381..db97f006 100644
--- a/classes/search/search-manager.ts
+++ b/classes/search/search-manager.ts
@@ -155,7 +155,7 @@ export class SonicSearchManager {
private static getNthDatabaseAccountBatch(
n: number,
batchSize = 1000,
- ): Promise[]> {
+ ): Promise[]> {
return db.query.Users.findMany({
offset: n * batchSize,
limit: batchSize,
diff --git a/cli/user/create.ts b/cli/user/create.ts
index 4d840b25..6a5fd696 100644
--- a/cli/user/create.ts
+++ b/cli/user/create.ts
@@ -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) {
diff --git a/cli/user/refetch.ts b/cli/user/refetch.ts
index c6ff0f9d..1eef6b61 100644
--- a/cli/user/refetch.ts
+++ b/cli/user/refetch.ts
@@ -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)}`,
diff --git a/drizzle/schema.ts b/drizzle/schema.ts
index 0b95d7d4..350fc851 100644
--- a/drizzle/schema.ts
+++ b/drizzle/schema.ts
@@ -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(),
- originalContent: jsonb("original_content").$type(),
- thumbnail: jsonb("thumbnail").$type(),
+ content: jsonb("content")
+ .notNull()
+ .$type>(),
+ originalContent:
+ jsonb("original_content").$type>(),
+ thumbnail:
+ jsonb("thumbnail").$type>(),
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(),
+ logo: jsonb("logo").$type(),
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(),
- extensions: jsonb("extensions").$type(),
+ 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;
+ value: z.infer;
}[]
>(),
endpoints: jsonb("endpoints").$type | null>(),
- source: jsonb("source").notNull().$type>(),
+ source: jsonb("source").$type>(),
avatarId: uuid("avatarId").references(() => Medias.id, {
onDelete: "set null",
onUpdate: "cascade",
diff --git a/package.json b/package.json
index 4d5d1554..fe267fe9 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/packages/sdk/README.md b/packages/sdk/README.md
new file mode 100644
index 00000000..2b863508
--- /dev/null
+++ b/packages/sdk/README.md
@@ -0,0 +1,220 @@
+
+
+
+
+@versia/sdk
+
+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.
diff --git a/packages/sdk/crypto.ts b/packages/sdk/crypto.ts
new file mode 100644
index 00000000..541cc4fd
--- /dev/null
+++ b/packages/sdk/crypto.ts
@@ -0,0 +1,94 @@
+const stringToBase64Hash = async (str: string): Promise => {
+ 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 => {
+ 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 => {
+ 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),
+ );
+};
diff --git a/packages/sdk/entities/collection.ts b/packages/sdk/entities/collection.ts
new file mode 100644
index 00000000..facc856c
--- /dev/null
+++ b/packages/sdk/entities/collection.ts
@@ -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) {
+ super(data);
+ }
+
+ public static fromJSON(json: JSONObject): Promise {
+ return CollectionSchema.parseAsync(json).then((u) => new Collection(u));
+ }
+}
+
+export class URICollection extends Entity {
+ public constructor(public data: z.infer) {
+ super(data);
+ }
+
+ public static fromJSON(json: JSONObject): Promise {
+ return URICollectionSchema.parseAsync(json).then(
+ (u) => new URICollection(u),
+ );
+ }
+}
diff --git a/packages/sdk/entities/contentformat.ts b/packages/sdk/entities/contentformat.ts
new file mode 100644
index 00000000..fcbfd836
--- /dev/null
+++ b/packages/sdk/entities/contentformat.ts
@@ -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 {
+ return ContentFormatSchema.parseAsync(data).then(
+ (d) => new ContentFormat(d),
+ );
+ }
+
+ public constructor(public data: z.infer) {}
+}
+
+export class TextContentFormat extends ContentFormat {
+ public static fromJSON(data: JSONObject): Promise {
+ return TextContentFormatSchema.parseAsync(data).then(
+ (d) => new TextContentFormat(d),
+ );
+ }
+
+ public constructor(public data: z.infer) {
+ super(data);
+ }
+}
+
+export class NonTextContentFormat extends ContentFormat {
+ public static fromJSON(data: JSONObject): Promise {
+ return NonTextContentFormatSchema.parseAsync(data).then(
+ (d) => new NonTextContentFormat(d),
+ );
+ }
+
+ public constructor(
+ public data: z.infer,
+ ) {
+ super(data);
+ }
+}
+
+export class ImageContentFormat extends ContentFormat {
+ public static fromJSON(data: JSONObject): Promise {
+ return ImageContentFormatSchema.parseAsync(data).then(
+ (d) => new ImageContentFormat(d),
+ );
+ }
+
+ public constructor(public data: z.infer) {
+ super(data);
+ }
+}
+
+export class VideoContentFormat extends ContentFormat {
+ public static fromJSON(data: JSONObject): Promise {
+ return VideoContentFormatSchema.parseAsync(data).then(
+ (d) => new VideoContentFormat(d),
+ );
+ }
+
+ public constructor(public data: z.infer) {
+ super(data);
+ }
+}
+
+export class AudioContentFormat extends ContentFormat {
+ public static fromJSON(data: JSONObject): Promise {
+ return AudioContentFormatSchema.parseAsync(data).then(
+ (d) => new AudioContentFormat(d),
+ );
+ }
+
+ public constructor(public data: z.infer) {
+ super(data);
+ }
+}
diff --git a/packages/sdk/entities/delete.ts b/packages/sdk/entities/delete.ts
new file mode 100644
index 00000000..85b33db3
--- /dev/null
+++ b/packages/sdk/entities/delete.ts
@@ -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) {
+ super(data);
+ }
+
+ public static fromJSON(json: JSONObject): Promise {
+ return DeleteSchema.parseAsync(json).then((u) => new Delete(u));
+ }
+}
diff --git a/packages/sdk/entities/entity.ts b/packages/sdk/entities/entity.ts
new file mode 100644
index 00000000..cd495399
--- /dev/null
+++ b/packages/sdk/entities/entity.ts
@@ -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 {
+ return EntitySchema.parseAsync(json).then((u) => new Entity(u));
+ }
+
+ public toJSON(): JSONObject {
+ return this.data;
+ }
+}
diff --git a/packages/sdk/entities/extensions/likes.ts b/packages/sdk/entities/extensions/likes.ts
new file mode 100644
index 00000000..f8c606b1
--- /dev/null
+++ b/packages/sdk/entities/extensions/likes.ts
@@ -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) {
+ super(data);
+ }
+
+ public static fromJSON(json: JSONObject): Promise {
+ 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) {
+ super(data);
+ }
+
+ public static fromJSON(json: JSONObject): Promise {
+ return DislikeSchema.parseAsync(json).then((u) => new Dislike(u));
+ }
+}
diff --git a/packages/sdk/entities/extensions/polls.ts b/packages/sdk/entities/extensions/polls.ts
new file mode 100644
index 00000000..43264f03
--- /dev/null
+++ b/packages/sdk/entities/extensions/polls.ts
@@ -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) {
+ super(data);
+ }
+
+ public static fromJSON(json: JSONObject): Promise {
+ return VoteSchema.parseAsync(json).then((u) => new Vote(u));
+ }
+}
diff --git a/packages/sdk/entities/extensions/reactions.ts b/packages/sdk/entities/extensions/reactions.ts
new file mode 100644
index 00000000..0d7d432f
--- /dev/null
+++ b/packages/sdk/entities/extensions/reactions.ts
@@ -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) {
+ super(data);
+ }
+
+ public static fromJSON(json: JSONObject): Promise {
+ return ReactionSchema.parseAsync(json).then((u) => new Reaction(u));
+ }
+}
diff --git a/packages/sdk/entities/extensions/reports.ts b/packages/sdk/entities/extensions/reports.ts
new file mode 100644
index 00000000..a3e709e4
--- /dev/null
+++ b/packages/sdk/entities/extensions/reports.ts
@@ -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) {
+ super(data);
+ }
+
+ public static fromJSON(json: JSONObject): Promise {
+ return ReportSchema.parseAsync(json).then((u) => new Report(u));
+ }
+}
diff --git a/packages/sdk/entities/extensions/share.ts b/packages/sdk/entities/extensions/share.ts
new file mode 100644
index 00000000..d49cc9fc
--- /dev/null
+++ b/packages/sdk/entities/extensions/share.ts
@@ -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) {
+ super(data);
+ }
+
+ public static fromJSON(json: JSONObject): Promise {
+ return ShareSchema.parseAsync(json).then((u) => new Share(u));
+ }
+}
diff --git a/packages/sdk/entities/follow.ts b/packages/sdk/entities/follow.ts
new file mode 100644
index 00000000..5fe86d76
--- /dev/null
+++ b/packages/sdk/entities/follow.ts
@@ -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) {
+ super(data);
+ }
+
+ public static fromJSON(json: JSONObject): Promise {
+ return FollowSchema.parseAsync(json).then((u) => new Follow(u));
+ }
+}
+
+export class FollowAccept extends Entity {
+ public static name = "FollowAccept";
+
+ public constructor(public data: z.infer) {
+ super(data);
+ }
+
+ public static fromJSON(json: JSONObject): Promise {
+ return FollowAcceptSchema.parseAsync(json).then(
+ (u) => new FollowAccept(u),
+ );
+ }
+}
+
+export class FollowReject extends Entity {
+ public static name = "FollowReject";
+
+ public constructor(public data: z.infer) {
+ super(data);
+ }
+
+ public static fromJSON(json: JSONObject): Promise {
+ return FollowRejectSchema.parseAsync(json).then(
+ (u) => new FollowReject(u),
+ );
+ }
+}
+
+export class Unfollow extends Entity {
+ public static name = "Unfollow";
+
+ public constructor(public data: z.infer) {
+ super(data);
+ }
+
+ public static fromJSON(json: JSONObject): Promise {
+ return UnfollowSchema.parseAsync(json).then((u) => new Unfollow(u));
+ }
+}
diff --git a/packages/sdk/entities/index.ts b/packages/sdk/entities/index.ts
new file mode 100644
index 00000000..a994334a
--- /dev/null
+++ b/packages/sdk/entities/index.ts
@@ -0,0 +1,21 @@
+// biome-ignore lint/performance/noBarrelFile:
+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";
diff --git a/packages/sdk/entities/instancemetadata.ts b/packages/sdk/entities/instancemetadata.ts
new file mode 100644
index 00000000..6517daff
--- /dev/null
+++ b/packages/sdk/entities/instancemetadata.ts
@@ -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) {
+ 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 {
+ return InstanceMetadataSchema.parseAsync(json).then(
+ (u) => new InstanceMetadata(u),
+ );
+ }
+}
diff --git a/packages/sdk/entities/note.ts b/packages/sdk/entities/note.ts
new file mode 100644
index 00000000..df64d15b
--- /dev/null
+++ b/packages/sdk/entities/note.ts
@@ -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) {
+ super(data);
+ }
+
+ public static fromJSON(json: JSONObject): Promise {
+ 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;
+ }
+}
diff --git a/packages/sdk/entities/user.ts b/packages/sdk/entities/user.ts
new file mode 100644
index 00000000..b3d6f0d7
--- /dev/null
+++ b/packages/sdk/entities/user.ts
@@ -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) {
+ super(data);
+ }
+
+ public static fromJSON(json: JSONObject): Promise {
+ 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;
+ }
+}
diff --git a/packages/sdk/http.ts b/packages/sdk/http.ts
new file mode 100644
index 00000000..86e8a43b
--- /dev/null
+++ b/packages/sdk/http.ts
@@ -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(
+ url: URL,
+ expectedType: T,
+ ): Promise> {
+ 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;
+ }
+
+ public async postEntity(url: URL, entity: Entity): Promise {
+ 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(
+ url: URL,
+ expectedType: T,
+ options?: {
+ limit?: number;
+ },
+ ): Promise[]> {
+ const entities: InstanceType[] = [];
+ 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,
+ );
+ }
+ }
+
+ 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 {
+ 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} The resolved User or null if not found
+ */
+ public static async resolveWebFinger(
+ username: string,
+ hostname: string,
+ contentType = "application/json",
+ serverUrl = `https://${hostname}`,
+ ): Promise {
+ 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);
+ }
+}
diff --git a/packages/sdk/inbox-processor.ts b/packages/sdk/inbox-processor.ts
new file mode 100644
index 00000000..6c6e000a
--- /dev/null
+++ b/packages/sdk/inbox-processor.ts
@@ -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
+>;
+type MaybePromise = T | Promise;
+
+/**
+ * @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(
+ entity: T,
+ handler: (entity: InstanceType) => MaybePromise,
+ ): EntitySorter {
+ this.handlers.set(
+ entity,
+ handler as (entity: Entity) => MaybePromise,
+ );
+ return this;
+ }
+
+ /**
+ * Sorts the entity based on the provided JSON data.
+ * @param {() => MaybePromise} 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,
+ ): Promise {
+ 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?.();
+ }
+ }
+}
diff --git a/packages/sdk/package.json b/packages/sdk/package.json
new file mode 100644
index 00000000..38ecfb2d
--- /dev/null
+++ b/packages/sdk/package.json
@@ -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"
+}
diff --git a/packages/sdk/regex.ts b/packages/sdk/regex.ts
new file mode 100644
index 00000000..602eccfc
--- /dev/null
+++ b/packages/sdk/regex.ts
@@ -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)$/;
diff --git a/packages/sdk/schemas/collection.ts b/packages/sdk/schemas/collection.ts
new file mode 100644
index 00000000..fa46c2ea
--- /dev/null
+++ b/packages/sdk/schemas/collection.ts
@@ -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),
+});
diff --git a/packages/sdk/schemas/common.ts b/packages/sdk/schemas/common.ts
new file mode 100644
index 00000000..468059ef
--- /dev/null
+++ b/packages/sdk/schemas/common.ts
@@ -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));
diff --git a/packages/sdk/schemas/contentformat.ts b/packages/sdk/schemas/contentformat.ts
new file mode 100644
index 00000000..5605a9fc
--- /dev/null
+++ b/packages/sdk/schemas/contentformat.ts
@@ -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,
+ }),
+);
diff --git a/packages/sdk/schemas/delete.ts b/packages/sdk/schemas/delete.ts
new file mode 100644
index 00000000..9cd7d9a8
--- /dev/null
+++ b/packages/sdk/schemas/delete.ts
@@ -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,
+});
diff --git a/packages/sdk/schemas/entity.ts b/packages/sdk/schemas/entity.ts
new file mode 100644
index 00000000..d5d1aa2a
--- /dev/null
+++ b/packages/sdk/schemas/entity.ts
@@ -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(),
+});
diff --git a/packages/sdk/schemas/extensions/emojis.ts b/packages/sdk/schemas/extensions/emojis.ts
new file mode 100644
index 00000000..d075f9b4
--- /dev/null
+++ b/packages/sdk/schemas/extensions/emojis.ts
@@ -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,
+ }),
+ ),
+});
diff --git a/packages/sdk/schemas/extensions/groups.ts b/packages/sdk/schemas/extensions/groups.ts
new file mode 100644
index 00000000..8c246056
--- /dev/null
+++ b/packages/sdk/schemas/extensions/groups.ts
@@ -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,
+});
diff --git a/packages/sdk/schemas/extensions/likes.ts b/packages/sdk/schemas/extensions/likes.ts
new file mode 100644
index 00000000..75208fb7
--- /dev/null
+++ b/packages/sdk/schemas/extensions/likes.ts
@@ -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,
+});
diff --git a/packages/sdk/schemas/extensions/migration.ts b/packages/sdk/schemas/extensions/migration.ts
new file mode 100644
index 00000000..da215ce3
--- /dev/null
+++ b/packages/sdk/schemas/extensions/migration.ts
@@ -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(),
+});
diff --git a/packages/sdk/schemas/extensions/polls.ts b/packages/sdk/schemas/extensions/polls.ts
new file mode 100644
index 00000000..ba553a36
--- /dev/null
+++ b/packages/sdk/schemas/extensions/polls.ts
@@ -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(),
+});
diff --git a/packages/sdk/schemas/extensions/reactions.ts b/packages/sdk/schemas/extensions/reactions.ts
new file mode 100644
index 00000000..99b7162d
--- /dev/null
+++ b/packages/sdk/schemas/extensions/reactions.ts
@@ -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),
+});
diff --git a/packages/sdk/schemas/extensions/reports.ts b/packages/sdk/schemas/extensions/reports.ts
new file mode 100644
index 00000000..c49bc8db
--- /dev/null
+++ b/packages/sdk/schemas/extensions/reports.ts
@@ -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(),
+});
diff --git a/packages/sdk/schemas/extensions/share.ts b/packages/sdk/schemas/extensions/share.ts
new file mode 100644
index 00000000..20968a08
--- /dev/null
+++ b/packages/sdk/schemas/extensions/share.ts
@@ -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,
+});
diff --git a/packages/sdk/schemas/extensions/vanity.ts b/packages/sdk/schemas/extensions/vanity.ts
new file mode 100644
index 00000000..6d47136a
--- /dev/null
+++ b/packages/sdk/schemas/extensions/vanity.ts
@@ -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(),
+});
diff --git a/packages/sdk/schemas/follow.ts b/packages/sdk/schemas/follow.ts
new file mode 100644
index 00000000..62c89c32
--- /dev/null
+++ b/packages/sdk/schemas/follow.ts
@@ -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,
+});
diff --git a/packages/sdk/schemas/index.ts b/packages/sdk/schemas/index.ts
new file mode 100644
index 00000000..a7faa8dc
--- /dev/null
+++ b/packages/sdk/schemas/index.ts
@@ -0,0 +1,27 @@
+// biome-ignore lint/performance/noBarrelFile:
+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";
diff --git a/packages/sdk/schemas/instance.ts b/packages/sdk/schemas/instance.ts
new file mode 100644
index 00000000..e62648b5
--- /dev/null
+++ b/packages/sdk/schemas/instance.ts
@@ -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(),
+});
diff --git a/packages/sdk/schemas/note.ts b/packages/sdk/schemas/note.ts
new file mode 100644
index 00000000..9d58beca
--- /dev/null
+++ b/packages/sdk/schemas/note.ts
@@ -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(),
+});
diff --git a/packages/sdk/schemas/user.ts b/packages/sdk/schemas/user.ts
new file mode 100644
index 00000000..aeaf1256
--- /dev/null
+++ b/packages/sdk/schemas/user.ts
@@ -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(),
+});
diff --git a/packages/sdk/schemas/webfinger.ts b/packages/sdk/schemas/webfinger.ts
new file mode 100644
index 00000000..524d2f00
--- /dev/null
+++ b/packages/sdk/schemas/webfinger.ts
@@ -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(),
+});
diff --git a/packages/sdk/types.ts b/packages/sdk/types.ts
new file mode 100644
index 00000000..217deada
--- /dev/null
+++ b/packages/sdk/types.ts
@@ -0,0 +1,11 @@
+type JSONValue =
+ | string
+ | number
+ | boolean
+ | null
+ | JSONValue[]
+ | { [key: string]: JSONValue };
+
+export interface JSONObject {
+ [k: string]: JSONValue;
+}
diff --git a/plugins/openid/routes/oauth/callback.ts b/plugins/openid/routes/oauth/callback.ts
index 2f7c6bff..23207ca0 100644
--- a/plugins/openid/routes/oauth/callback.ts
+++ b/plugins/openid/routes/oauth/callback.ts
@@ -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
diff --git a/tests/utils.ts b/tests/utils.ts
index 508d92d1..3058d501 100644
--- a/tests/utils.ts
+++ b/tests/utils.ts
@@ -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,
});
diff --git a/types/api.ts b/types/api.ts
index e6cb1a46..444d4b4e 100644
--- a/types/api.ts
+++ b/types/api.ts
@@ -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;
diff --git a/utils/content_types.ts b/utils/content_types.ts
index fb95caa3..f1f0daa1 100644
--- a/utils/content_types.ts
+++ b/utils/content_types.ts
@@ -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 | null,
): {
content: string;
format: string;
@@ -32,7 +33,7 @@ export const getBestContentType = (
export const urlToContentFormat = (
url: URL,
contentType?: string,
-): ContentFormat | null => {
+): z.infer | null => {
if (url.href.startsWith("https://api.dicebear.com/")) {
return {
"image/svg+xml": {