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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -83,7 +83,7 @@ export default apiRoute((app) =>
throw new Error("Failed to reblog"); 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); await note.author.notify("reblog", user, newReblog);
} }

View file

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

View file

@ -1,4 +1,5 @@
import { apiRoute, auth, handleZodError, jsonOrForm } from "@/api"; import { apiRoute, auth, handleZodError, jsonOrForm } from "@/api";
import { sanitizedHtmlStrip } from "@/sanitization";
import { import {
Attachment as AttachmentSchema, Attachment as AttachmentSchema,
PollOption, PollOption,
@ -7,12 +8,15 @@ import {
zBoolean, zBoolean,
} from "@versia/client/schemas"; } from "@versia/client/schemas";
import { RolePermission } 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 { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod"; import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod"; import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { contentToHtml, parseTextMentions } from "~/classes/functions/status";
import { config } from "~/config.ts"; import { config } from "~/config.ts";
import * as VersiaEntities from "~/packages/sdk/entities";
const schema = z const schema = z
.object({ .object({
@ -174,27 +178,59 @@ export default apiRoute((app) =>
); );
} }
const newNote = await Note.fromData({ const sanitizedSpoilerText = spoiler_text
author: user, ? await sanitizedHtmlStrip(spoiler_text)
content: { : undefined;
const content = status
? new VersiaEntities.TextContentFormat({
[content_type]: { [content_type]: {
content: status ?? "", content: status,
remote: false, 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, visibility,
isSensitive: sensitive ?? false, content: content
spoilerText: spoiler_text ?? "", ? await contentToHtml(content, parsedMentions)
mediaAttachments: foundAttachments, : undefined,
sensitive,
spoilerText: sanitizedSpoilerText,
replyId: in_reply_to_id ?? undefined, replyId: in_reply_to_id ?? undefined,
quoteId: quote_id ?? undefined, quotingId: quote_id ?? undefined,
application: application ?? 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) { if (!local_only) {
await newNote.federateToUsers(); 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); return context.json(await newNote.toApi(user), 200);
}, },
), ),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -20,8 +20,8 @@
"@scalar/hono-api-reference": "^0.8.0", "@scalar/hono-api-reference": "^0.8.0",
"@sentry/bun": "^9.11.0", "@sentry/bun": "^9.11.0",
"@versia/client": "workspace:*", "@versia/client": "workspace:*",
"@versia/federation": "^0.2.1",
"@versia/kit": "workspace:*", "@versia/kit": "workspace:*",
"@versia/sdk": "workspace:*",
"altcha-lib": "^1.2.0", "altcha-lib": "^1.2.0",
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
"bullmq": "^5.47.2", "bullmq": "^5.47.2",
@ -105,6 +105,10 @@
"zod-validation-error": "^3.3.0", "zod-validation-error": "^3.3.0",
}, },
}, },
"packages/sdk": {
"name": "@versia/sdk",
"version": "0.0.1",
},
}, },
"trustedDependencies": [ "trustedDependencies": [
"sharp", "sharp",
@ -568,10 +572,10 @@
"@versia/client": ["@versia/client@workspace:packages/client"], "@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/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=="], "@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=="], "@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=="], "@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-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=="], "@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=="], "@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-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=="], "cheerio/dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,7 +3,8 @@ import { Queue } from "bullmq";
import { Worker } from "bullmq"; import { Worker } from "bullmq";
import chalk from "chalk"; import chalk from "chalk";
import { config } from "~/config.ts"; 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"; import { connection } from "~/utils/redis.ts";
export enum DeliveryJobType { export enum DeliveryJobType {
@ -11,7 +12,7 @@ export enum DeliveryJobType {
} }
export type DeliveryJobData = { export type DeliveryJobData = {
entity: KnownEntity; entity: JSONObject;
recipientId: string; recipientId: string;
senderId: string; senderId: string;
}; };
@ -39,7 +40,9 @@ export const getDeliveryWorker = (): Worker<
if (!sender) { if (!sender) {
throw new Error( 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) { if (!recipient) {
throw new Error( throw new Error(
`Could not resolve recipient ID ${chalk.gray(recipientId)}`, `Could not resolve recipient ID ${chalk.gray(
recipientId,
)}`,
); );
} }
await job.log( 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( await job.log(
`✔ Finished federating entity [${entity.id}]`, `✔ Finished federating entity [${entity.id}]`,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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