mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 00:18:19 +01:00
refactor(federation): ♻️ Rewrite federation SDK
This commit is contained in:
parent
ad1dc13a51
commit
d638610361
|
|
@ -49,7 +49,6 @@ export default apiRoute((app) =>
|
|||
),
|
||||
async (context) => {
|
||||
const { acct } = context.req.valid("query");
|
||||
const { user } = context.get("auth");
|
||||
|
||||
// Check if acct is matching format username@domain.com or @username@domain.com
|
||||
const { username, domain } = parseUserAddress(acct);
|
||||
|
|
@ -93,9 +92,7 @@ export default apiRoute((app) =>
|
|||
}
|
||||
|
||||
// Fetch from remote instance
|
||||
const manager = await (user ?? User).getFederationRequester();
|
||||
|
||||
const uri = await User.webFinger(manager, username, domain);
|
||||
const uri = await User.webFinger(username, domain);
|
||||
|
||||
if (!uri) {
|
||||
throw ApiError.accountNotFound();
|
||||
|
|
|
|||
|
|
@ -91,9 +91,7 @@ export default apiRoute((app) =>
|
|||
const accounts: User[] = [];
|
||||
|
||||
if (resolve && domain) {
|
||||
const manager = await (user ?? User).getFederationRequester();
|
||||
|
||||
const uri = await User.webFinger(manager, username, domain);
|
||||
const uri = await User.webFinger(username, domain);
|
||||
|
||||
if (uri) {
|
||||
const resolvedUser = await User.resolve(uri);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Account as AccountSchema, zBoolean } from "@versia/client/schemas";
|
|||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Emoji, Media, User } from "@versia/kit/db";
|
||||
import { Users } from "@versia/kit/tables";
|
||||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
|
|
@ -186,12 +187,14 @@ export default apiRoute((app) =>
|
|||
|
||||
if (note && self.source) {
|
||||
self.source.note = note;
|
||||
self.note = await contentToHtml({
|
||||
"text/markdown": {
|
||||
content: note,
|
||||
remote: false,
|
||||
},
|
||||
});
|
||||
self.note = await contentToHtml(
|
||||
new VersiaEntities.TextContentFormat({
|
||||
"text/markdown": {
|
||||
content: note,
|
||||
remote: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (source?.privacy) {
|
||||
|
|
@ -275,23 +278,23 @@ export default apiRoute((app) =>
|
|||
for (const field of fields_attributes) {
|
||||
// Can be Markdown or plaintext, also has emojis
|
||||
const parsedName = await contentToHtml(
|
||||
{
|
||||
new VersiaEntities.TextContentFormat({
|
||||
"text/markdown": {
|
||||
content: field.name,
|
||||
remote: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
const parsedValue = await contentToHtml(
|
||||
{
|
||||
new VersiaEntities.TextContentFormat({
|
||||
"text/markdown": {
|
||||
content: field.value,
|
||||
remote: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Media } from "@versia/kit/db";
|
||||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
|
|
@ -228,12 +229,12 @@ export default apiRoute((app) => {
|
|||
const newNote = await note.updateFromData({
|
||||
author: user,
|
||||
content: statusText
|
||||
? {
|
||||
? new VersiaEntities.TextContentFormat({
|
||||
[content_type]: {
|
||||
content: statusText,
|
||||
remote: false,
|
||||
},
|
||||
}
|
||||
})
|
||||
: undefined,
|
||||
isSensitive: sensitive,
|
||||
spoilerText: spoiler_text,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
} from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Media, Note } from "@versia/kit/db";
|
||||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
|
|
@ -176,12 +177,12 @@ export default apiRoute((app) =>
|
|||
|
||||
const newNote = await Note.fromData({
|
||||
author: user,
|
||||
content: {
|
||||
content: new VersiaEntities.TextContentFormat({
|
||||
[content_type]: {
|
||||
content: status ?? "",
|
||||
remote: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
visibility,
|
||||
isSensitive: sensitive ?? false,
|
||||
spoilerText: spoiler_text ?? "",
|
||||
|
|
|
|||
|
|
@ -198,15 +198,7 @@ export default apiRoute((app) =>
|
|||
}
|
||||
|
||||
if (resolve && domain) {
|
||||
const manager = await (
|
||||
user ?? User
|
||||
).getFederationRequester();
|
||||
|
||||
const uri = await User.webFinger(
|
||||
manager,
|
||||
username,
|
||||
domain,
|
||||
);
|
||||
const uri = await User.webFinger(username, domain);
|
||||
|
||||
if (uri) {
|
||||
const newUser = await User.resolve(uri);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { apiRoute, handleZodError } from "@/api";
|
||||
import type { Entity } from "@versia/federation/types";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
|
|
@ -33,7 +32,7 @@ export default apiRoute((app) =>
|
|||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const body: Entity = await context.req.valid("json");
|
||||
const body = await context.req.valid("json");
|
||||
|
||||
await inboxQueue.add(InboxJobType.ProcessEntity, {
|
||||
data: body,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { apiRoute, handleZodError } from "@/api";
|
||||
import { Status as StatusSchema } from "@versia/client/schemas";
|
||||
import { LikeExtension as LikeSchema } from "@versia/federation/schemas";
|
||||
import { Like, User } from "@versia/kit/db";
|
||||
import { Likes } from "@versia/kit/tables";
|
||||
import { LikeSchema } from "@versia/sdk/schemas";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { apiRoute, handleZodError } from "@/api";
|
||||
import { Status as StatusSchema } from "@versia/client/schemas";
|
||||
import { Note as NoteSchema } from "@versia/federation/schemas";
|
||||
import { Note } from "@versia/kit/db";
|
||||
import { Notes } from "@versia/kit/tables";
|
||||
import { NoteSchema } from "@versia/sdk/schemas";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { apiRoute, handleZodError } from "@/api";
|
||||
import { Status as StatusSchema } from "@versia/client/schemas";
|
||||
import { URICollection as URICollectionSchema } from "@versia/federation/schemas";
|
||||
import type { URICollection } from "@versia/federation/types";
|
||||
import { Note, db } from "@versia/kit/db";
|
||||
import { Notes } from "@versia/kit/tables";
|
||||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import { URICollectionSchema } from "@versia/sdk/schemas";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
|
|
@ -88,39 +88,39 @@ export default apiRoute((app) =>
|
|||
),
|
||||
);
|
||||
|
||||
const uriCollection = {
|
||||
author: note.author.getUri().href,
|
||||
const uriCollection = new VersiaEntities.URICollection({
|
||||
author: note.author.getUri(),
|
||||
first: new URL(
|
||||
`/notes/${note.id}/quotes?offset=0`,
|
||||
config.http.base_url,
|
||||
).href,
|
||||
),
|
||||
last:
|
||||
replyCount > limit
|
||||
? new URL(
|
||||
`/notes/${note.id}/quotes?offset=${replyCount - limit}`,
|
||||
config.http.base_url,
|
||||
).href
|
||||
)
|
||||
: new URL(
|
||||
`/notes/${note.id}/quotes`,
|
||||
config.http.base_url,
|
||||
).href,
|
||||
),
|
||||
next:
|
||||
offset + limit < replyCount
|
||||
? new URL(
|
||||
`/notes/${note.id}/quotes?offset=${offset + limit}`,
|
||||
config.http.base_url,
|
||||
).href
|
||||
)
|
||||
: null,
|
||||
previous:
|
||||
offset - limit >= 0
|
||||
? new URL(
|
||||
`/notes/${note.id}/quotes?offset=${offset - limit}`,
|
||||
config.http.base_url,
|
||||
).href
|
||||
)
|
||||
: null,
|
||||
total: replyCount,
|
||||
items: replies.map((reply) => reply.getUri().href),
|
||||
} satisfies URICollection;
|
||||
items: replies.map((reply) => reply.getUri()),
|
||||
});
|
||||
|
||||
// If base_url uses https and request uses http, rewrite request to use https
|
||||
// This fixes reverse proxy errors
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { apiRoute, handleZodError } from "@/api";
|
||||
import { Status as StatusSchema } from "@versia/client/schemas";
|
||||
import { URICollection as URICollectionSchema } from "@versia/federation/schemas";
|
||||
import type { URICollection } from "@versia/federation/types";
|
||||
import { Note, db } from "@versia/kit/db";
|
||||
import { Notes } from "@versia/kit/tables";
|
||||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import { URICollectionSchema } from "@versia/sdk/schemas";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
|
|
@ -86,39 +86,39 @@ export default apiRoute((app) =>
|
|||
),
|
||||
);
|
||||
|
||||
const uriCollection = {
|
||||
author: note.author.getUri().href,
|
||||
const uriCollection = new VersiaEntities.URICollection({
|
||||
author: note.author.getUri(),
|
||||
first: new URL(
|
||||
`/notes/${note.id}/replies?offset=0`,
|
||||
config.http.base_url,
|
||||
).href,
|
||||
),
|
||||
last:
|
||||
replyCount > limit
|
||||
? new URL(
|
||||
`/notes/${note.id}/replies?offset=${replyCount - limit}`,
|
||||
config.http.base_url,
|
||||
).href
|
||||
)
|
||||
: new URL(
|
||||
`/notes/${note.id}/replies`,
|
||||
config.http.base_url,
|
||||
).href,
|
||||
),
|
||||
next:
|
||||
offset + limit < replyCount
|
||||
? new URL(
|
||||
`/notes/${note.id}/replies?offset=${offset + limit}`,
|
||||
config.http.base_url,
|
||||
).href
|
||||
)
|
||||
: null,
|
||||
previous:
|
||||
offset - limit >= 0
|
||||
? new URL(
|
||||
`/notes/${note.id}/replies?offset=${offset - limit}`,
|
||||
config.http.base_url,
|
||||
).href
|
||||
)
|
||||
: null,
|
||||
total: replyCount,
|
||||
items: replies.map((reply) => reply.getUri().href),
|
||||
} satisfies URICollection;
|
||||
items: replies.map((reply) => reply.getUri()),
|
||||
});
|
||||
|
||||
// If base_url uses https and request uses http, rewrite request to use https
|
||||
// This fixes reverse proxy errors
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { apiRoute, handleZodError } from "@/api";
|
||||
import type { Entity } from "@versia/federation/types";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { InboxJobType, inboxQueue } from "~/classes/queues/inbox";
|
||||
import type { JSONObject } from "~/packages/federation/types";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
|
|
@ -89,7 +89,7 @@ export default apiRoute((app) =>
|
|||
),
|
||||
validator("json", z.any(), handleZodError),
|
||||
async (context) => {
|
||||
const body: Entity = await context.req.valid("json");
|
||||
const body: JSONObject = await context.req.valid("json");
|
||||
|
||||
await inboxQueue.add(InboxJobType.ProcessEntity, {
|
||||
data: body,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { apiRoute, handleZodError } from "@/api";
|
||||
import { User as UserSchema } from "@versia/federation/schemas";
|
||||
import { User } from "@versia/kit/db";
|
||||
import { UserSchema } from "@versia/sdk/schemas";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
|
|
@ -43,6 +43,7 @@ export default apiRoute((app) =>
|
|||
}),
|
||||
handleZodError,
|
||||
),
|
||||
// @ts-expect-error
|
||||
async (context) => {
|
||||
const { uuid } = context.req.valid("param");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import { apiRoute, handleZodError } from "@/api";
|
||||
import {
|
||||
Collection as CollectionSchema,
|
||||
Note as NoteSchema,
|
||||
} from "@versia/federation/schemas";
|
||||
import { Note, User, db } from "@versia/kit/db";
|
||||
import { Notes } from "@versia/kit/tables";
|
||||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import { CollectionSchema, NoteSchema } from "@versia/sdk/schemas";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
|
|
@ -96,35 +94,35 @@ export default apiRoute((app) =>
|
|||
),
|
||||
);
|
||||
|
||||
const json = {
|
||||
const json = new VersiaEntities.Collection({
|
||||
first: new URL(
|
||||
`/users/${uuid}/outbox?page=1`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
),
|
||||
last: new URL(
|
||||
`/users/${uuid}/outbox?page=${Math.ceil(
|
||||
totalNotes / NOTES_PER_PAGE,
|
||||
)}`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
),
|
||||
total: totalNotes,
|
||||
author: author.getUri().toString(),
|
||||
author: author.getUri(),
|
||||
next:
|
||||
notes.length === NOTES_PER_PAGE
|
||||
? new URL(
|
||||
`/users/${uuid}/outbox?page=${pageNumber + 1}`,
|
||||
config.http.base_url,
|
||||
).toString()
|
||||
)
|
||||
: null,
|
||||
previous:
|
||||
pageNumber > 1
|
||||
? new URL(
|
||||
`/users/${uuid}/outbox?page=${pageNumber - 1}`,
|
||||
config.http.base_url,
|
||||
).toString()
|
||||
)
|
||||
: null,
|
||||
items: notes.map((note) => note.toVersia()),
|
||||
};
|
||||
});
|
||||
|
||||
const { headers } = await author.sign(
|
||||
json,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { apiRoute } from "@/api";
|
||||
import { urlToContentFormat } from "@/content_types";
|
||||
import { InstanceMetadata as InstanceMetadataSchema } from "@versia/federation/schemas";
|
||||
import { User } from "@versia/kit/db";
|
||||
import { Users } from "@versia/kit/tables";
|
||||
import { InstanceMetadataSchema } from "@versia/sdk/schemas";
|
||||
import { asc } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
|
|
|
|||
|
|
@ -6,10 +6,9 @@ import {
|
|||
webfingerMention,
|
||||
} from "@/api";
|
||||
import { getLogger } from "@logtape/logtape";
|
||||
import type { ResponseError } from "@versia/federation";
|
||||
import { WebFinger } from "@versia/federation/schemas";
|
||||
import { User } from "@versia/kit/db";
|
||||
import { Users } from "@versia/kit/tables";
|
||||
import { WebFingerSchema } from "@versia/sdk/schemas";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
|
|
@ -28,7 +27,7 @@ export default apiRoute((app) =>
|
|||
description: "User information",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(WebFinger),
|
||||
schema: resolver(WebFingerSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -81,23 +80,22 @@ export default apiRoute((app) =>
|
|||
throw ApiError.accountNotFound();
|
||||
}
|
||||
|
||||
let activityPubUrl = "";
|
||||
let activityPubUrl: URL | null = null;
|
||||
|
||||
if (config.federation.bridge) {
|
||||
const manager = await User.getFederationRequester();
|
||||
|
||||
try {
|
||||
activityPubUrl = await manager.webFinger(
|
||||
user.data.username,
|
||||
config.http.base_url.host,
|
||||
"application/activity+json",
|
||||
config.federation.bridge.url.origin,
|
||||
);
|
||||
activityPubUrl =
|
||||
await User.federationRequester.resolveWebFinger(
|
||||
user.data.username,
|
||||
config.http.base_url.host,
|
||||
"application/activity+json",
|
||||
config.federation.bridge.url.origin,
|
||||
);
|
||||
} catch (e) {
|
||||
const error = e as ResponseError;
|
||||
const error = e as ApiError;
|
||||
|
||||
getLogger(["federation", "bridge"])
|
||||
.error`Error from bridge: ${await error.response.data}`;
|
||||
.error`Error from bridge: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -112,7 +110,7 @@ export default apiRoute((app) =>
|
|||
? {
|
||||
rel: "self",
|
||||
type: "application/activity+json",
|
||||
href: activityPubUrl,
|
||||
href: activityPubUrl.href,
|
||||
}
|
||||
: undefined,
|
||||
{
|
||||
|
|
|
|||
14
bun.lock
14
bun.lock
|
|
@ -20,8 +20,8 @@
|
|||
"@scalar/hono-api-reference": "^0.8.0",
|
||||
"@sentry/bun": "^9.11.0",
|
||||
"@versia/client": "workspace:*",
|
||||
"@versia/federation": "^0.2.1",
|
||||
"@versia/kit": "workspace:*",
|
||||
"@versia/sdk": "workspace:*",
|
||||
"altcha-lib": "^1.2.0",
|
||||
"blurhash": "^2.0.5",
|
||||
"bullmq": "^5.47.2",
|
||||
|
|
@ -95,6 +95,10 @@
|
|||
"@badgateway/oauth2-client": "^2.4.2",
|
||||
},
|
||||
},
|
||||
"packages/federation": {
|
||||
"name": "@versia/sdk",
|
||||
"version": "0.0.1",
|
||||
},
|
||||
"packages/plugin-kit": {
|
||||
"name": "@versia/kit",
|
||||
"version": "0.0.0",
|
||||
|
|
@ -568,10 +572,10 @@
|
|||
|
||||
"@versia/client": ["@versia/client@workspace:packages/client"],
|
||||
|
||||
"@versia/federation": ["@versia/federation@0.2.1", "", { "dependencies": { "magic-regexp": "^0.8.0", "mime-types": "^2.1.35", "zod": "^3.24.1", "zod-validation-error": "^3.4.0" } }, "sha512-FTo3VGNJBGmCi0ZEQMzqFZBbcfbX81kmg0UgY4cKamr1dJWgEf72IAZnEDgrBffFjYtreLGdEjFkkcq3JfS8oQ=="],
|
||||
|
||||
"@versia/kit": ["@versia/kit@workspace:packages/plugin-kit"],
|
||||
|
||||
"@versia/sdk": ["@versia/sdk@workspace:packages/federation"],
|
||||
|
||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.3", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-IYSLEQj4LgZZuoVpdSUCw3dIynTWQgPlaRP6iAvMle4My0HdYwr5g5wQAfwOeHQBmYwEkqF70nRpSilr6PoUDg=="],
|
||||
|
||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.13", "", { "dependencies": { "@babel/parser": "^7.25.3", "@vue/shared": "3.5.13", "entities": "^4.5.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.0" } }, "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q=="],
|
||||
|
|
@ -1412,8 +1416,6 @@
|
|||
|
||||
"@ts-morph/common/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||
|
||||
"@versia/federation/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
|
@ -1532,8 +1534,6 @@
|
|||
|
||||
"@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
|
||||
|
||||
"@versia/federation/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"cheerio-select/domutils/dom-serializer": ["dom-serializer@1.4.1", "", { "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", "entities": "^2.0.0" } }, "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag=="],
|
||||
|
||||
"cheerio/dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { emojiValidatorWithColons, emojiValidatorWithIdentifiers } from "@/api";
|
||||
import type { CustomEmoji } from "@versia/client/schemas";
|
||||
import type { CustomEmojiExtension } from "@versia/federation/types";
|
||||
import { type Instance, Media, db } from "@versia/kit/db";
|
||||
import { Emojis, type Instances, type Medias } from "@versia/kit/tables";
|
||||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import type { ImageContentFormatSchema } from "@versia/sdk/schemas";
|
||||
import { randomUUIDv7 } from "bun";
|
||||
import {
|
||||
type InferInsertModel,
|
||||
|
|
@ -130,7 +131,10 @@ export class Emoji extends BaseInterface<typeof Emojis, EmojiType> {
|
|||
}
|
||||
|
||||
public static async fetchFromRemote(
|
||||
emojiToFetch: CustomEmojiExtension["emojis"][0],
|
||||
emojiToFetch: {
|
||||
name: string;
|
||||
url: z.infer<typeof ImageContentFormatSchema>;
|
||||
},
|
||||
instance: Instance,
|
||||
): Promise<Emoji> {
|
||||
const existingEmoji = await Emoji.fromSql(
|
||||
|
|
@ -189,15 +193,23 @@ export class Emoji extends BaseInterface<typeof Emojis, EmojiType> {
|
|||
};
|
||||
}
|
||||
|
||||
public toVersia(): CustomEmojiExtension["emojis"][0] {
|
||||
public toVersia(): {
|
||||
name: string;
|
||||
url: z.infer<typeof ImageContentFormatSchema>;
|
||||
} {
|
||||
return {
|
||||
name: `:${this.data.shortcode}:`,
|
||||
url: this.media.toVersia(),
|
||||
url: this.media.toVersia().data as z.infer<
|
||||
typeof ImageContentFormatSchema
|
||||
>,
|
||||
};
|
||||
}
|
||||
|
||||
public static async fromVersia(
|
||||
emoji: CustomEmojiExtension["emojis"][0],
|
||||
emoji: {
|
||||
name: string;
|
||||
url: z.infer<typeof ImageContentFormatSchema>;
|
||||
},
|
||||
instance: Instance,
|
||||
): Promise<Emoji> {
|
||||
// Extracts the shortcode from the emoji name (e.g. :shortcode: -> shortcode)
|
||||
|
|
@ -209,7 +221,9 @@ export class Emoji extends BaseInterface<typeof Emojis, EmojiType> {
|
|||
throw new Error("Could not extract shortcode from emoji name");
|
||||
}
|
||||
|
||||
const media = await Media.fromVersia(emoji.url);
|
||||
const media = await Media.fromVersia(
|
||||
new VersiaEntities.ImageContentFormat(emoji.url),
|
||||
);
|
||||
|
||||
return Emoji.insert({
|
||||
id: randomUUIDv7(),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { getLogger } from "@logtape/logtape";
|
||||
import { EntityValidator, type ResponseError } from "@versia/federation";
|
||||
import type { InstanceMetadata } from "@versia/federation/types";
|
||||
import { db } from "@versia/kit/db";
|
||||
import { Instances } from "@versia/kit/tables";
|
||||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import { randomUUIDv7 } from "bun";
|
||||
import chalk from "chalk";
|
||||
import {
|
||||
|
|
@ -137,24 +136,20 @@ export class Instance extends BaseInterface<typeof Instances> {
|
|||
}
|
||||
|
||||
public static async fetchMetadata(url: URL): Promise<{
|
||||
metadata: InstanceMetadata;
|
||||
metadata: VersiaEntities.InstanceMetadata;
|
||||
protocol: "versia" | "activitypub";
|
||||
}> {
|
||||
const origin = new URL(url).origin;
|
||||
const wellKnownUrl = new URL("/.well-known/versia", origin);
|
||||
|
||||
const requester = await User.getFederationRequester();
|
||||
try {
|
||||
const metadata = await User.federationRequester.fetchEntity(
|
||||
wellKnownUrl,
|
||||
VersiaEntities.InstanceMetadata,
|
||||
);
|
||||
|
||||
const { ok, raw, data } = await requester
|
||||
.get(wellKnownUrl, {
|
||||
// @ts-expect-error Bun extension
|
||||
proxy: config.http.proxy_address,
|
||||
})
|
||||
.catch((e) => ({
|
||||
...(e as ResponseError).response,
|
||||
}));
|
||||
|
||||
if (!(ok && raw.headers.get("content-type")?.includes("json"))) {
|
||||
return { metadata, protocol: "versia" };
|
||||
} catch {
|
||||
// If the server doesn't have a Versia well-known endpoint, it's not a Versia instance
|
||||
// Try to resolve ActivityPub metadata instead
|
||||
const data = await Instance.fetchActivityPubMetadata(url);
|
||||
|
|
@ -171,57 +166,35 @@ export class Instance extends BaseInterface<typeof Instances> {
|
|||
protocol: "activitypub",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const metadata = await new EntityValidator().InstanceMetadata(data);
|
||||
|
||||
return { metadata, protocol: "versia" };
|
||||
} catch {
|
||||
throw new ApiError(
|
||||
404,
|
||||
`Instance at ${origin} has invalid metadata`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static async fetchActivityPubMetadata(
|
||||
url: URL,
|
||||
): Promise<InstanceMetadata | null> {
|
||||
): Promise<VersiaEntities.InstanceMetadata | null> {
|
||||
const origin = new URL(url).origin;
|
||||
const wellKnownUrl = new URL("/.well-known/nodeinfo", origin);
|
||||
|
||||
// Go to endpoint, then follow the links to the actual metadata
|
||||
|
||||
const logger = getLogger(["federation", "resolvers"]);
|
||||
const requester = await User.getFederationRequester();
|
||||
|
||||
try {
|
||||
const {
|
||||
raw: response,
|
||||
ok,
|
||||
data: wellKnown,
|
||||
} = await requester
|
||||
.get<{
|
||||
links: { rel: string; href: string }[];
|
||||
}>(wellKnownUrl, {
|
||||
// @ts-expect-error Bun extension
|
||||
proxy: config.http.proxy_address,
|
||||
})
|
||||
.catch((e) => ({
|
||||
...(
|
||||
e as ResponseError<{
|
||||
links: { rel: string; href: string }[];
|
||||
}>
|
||||
).response,
|
||||
}));
|
||||
const { json, ok, status } = await fetch(wellKnownUrl, {
|
||||
// @ts-expect-error Bun extension
|
||||
proxy: config.http.proxy_address,
|
||||
});
|
||||
|
||||
if (!ok) {
|
||||
logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
|
||||
origin,
|
||||
)} - HTTP ${response.status}`;
|
||||
)} - HTTP ${status}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
const wellKnown = (await json()) as {
|
||||
links: { rel: string; href: string }[];
|
||||
};
|
||||
|
||||
if (!wellKnown.links) {
|
||||
logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
|
||||
origin,
|
||||
|
|
@ -243,44 +216,32 @@ export class Instance extends BaseInterface<typeof Instances> {
|
|||
}
|
||||
|
||||
const {
|
||||
raw: metadataResponse,
|
||||
json: json2,
|
||||
ok: ok2,
|
||||
data: metadata,
|
||||
} = await requester
|
||||
.get<{
|
||||
metadata: {
|
||||
nodeName?: string;
|
||||
title?: string;
|
||||
nodeDescription?: string;
|
||||
description?: string;
|
||||
};
|
||||
software: { version: string };
|
||||
}>(metadataUrl.href, {
|
||||
// @ts-expect-error Bun extension
|
||||
proxy: config.http.proxy_address,
|
||||
})
|
||||
.catch((e) => ({
|
||||
...(
|
||||
e as ResponseError<{
|
||||
metadata: {
|
||||
nodeName?: string;
|
||||
title?: string;
|
||||
nodeDescription?: string;
|
||||
description?: string;
|
||||
};
|
||||
software: { version: string };
|
||||
}>
|
||||
).response,
|
||||
}));
|
||||
status: status2,
|
||||
} = await fetch(metadataUrl.href, {
|
||||
// @ts-expect-error Bun extension
|
||||
proxy: config.http.proxy_address,
|
||||
});
|
||||
|
||||
if (!ok2) {
|
||||
logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
|
||||
origin,
|
||||
)} - HTTP ${metadataResponse.status}`;
|
||||
)} - HTTP ${status2}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
const metadata = (await json2()) as {
|
||||
metadata: {
|
||||
nodeName?: string;
|
||||
title?: string;
|
||||
nodeDescription?: string;
|
||||
description?: string;
|
||||
};
|
||||
software: { version: string };
|
||||
};
|
||||
|
||||
return new VersiaEntities.InstanceMetadata({
|
||||
name:
|
||||
metadata.metadata.nodeName || metadata.metadata.title || "",
|
||||
description:
|
||||
|
|
@ -301,7 +262,7 @@ export class Instance extends BaseInterface<typeof Instances> {
|
|||
extensions: [],
|
||||
versions: [],
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
|
||||
origin,
|
||||
|
|
@ -340,13 +301,13 @@ export class Instance extends BaseInterface<typeof Instances> {
|
|||
return Instance.insert({
|
||||
id: randomUUIDv7(),
|
||||
baseUrl: host,
|
||||
name: metadata.name,
|
||||
version: metadata.software.version,
|
||||
logo: metadata.logo,
|
||||
name: metadata.data.name,
|
||||
version: metadata.data.software.version,
|
||||
logo: metadata.data.logo,
|
||||
protocol,
|
||||
publicKey: metadata.public_key,
|
||||
inbox: metadata.shared_inbox ?? null,
|
||||
extensions: metadata.extensions ?? null,
|
||||
publicKey: metadata.data.public_key,
|
||||
inbox: metadata.data.shared_inbox?.href ?? null,
|
||||
extensions: metadata.data.extensions ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -365,13 +326,13 @@ export class Instance extends BaseInterface<typeof Instances> {
|
|||
const { metadata, protocol } = output;
|
||||
|
||||
await this.update({
|
||||
name: metadata.name,
|
||||
version: metadata.software.version,
|
||||
logo: metadata.logo,
|
||||
name: metadata.data.name,
|
||||
version: metadata.data.software.version,
|
||||
logo: metadata.data.logo,
|
||||
protocol,
|
||||
publicKey: metadata.public_key,
|
||||
inbox: metadata.shared_inbox ?? null,
|
||||
extensions: metadata.extensions ?? null,
|
||||
publicKey: metadata.data.public_key,
|
||||
inbox: metadata.data.shared_inbox?.href ?? null,
|
||||
extensions: metadata.data.extensions ?? null,
|
||||
});
|
||||
|
||||
return this;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import type { Delete, LikeExtension } from "@versia/federation/types";
|
||||
import { db } from "@versia/kit/db";
|
||||
import {
|
||||
Likes,
|
||||
|
|
@ -6,6 +5,7 @@ import {
|
|||
Notifications,
|
||||
type Users,
|
||||
} from "@versia/kit/tables";
|
||||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import {
|
||||
type InferInsertModel,
|
||||
type InferSelectModel,
|
||||
|
|
@ -149,25 +149,24 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
|
|||
return new URL(`/likes/${this.data.id}`, config.http.base_url);
|
||||
}
|
||||
|
||||
public toVersia(): LikeExtension {
|
||||
return {
|
||||
public toVersia(): VersiaEntities.Like {
|
||||
return new VersiaEntities.Like({
|
||||
id: this.data.id,
|
||||
author: User.getUri(
|
||||
this.data.liker.id,
|
||||
this.data.liker.uri ? new URL(this.data.liker.uri) : null,
|
||||
).toString(),
|
||||
),
|
||||
type: "pub.versia:likes/Like",
|
||||
created_at: new Date(this.data.createdAt).toISOString(),
|
||||
liked:
|
||||
this.data.liked.uri ??
|
||||
new URL(`/notes/${this.data.liked.id}`, config.http.base_url)
|
||||
.href,
|
||||
uri: this.getUri().toString(),
|
||||
};
|
||||
liked: this.data.liked.uri
|
||||
? new URL(this.data.liked.uri)
|
||||
: new URL(`/notes/${this.data.liked.id}`, config.http.base_url),
|
||||
uri: this.getUri(),
|
||||
});
|
||||
}
|
||||
|
||||
public unlikeToVersia(unliker?: User): Delete {
|
||||
return {
|
||||
public unlikeToVersia(unliker?: User): VersiaEntities.Delete {
|
||||
return new VersiaEntities.Delete({
|
||||
type: "Delete",
|
||||
id: crypto.randomUUID(),
|
||||
created_at: new Date().toISOString(),
|
||||
|
|
@ -178,9 +177,9 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
|
|||
: this.data.liker.uri
|
||||
? new URL(this.data.liker.uri)
|
||||
: null,
|
||||
).toString(),
|
||||
),
|
||||
deleted_type: "pub.versia:likes/Like",
|
||||
deleted: this.getUri().toString(),
|
||||
};
|
||||
deleted: this.getUri(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import { join } from "node:path";
|
||||
import { mimeLookup } from "@/content_types.ts";
|
||||
import type { Attachment as AttachmentSchema } from "@versia/client/schemas";
|
||||
import type { ContentFormat } from "@versia/federation/types";
|
||||
import { db } from "@versia/kit/db";
|
||||
import { Medias } from "@versia/kit/tables";
|
||||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import type {
|
||||
ContentFormatSchema,
|
||||
ImageContentFormatSchema,
|
||||
} from "@versia/sdk/schemas";
|
||||
import { S3Client, SHA256, randomUUIDv7, write } from "bun";
|
||||
import {
|
||||
type InferInsertModel,
|
||||
|
|
@ -202,7 +206,9 @@ export class Media extends BaseInterface<typeof Medias> {
|
|||
const newAttachment = await Media.insert({
|
||||
id: randomUUIDv7(),
|
||||
content,
|
||||
thumbnail: thumbnailContent,
|
||||
thumbnail: thumbnailContent as z.infer<
|
||||
typeof ImageContentFormatSchema
|
||||
>,
|
||||
});
|
||||
|
||||
if (config.media.conversion.convert_images) {
|
||||
|
|
@ -234,7 +240,7 @@ export class Media extends BaseInterface<typeof Medias> {
|
|||
): Promise<Media> {
|
||||
const mimeType = await mimeLookup(uri);
|
||||
|
||||
const content: ContentFormat = {
|
||||
const content: z.infer<typeof ContentFormatSchema> = {
|
||||
[mimeType]: {
|
||||
content: uri.toString(),
|
||||
remote: true,
|
||||
|
|
@ -303,7 +309,7 @@ export class Media extends BaseInterface<typeof Medias> {
|
|||
public async updateFromUrl(uri: URL): Promise<void> {
|
||||
const mimeType = await mimeLookup(uri);
|
||||
|
||||
const content: ContentFormat = {
|
||||
const content: z.infer<typeof ContentFormatSchema> = {
|
||||
[mimeType]: {
|
||||
content: uri.toString(),
|
||||
remote: true,
|
||||
|
|
@ -333,12 +339,19 @@ export class Media extends BaseInterface<typeof Medias> {
|
|||
const content = await Media.fileToContentFormat(file, url);
|
||||
|
||||
await this.update({
|
||||
thumbnail: content,
|
||||
thumbnail: content as z.infer<typeof ImageContentFormatSchema>,
|
||||
});
|
||||
}
|
||||
|
||||
public async updateMetadata(
|
||||
metadata: Partial<Omit<ContentFormat[keyof ContentFormat], "content">>,
|
||||
metadata: Partial<
|
||||
Omit<
|
||||
z.infer<typeof ContentFormatSchema>[keyof z.infer<
|
||||
typeof ContentFormatSchema
|
||||
>],
|
||||
"content"
|
||||
>
|
||||
>,
|
||||
): Promise<void> {
|
||||
const content = this.data.content;
|
||||
|
||||
|
|
@ -447,7 +460,7 @@ export class Media extends BaseInterface<typeof Medias> {
|
|||
options?: Partial<{
|
||||
description: string;
|
||||
}>,
|
||||
): Promise<ContentFormat> {
|
||||
): Promise<z.infer<typeof ContentFormatSchema>> {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const isImage = file.type.startsWith("image/");
|
||||
const { width, height } = isImage ? await sharp(buffer).metadata() : {};
|
||||
|
|
@ -521,15 +534,17 @@ export class Media extends BaseInterface<typeof Medias> {
|
|||
};
|
||||
}
|
||||
|
||||
public toVersia(): ContentFormat {
|
||||
return this.data.content;
|
||||
public toVersia(): VersiaEntities.ContentFormat {
|
||||
return new VersiaEntities.ContentFormat(this.data.content);
|
||||
}
|
||||
|
||||
public static fromVersia(contentFormat: ContentFormat): Promise<Media> {
|
||||
public static fromVersia(
|
||||
contentFormat: VersiaEntities.ContentFormat,
|
||||
): Promise<Media> {
|
||||
return Media.insert({
|
||||
id: randomUUIDv7(),
|
||||
content: contentFormat,
|
||||
originalContent: contentFormat,
|
||||
content: contentFormat.data,
|
||||
originalContent: contentFormat.data,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,6 @@ import { sanitizedHtmlStrip } from "@/sanitization";
|
|||
import { sentry } from "@/sentry";
|
||||
import { getLogger } from "@logtape/logtape";
|
||||
import type { Status, Status as StatusSchema } from "@versia/client/schemas";
|
||||
import { EntityValidator } from "@versia/federation";
|
||||
import type {
|
||||
ContentFormat,
|
||||
Delete as VersiaDelete,
|
||||
Note as VersiaNote,
|
||||
} from "@versia/federation/types";
|
||||
import { Instance, db } from "@versia/kit/db";
|
||||
import {
|
||||
EmojiToNote,
|
||||
|
|
@ -18,6 +12,7 @@ import {
|
|||
Notes,
|
||||
Users,
|
||||
} from "@versia/kit/tables";
|
||||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import { randomUUIDv7 } from "bun";
|
||||
import {
|
||||
type InferInsertModel,
|
||||
|
|
@ -39,6 +34,7 @@ import {
|
|||
parseTextMentions,
|
||||
} from "~/classes/functions/status";
|
||||
import { config } from "~/config.ts";
|
||||
import type { NonTextContentFormatSchema } from "~/packages/federation/schemas/contentformat.ts";
|
||||
import { DeliveryJobType, deliveryQueue } from "../queues/delivery.ts";
|
||||
import { Application } from "./application.ts";
|
||||
import { BaseInterface } from "./base.ts";
|
||||
|
|
@ -222,7 +218,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
await deliveryQueue.addBulk(
|
||||
users.map((user) => ({
|
||||
data: {
|
||||
entity: this.toVersia(),
|
||||
entity: this.toVersia().toJSON(),
|
||||
recipientId: user.id,
|
||||
senderId: this.author.id,
|
||||
},
|
||||
|
|
@ -323,7 +319,12 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
throw new Error("Cannot refetch a local note (it is not remote)");
|
||||
}
|
||||
|
||||
const updated = await Note.fetchFromRemote(this.getUri());
|
||||
const note = await User.federationRequester.fetchEntity(
|
||||
this.getUri(),
|
||||
VersiaEntities.Note,
|
||||
);
|
||||
|
||||
const updated = await Note.fromVersia(note);
|
||||
|
||||
if (!updated) {
|
||||
throw new Error("Note not found after update");
|
||||
|
|
@ -341,12 +342,12 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
*/
|
||||
public static async fromData(data: {
|
||||
author: User;
|
||||
content: ContentFormat;
|
||||
content: VersiaEntities.TextContentFormat;
|
||||
visibility: z.infer<typeof StatusSchema.shape.visibility>;
|
||||
isSensitive: boolean;
|
||||
spoilerText: string;
|
||||
emojis?: Emoji[];
|
||||
uri?: string;
|
||||
uri?: URL;
|
||||
mentions?: User[];
|
||||
/** List of IDs of database Attachment objects */
|
||||
mediaAttachments?: Media[];
|
||||
|
|
@ -355,8 +356,8 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
application?: Application;
|
||||
}): Promise<Note> {
|
||||
const plaintextContent =
|
||||
data.content["text/plain"]?.content ??
|
||||
Object.entries(data.content)[0][1].content;
|
||||
data.content.data["text/plain"]?.content ??
|
||||
Object.entries(data.content.data)[0][1].content;
|
||||
|
||||
const parsedMentions = mergeAndDeduplicate(
|
||||
data.mentions ?? [],
|
||||
|
|
@ -374,15 +375,15 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
authorId: data.author.id,
|
||||
content: htmlContent,
|
||||
contentSource:
|
||||
data.content["text/plain"]?.content ||
|
||||
data.content["text/markdown"]?.content ||
|
||||
Object.entries(data.content)[0][1].content ||
|
||||
data.content.data["text/plain"]?.content ||
|
||||
data.content.data["text/markdown"]?.content ||
|
||||
Object.entries(data.content.data)[0][1].content ||
|
||||
"",
|
||||
contentType: "text/html",
|
||||
visibility: data.visibility,
|
||||
sensitive: data.isSensitive,
|
||||
spoilerText: await sanitizedHtmlStrip(data.spoilerText),
|
||||
uri: data.uri || null,
|
||||
uri: data.uri?.href || null,
|
||||
replyId: data.replyId ?? null,
|
||||
quotingId: data.quoteId ?? null,
|
||||
applicationId: data.application?.id ?? null,
|
||||
|
|
@ -416,12 +417,12 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
*/
|
||||
public async updateFromData(data: {
|
||||
author: User;
|
||||
content?: ContentFormat;
|
||||
content?: VersiaEntities.TextContentFormat;
|
||||
visibility?: z.infer<typeof StatusSchema.shape.visibility>;
|
||||
isSensitive?: boolean;
|
||||
spoilerText?: string;
|
||||
emojis?: Emoji[];
|
||||
uri?: string;
|
||||
uri?: URL;
|
||||
mentions?: User[];
|
||||
mediaAttachments?: Media[];
|
||||
replyId?: string;
|
||||
|
|
@ -429,8 +430,8 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
application?: Application;
|
||||
}): Promise<Note> {
|
||||
const plaintextContent = data.content
|
||||
? (data.content["text/plain"]?.content ??
|
||||
Object.entries(data.content)[0][1].content)
|
||||
? (data.content.data["text/plain"]?.content ??
|
||||
Object.entries(data.content.data)[0][1].content)
|
||||
: undefined;
|
||||
|
||||
const parsedMentions = mergeAndDeduplicate(
|
||||
|
|
@ -451,15 +452,16 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
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 ||
|
||||
? data.content.data["text/plain"]?.content ||
|
||||
data.content.data["text/markdown"]?.content ||
|
||||
Object.entries(data.content.data)[0][1].content ||
|
||||
""
|
||||
: undefined,
|
||||
contentType: "text/html",
|
||||
visibility: data.visibility,
|
||||
sensitive: data.isSensitive,
|
||||
spoilerText: data.spoilerText,
|
||||
uri: data.uri?.href,
|
||||
replyId: data.replyId,
|
||||
quotingId: data.quoteId,
|
||||
applicationId: data.application?.id,
|
||||
|
|
@ -575,37 +577,12 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
return await Note.fromId(uuid[0]);
|
||||
}
|
||||
|
||||
return await Note.fetchFromRemote(uri);
|
||||
}
|
||||
const note = await User.federationRequester.fetchEntity(
|
||||
uri,
|
||||
VersiaEntities.Note,
|
||||
);
|
||||
|
||||
/**
|
||||
* Save a note from a remote server
|
||||
* @param uri - The URI of the note to save
|
||||
* @returns The saved note, or null if the note could not be fetched
|
||||
*/
|
||||
public static async fetchFromRemote(uri: URL): Promise<Note | null> {
|
||||
const instance = await Instance.resolve(uri);
|
||||
|
||||
if (!instance) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requester = await User.getFederationRequester();
|
||||
|
||||
const { data } = await requester.get(uri, {
|
||||
// @ts-expect-error Bun extension
|
||||
proxy: config.http.proxy_address,
|
||||
});
|
||||
|
||||
const note = await new EntityValidator().Note(data);
|
||||
|
||||
const author = await User.resolve(new URL(note.author));
|
||||
|
||||
if (!author) {
|
||||
throw new Error("Invalid object author");
|
||||
}
|
||||
|
||||
return await Note.fromVersia(note, author, instance);
|
||||
return Note.fromVersia(note);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -615,15 +592,18 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
* @param instance Instance of the note
|
||||
* @returns The saved note
|
||||
*/
|
||||
public static async fromVersia(
|
||||
note: VersiaNote,
|
||||
author: User,
|
||||
instance: Instance,
|
||||
): Promise<Note> {
|
||||
public static async fromVersia(note: VersiaEntities.Note): Promise<Note> {
|
||||
const emojis: Emoji[] = [];
|
||||
const logger = getLogger(["federation", "resolvers"]);
|
||||
|
||||
for (const emoji of note.extensions?.["pub.versia:custom_emojis"]
|
||||
const author = await User.resolve(note.data.author);
|
||||
if (!author) {
|
||||
throw new Error("Invalid object author");
|
||||
}
|
||||
|
||||
const instance = await Instance.resolve(note.data.uri);
|
||||
|
||||
for (const emoji of note.data.extensions?.["pub.versia:custom_emojis"]
|
||||
?.emojis ?? []) {
|
||||
const resolvedEmoji = await Emoji.fetchFromRemote(
|
||||
emoji,
|
||||
|
|
@ -641,7 +621,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
|
||||
const attachments: Media[] = [];
|
||||
|
||||
for (const attachment of note.attachments ?? []) {
|
||||
for (const attachment of note.attachments) {
|
||||
const resolvedAttachment = await Media.fromVersia(attachment).catch(
|
||||
(e) => {
|
||||
logger.error`${e}`;
|
||||
|
|
@ -655,9 +635,9 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
}
|
||||
}
|
||||
|
||||
let visibility = note.group
|
||||
? ["public", "followers"].includes(note.group)
|
||||
? (note.group as "public" | "private")
|
||||
let visibility = note.data.group
|
||||
? ["public", "followers"].includes(note.data.group as string)
|
||||
? (note.data.group as "public" | "private")
|
||||
: ("url" as const)
|
||||
: ("direct" as const);
|
||||
|
||||
|
|
@ -668,34 +648,37 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
|
||||
const newData = {
|
||||
author,
|
||||
content: note.content ?? {
|
||||
"text/plain": {
|
||||
content: "",
|
||||
remote: false,
|
||||
},
|
||||
},
|
||||
content:
|
||||
note.content ??
|
||||
new VersiaEntities.TextContentFormat({
|
||||
"text/plain": {
|
||||
content: "",
|
||||
remote: false,
|
||||
},
|
||||
}),
|
||||
visibility,
|
||||
isSensitive: note.is_sensitive ?? false,
|
||||
spoilerText: note.subject ?? "",
|
||||
isSensitive: note.data.is_sensitive ?? false,
|
||||
spoilerText: note.data.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>[],
|
||||
),
|
||||
uri: note.data.uri,
|
||||
mentions: (
|
||||
await Promise.all(
|
||||
(note.data.mentions ?? []).map(
|
||||
async (mention) => await User.resolve(mention),
|
||||
),
|
||||
)
|
||||
).filter((mention) => mention !== null),
|
||||
mediaAttachments: attachments,
|
||||
replyId: note.replies_to
|
||||
? (await Note.resolve(new URL(note.replies_to)))?.data.id
|
||||
replyId: note.data.replies_to
|
||||
? (await Note.resolve(note.data.replies_to))?.data.id
|
||||
: undefined,
|
||||
quoteId: note.quotes
|
||||
? (await Note.resolve(new URL(note.quotes)))?.data.id
|
||||
quoteId: note.data.quotes
|
||||
? (await Note.resolve(note.data.quotes))?.data.id
|
||||
: undefined,
|
||||
};
|
||||
|
||||
// Check if new note already exists
|
||||
|
||||
const foundNote = await Note.fromSql(eq(Notes.uri, note.uri));
|
||||
const foundNote = await Note.fromSql(eq(Notes.uri, note.data.uri.href));
|
||||
|
||||
// If it exists, simply update it
|
||||
if (foundNote) {
|
||||
|
|
@ -872,31 +855,31 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
);
|
||||
}
|
||||
|
||||
public deleteToVersia(): VersiaDelete {
|
||||
public deleteToVersia(): VersiaEntities.Delete {
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
return {
|
||||
return new VersiaEntities.Delete({
|
||||
type: "Delete",
|
||||
id,
|
||||
author: this.author.getUri().toString(),
|
||||
author: this.author.getUri(),
|
||||
deleted_type: "Note",
|
||||
deleted: this.getUri().toString(),
|
||||
deleted: this.getUri(),
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a note to the Versia format
|
||||
* @returns The note in the Versia format
|
||||
*/
|
||||
public toVersia(): VersiaNote {
|
||||
public toVersia(): VersiaEntities.Note {
|
||||
const status = this.data;
|
||||
return {
|
||||
return new VersiaEntities.Note({
|
||||
type: "Note",
|
||||
created_at: new Date(status.createdAt).toISOString(),
|
||||
id: status.id,
|
||||
author: this.author.getUri().toString(),
|
||||
uri: this.getUri().toString(),
|
||||
author: this.author.getUri(),
|
||||
uri: this.getUri(),
|
||||
content: {
|
||||
"text/html": {
|
||||
content: status.content,
|
||||
|
|
@ -908,28 +891,37 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
},
|
||||
},
|
||||
collections: {
|
||||
replies: `/notes/${status.id}/replies`,
|
||||
quotes: `/notes/${status.id}/quotes`,
|
||||
replies: new URL(
|
||||
`/notes/${status.id}/replies`,
|
||||
config.http.base_url,
|
||||
),
|
||||
quotes: new URL(
|
||||
`/notes/${status.id}/quotes`,
|
||||
config.http.base_url,
|
||||
),
|
||||
},
|
||||
attachments: (status.attachments ?? []).map((attachment) =>
|
||||
new Media(attachment).toVersia(),
|
||||
attachments: status.attachments.map(
|
||||
(attachment) =>
|
||||
new Media(attachment).toVersia().data as z.infer<
|
||||
typeof NonTextContentFormatSchema
|
||||
>,
|
||||
),
|
||||
is_sensitive: status.sensitive,
|
||||
mentions: status.mentions.map((mention) =>
|
||||
User.getUri(
|
||||
mention.id,
|
||||
mention.uri ? new URL(mention.uri) : null,
|
||||
).toString(),
|
||||
),
|
||||
),
|
||||
quotes: status.quote
|
||||
? (status.quote.uri ??
|
||||
new URL(`/notes/${status.quote.id}`, config.http.base_url)
|
||||
.href)
|
||||
? status.quote.uri
|
||||
? new URL(status.quote.uri)
|
||||
: new URL(`/notes/${status.quote.id}`, config.http.base_url)
|
||||
: null,
|
||||
replies_to: status.reply
|
||||
? (status.reply.uri ??
|
||||
new URL(`/notes/${status.reply.id}`, config.http.base_url)
|
||||
.href)
|
||||
? status.reply.uri
|
||||
? new URL(status.reply.uri)
|
||||
: new URL(`/notes/${status.reply.id}`, config.http.base_url)
|
||||
: null,
|
||||
subject: status.spoilerText,
|
||||
// TODO: Refactor as part of groups
|
||||
|
|
@ -942,7 +934,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
},
|
||||
// TODO: Add polls and reactions
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ReactionExtension } from "@versia/federation/types";
|
||||
import { Emoji, Instance, type Note, User, db } from "@versia/kit/db";
|
||||
import { type Notes, Reactions, type Users } from "@versia/kit/tables";
|
||||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import { randomUUIDv7 } from "bun";
|
||||
import {
|
||||
type InferInsertModel,
|
||||
|
|
@ -173,24 +173,23 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
|
|||
return !!this.data.emoji || !this.data.emojiText;
|
||||
}
|
||||
|
||||
public toVersia(): ReactionExtension {
|
||||
public toVersia(): VersiaEntities.Reaction {
|
||||
if (!this.isLocal()) {
|
||||
throw new Error("Cannot convert a non-local reaction to Versia");
|
||||
}
|
||||
|
||||
return {
|
||||
uri: this.getUri(config.http.base_url).toString(),
|
||||
return new VersiaEntities.Reaction({
|
||||
uri: this.getUri(config.http.base_url),
|
||||
type: "pub.versia:reactions/Reaction",
|
||||
author: User.getUri(
|
||||
this.data.authorId,
|
||||
this.data.author.uri ? new URL(this.data.author.uri) : null,
|
||||
).toString(),
|
||||
),
|
||||
created_at: new Date(this.data.createdAt).toISOString(),
|
||||
id: this.id,
|
||||
object:
|
||||
this.data.note.uri ??
|
||||
new URL(`/notes/${this.data.noteId}`, config.http.base_url)
|
||||
.href,
|
||||
object: this.data.note.uri
|
||||
? new URL(this.data.note.uri)
|
||||
: new URL(`/notes/${this.data.noteId}`, config.http.base_url),
|
||||
content: this.hasCustomEmoji()
|
||||
? `:${this.data.emoji?.shortcode}:`
|
||||
: this.data.emojiText || "",
|
||||
|
|
@ -205,11 +204,11 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
|
|||
},
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public static async fromVersia(
|
||||
reactionToConvert: ReactionExtension,
|
||||
reactionToConvert: VersiaEntities.Reaction,
|
||||
author: User,
|
||||
note: Note,
|
||||
): Promise<Reaction> {
|
||||
|
|
@ -218,7 +217,7 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
|
|||
}
|
||||
|
||||
const emojiEntity =
|
||||
reactionToConvert.extensions?.["pub.versia:custom_emojis"]
|
||||
reactionToConvert.data.extensions?.["pub.versia:custom_emojis"]
|
||||
?.emojis[0];
|
||||
const emoji = emojiEntity
|
||||
? await Emoji.fetchFromRemote(
|
||||
|
|
@ -233,11 +232,11 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
|
|||
|
||||
return Reaction.insert({
|
||||
id: randomUUIDv7(),
|
||||
uri: reactionToConvert.uri,
|
||||
uri: reactionToConvert.data.uri.href,
|
||||
authorId: author.id,
|
||||
noteId: note.id,
|
||||
emojiId: emoji ? emoji.id : null,
|
||||
emojiText: emoji ? null : reactionToConvert.content,
|
||||
emojiText: emoji ? null : reactionToConvert.data.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,19 +9,6 @@ import type {
|
|||
Source,
|
||||
} from "@versia/client/schemas";
|
||||
import type { RolePermission } from "@versia/client/schemas";
|
||||
import {
|
||||
EntityValidator,
|
||||
FederationRequester,
|
||||
type HttpVerb,
|
||||
SignatureConstructor,
|
||||
} from "@versia/federation";
|
||||
import type {
|
||||
Collection,
|
||||
Unfollow,
|
||||
FollowAccept as VersiaFollowAccept,
|
||||
FollowReject as VersiaFollowReject,
|
||||
User as VersiaUser,
|
||||
} from "@versia/federation/types";
|
||||
import { Media, Notification, PushSubscription, db } from "@versia/kit/db";
|
||||
import {
|
||||
EmojiToUser,
|
||||
|
|
@ -32,6 +19,10 @@ import {
|
|||
UserToPinnedNotes,
|
||||
Users,
|
||||
} from "@versia/kit/tables";
|
||||
import { sign } from "@versia/sdk/crypto";
|
||||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import { FederationRequester } from "@versia/sdk/http";
|
||||
import type { ImageContentFormatSchema } from "@versia/sdk/schemas";
|
||||
import { randomUUIDv7 } from "bun";
|
||||
import { password as bunPassword } from "bun";
|
||||
import chalk from "chalk";
|
||||
|
|
@ -54,7 +45,7 @@ import type { z } from "zod";
|
|||
import { findManyUsers } from "~/classes/functions/user";
|
||||
import { searchManager } from "~/classes/search/search-manager";
|
||||
import { config } from "~/config.ts";
|
||||
import type { KnownEntity } from "~/types/api.ts";
|
||||
import type { HttpVerb, KnownEntity } from "~/types/api.ts";
|
||||
import { ProxiableUrl } from "../media/url.ts";
|
||||
import { DeliveryJobType, deliveryQueue } from "../queues/delivery.ts";
|
||||
import { PushJobType, pushQueue } from "../queues/push.ts";
|
||||
|
|
@ -240,7 +231,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
): Promise<void> {
|
||||
if (followee.isRemote()) {
|
||||
await deliveryQueue.add(DeliveryJobType.FederateEntity, {
|
||||
entity: this.unfollowToVersia(followee),
|
||||
entity: this.unfollowToVersia(followee).toJSON(),
|
||||
recipientId: followee.id,
|
||||
senderId: this.id,
|
||||
});
|
||||
|
|
@ -251,15 +242,15 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
});
|
||||
}
|
||||
|
||||
private unfollowToVersia(followee: User): Unfollow {
|
||||
private unfollowToVersia(followee: User): VersiaEntities.Unfollow {
|
||||
const id = crypto.randomUUID();
|
||||
return {
|
||||
return new VersiaEntities.Unfollow({
|
||||
type: "Unfollow",
|
||||
id,
|
||||
author: this.getUri().toString(),
|
||||
author: this.getUri(),
|
||||
created_at: new Date().toISOString(),
|
||||
followee: followee.getUri().toString(),
|
||||
};
|
||||
followee: followee.getUri(),
|
||||
});
|
||||
}
|
||||
|
||||
public async sendFollowAccept(follower: User): Promise<void> {
|
||||
|
|
@ -271,16 +262,16 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
throw new Error("Followee must be a local user");
|
||||
}
|
||||
|
||||
const entity: VersiaFollowAccept = {
|
||||
const entity = new VersiaEntities.FollowAccept({
|
||||
type: "FollowAccept",
|
||||
id: crypto.randomUUID(),
|
||||
author: this.getUri().toString(),
|
||||
author: this.getUri(),
|
||||
created_at: new Date().toISOString(),
|
||||
follower: follower.getUri().toString(),
|
||||
};
|
||||
follower: follower.getUri(),
|
||||
});
|
||||
|
||||
await deliveryQueue.add(DeliveryJobType.FederateEntity, {
|
||||
entity,
|
||||
entity: entity.toJSON(),
|
||||
recipientId: follower.id,
|
||||
senderId: this.id,
|
||||
});
|
||||
|
|
@ -295,43 +286,77 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
throw new Error("Followee must be a local user");
|
||||
}
|
||||
|
||||
const entity: VersiaFollowReject = {
|
||||
const entity = new VersiaEntities.FollowReject({
|
||||
type: "FollowReject",
|
||||
id: crypto.randomUUID(),
|
||||
author: this.getUri().toString(),
|
||||
author: this.getUri(),
|
||||
created_at: new Date().toISOString(),
|
||||
follower: follower.getUri().toString(),
|
||||
};
|
||||
follower: follower.getUri(),
|
||||
});
|
||||
|
||||
await deliveryQueue.add(DeliveryJobType.FederateEntity, {
|
||||
entity,
|
||||
entity: entity.toJSON(),
|
||||
recipientId: follower.id,
|
||||
senderId: this.id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs a Versia entity with that user's private key
|
||||
*
|
||||
* @param entity Entity to sign
|
||||
* @param signatureUrl URL to embed in signature (must be the same URI of queries made with this signature)
|
||||
* @param signatureMethod HTTP method to embed in signature (default: POST)
|
||||
* @returns The signed string and headers to send with the request
|
||||
*/
|
||||
public async sign(
|
||||
entity: KnownEntity | VersiaEntities.Collection,
|
||||
signatureUrl: URL,
|
||||
signatureMethod: HttpVerb = "POST",
|
||||
): Promise<{
|
||||
headers: Headers;
|
||||
}> {
|
||||
const privateKey = await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
Buffer.from(this.data.privateKey ?? "", "base64"),
|
||||
"Ed25519",
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
|
||||
const { headers } = await sign(
|
||||
privateKey,
|
||||
this.getUri(),
|
||||
new Request(signatureUrl, {
|
||||
method: signatureMethod,
|
||||
body: JSON.stringify(entity),
|
||||
}),
|
||||
);
|
||||
|
||||
return { headers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a WebFinger lookup to find a user's URI
|
||||
* @param manager
|
||||
* @param username
|
||||
* @param hostname
|
||||
* @returns URI, or null if not found
|
||||
*/
|
||||
public static async webFinger(
|
||||
manager: FederationRequester,
|
||||
public static webFinger(
|
||||
username: string,
|
||||
hostname: string,
|
||||
): Promise<URL | null> {
|
||||
try {
|
||||
return new URL(await manager.webFinger(username, hostname));
|
||||
return User.federationRequester.resolveWebFinger(
|
||||
username,
|
||||
hostname,
|
||||
);
|
||||
} catch {
|
||||
try {
|
||||
return new URL(
|
||||
await manager.webFinger(
|
||||
username,
|
||||
hostname,
|
||||
"application/activity+json",
|
||||
),
|
||||
return User.federationRequester.resolveWebFinger(
|
||||
username,
|
||||
hostname,
|
||||
"application/activity+json",
|
||||
);
|
||||
} catch {
|
||||
return Promise.resolve(null);
|
||||
|
|
@ -455,7 +480,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
* @param uri The URI of the like, if it is remote
|
||||
* @returns The like object created or the existing like
|
||||
*/
|
||||
public async like(note: Note, uri?: string): Promise<Like> {
|
||||
public async like(note: Note, uri?: URL): Promise<Like> {
|
||||
// Check if the user has already liked the note
|
||||
const existingLike = await Like.fromSql(
|
||||
and(eq(Likes.likerId, this.id), eq(Likes.likedId, note.id)),
|
||||
|
|
@ -469,7 +494,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
id: randomUUIDv7(),
|
||||
likerId: this.id,
|
||||
likedId: note.id,
|
||||
uri,
|
||||
uri: uri?.href,
|
||||
});
|
||||
|
||||
if (this.isLocal() && note.author.isLocal()) {
|
||||
|
|
@ -601,7 +626,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
}
|
||||
|
||||
if (instance.data.protocol === "versia") {
|
||||
return await User.saveFromVersia(uri, instance);
|
||||
return await User.saveFromVersia(uri);
|
||||
}
|
||||
|
||||
if (instance.data.protocol === "activitypub") {
|
||||
|
|
@ -616,28 +641,19 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
config.federation.bridge.url,
|
||||
);
|
||||
|
||||
return await User.saveFromVersia(bridgeUri, instance);
|
||||
return await User.saveFromVersia(bridgeUri);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
private static async saveFromVersia(uri: URL): Promise<User> {
|
||||
const userData = await User.federationRequester.fetchEntity(
|
||||
uri,
|
||||
VersiaEntities.User,
|
||||
);
|
||||
|
||||
const { data: json } = output;
|
||||
|
||||
const validator = new EntityValidator();
|
||||
const data = await validator.User(json);
|
||||
|
||||
const user = await User.fromVersia(data, instance);
|
||||
const user = await User.fromVersia(userData);
|
||||
|
||||
await searchManager.addUser(user);
|
||||
|
||||
|
|
@ -663,30 +679,32 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
);
|
||||
}
|
||||
|
||||
public static async fromVersia(
|
||||
user: VersiaUser,
|
||||
instance: Instance,
|
||||
): Promise<User> {
|
||||
public static async fromVersia(user: VersiaEntities.User): Promise<User> {
|
||||
const instance = await Instance.resolve(user.data.uri);
|
||||
|
||||
const data = {
|
||||
username: user.username,
|
||||
uri: user.uri,
|
||||
createdAt: new Date(user.created_at).toISOString(),
|
||||
username: user.data.username,
|
||||
uri: user.data.uri.href,
|
||||
createdAt: new Date(user.data.created_at).toISOString(),
|
||||
endpoints: {
|
||||
dislikes:
|
||||
user.collections["pub.versia:likes/Dislikes"] ?? undefined,
|
||||
featured: user.collections.featured,
|
||||
likes: user.collections["pub.versia:likes/Likes"] ?? undefined,
|
||||
followers: user.collections.followers,
|
||||
following: user.collections.following,
|
||||
inbox: user.inbox,
|
||||
outbox: user.collections.outbox,
|
||||
user.data.collections["pub.versia:likes/Dislikes"]?.href ??
|
||||
undefined,
|
||||
featured: user.data.collections.featured.href,
|
||||
likes:
|
||||
user.data.collections["pub.versia:likes/Likes"]?.href ??
|
||||
undefined,
|
||||
followers: user.data.collections.followers.href,
|
||||
following: user.data.collections.following.href,
|
||||
inbox: user.data.inbox.href,
|
||||
outbox: user.data.collections.outbox.href,
|
||||
},
|
||||
fields: user.fields ?? [],
|
||||
updatedAt: new Date(user.created_at).toISOString(),
|
||||
fields: user.data.fields ?? [],
|
||||
updatedAt: new Date(user.data.created_at).toISOString(),
|
||||
instanceId: instance.id,
|
||||
displayName: user.display_name ?? "",
|
||||
note: getBestContentType(user.bio).content,
|
||||
publicKey: user.public_key.key,
|
||||
displayName: user.data.display_name ?? "",
|
||||
note: getBestContentType(user.data.bio).content,
|
||||
publicKey: user.data.public_key.key,
|
||||
source: {
|
||||
language: "en",
|
||||
note: "",
|
||||
|
|
@ -697,46 +715,46 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
};
|
||||
|
||||
const userEmojis =
|
||||
user.extensions?.["pub.versia:custom_emojis"]?.emojis ?? [];
|
||||
user.data.extensions?.["pub.versia:custom_emojis"]?.emojis ?? [];
|
||||
|
||||
const emojis = await Promise.all(
|
||||
userEmojis.map((emoji) => Emoji.fromVersia(emoji, instance)),
|
||||
);
|
||||
|
||||
// Check if new user already exists
|
||||
const foundUser = await User.fromSql(eq(Users.uri, user.uri));
|
||||
const foundUser = await User.fromSql(eq(Users.uri, user.data.uri.href));
|
||||
|
||||
// If it exists, simply update it
|
||||
if (foundUser) {
|
||||
let avatar: Media | null = null;
|
||||
let header: Media | null = null;
|
||||
|
||||
if (user.avatar) {
|
||||
if (user.data.avatar) {
|
||||
if (foundUser.avatar) {
|
||||
avatar = new Media(
|
||||
await foundUser.avatar.update({
|
||||
content: user.avatar,
|
||||
content: user.data.avatar,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
avatar = await Media.insert({
|
||||
id: randomUUIDv7(),
|
||||
content: user.avatar,
|
||||
content: user.data.avatar,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (user.header) {
|
||||
if (user.data.header) {
|
||||
if (foundUser.header) {
|
||||
header = new Media(
|
||||
await foundUser.header.update({
|
||||
content: user.header,
|
||||
content: user.data.header,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
header = await Media.insert({
|
||||
id: randomUUIDv7(),
|
||||
content: user.header,
|
||||
content: user.data.header,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -752,17 +770,17 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
}
|
||||
|
||||
// Else, create a new user
|
||||
const avatar = user.avatar
|
||||
const avatar = user.data.avatar
|
||||
? await Media.insert({
|
||||
id: randomUUIDv7(),
|
||||
content: user.avatar,
|
||||
content: user.data.avatar,
|
||||
})
|
||||
: null;
|
||||
|
||||
const header = user.header
|
||||
const header = user.data.header
|
||||
? await Media.insert({
|
||||
id: randomUUIDv7(),
|
||||
content: user.header,
|
||||
content: user.data.header,
|
||||
})
|
||||
: null;
|
||||
|
||||
|
|
@ -977,72 +995,25 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
return updated.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs a Versia entity with that user's private key
|
||||
*
|
||||
* @param entity Entity to sign
|
||||
* @param signatureUrl URL to embed in signature (must be the same URI of queries made with this signature)
|
||||
* @param signatureMethod HTTP method to embed in signature (default: POST)
|
||||
* @returns The signed string and headers to send with the request
|
||||
*/
|
||||
public async sign(
|
||||
entity: KnownEntity | Collection,
|
||||
signatureUrl: URL,
|
||||
signatureMethod: HttpVerb = "POST",
|
||||
): Promise<{
|
||||
headers: Headers;
|
||||
signedString: string;
|
||||
}> {
|
||||
const signatureConstructor = await SignatureConstructor.fromStringKey(
|
||||
this.data.privateKey ?? "",
|
||||
this.getUri(),
|
||||
);
|
||||
|
||||
const output = await signatureConstructor.sign(
|
||||
signatureMethod,
|
||||
signatureUrl,
|
||||
JSON.stringify(entity),
|
||||
);
|
||||
|
||||
if (config.debug?.federation) {
|
||||
const logger = getLogger("federation");
|
||||
|
||||
// Log public key
|
||||
logger.debug`Sender public key: ${this.data.publicKey}`;
|
||||
|
||||
// Log signed string
|
||||
logger.debug`Signed string:\n${output.signedString}`;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get the appropriate Versia SDK requester with the instance's private key
|
||||
*
|
||||
* @returns The requester
|
||||
*/
|
||||
public static getFederationRequester(): FederationRequester {
|
||||
const signatureConstructor = new SignatureConstructor(
|
||||
public static get federationRequester(): FederationRequester {
|
||||
return new FederationRequester(
|
||||
config.instance.keys.private,
|
||||
config.http.base_url,
|
||||
);
|
||||
|
||||
return new FederationRequester(signatureConstructor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get the appropriate Versia SDK requester with this user's private key
|
||||
*
|
||||
* @returns The requester
|
||||
*/
|
||||
public async getFederationRequester(): Promise<FederationRequester> {
|
||||
const signatureConstructor = await SignatureConstructor.fromStringKey(
|
||||
this.data.privateKey ?? "",
|
||||
this.getUri(),
|
||||
);
|
||||
|
||||
return new FederationRequester(signatureConstructor);
|
||||
public get federationRequester(): Promise<FederationRequester> {
|
||||
return crypto.subtle
|
||||
.importKey(
|
||||
"pkcs8",
|
||||
Buffer.from(this.data.privateKey ?? "", "base64"),
|
||||
"Ed25519",
|
||||
false,
|
||||
["sign"],
|
||||
)
|
||||
.then((k) => {
|
||||
return new FederationRequester(k, this.getUri());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1071,7 +1042,8 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
followers.map((follower) => ({
|
||||
name: DeliveryJobType.FederateEntity,
|
||||
data: {
|
||||
entity,
|
||||
entity: entity.toJSON(),
|
||||
type: entity.data.type,
|
||||
recipientId: follower.id,
|
||||
senderId: this.id,
|
||||
},
|
||||
|
|
@ -1098,20 +1070,14 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
);
|
||||
}
|
||||
|
||||
const { headers } = await this.sign(entity, new URL(inbox));
|
||||
|
||||
try {
|
||||
await new FederationRequester().post(inbox, entity, {
|
||||
// @ts-expect-error Bun extension
|
||||
proxy: config.http.proxy_address,
|
||||
headers: {
|
||||
...headers.toJSON(),
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
});
|
||||
await (await this.federationRequester).postEntity(
|
||||
new URL(inbox),
|
||||
entity,
|
||||
);
|
||||
} catch (e) {
|
||||
getLogger(["federation", "delivery"])
|
||||
.error`Federating ${chalk.gray(entity.type)} to ${user.getUri()} ${chalk.bold.red("failed")}`;
|
||||
.error`Federating ${chalk.gray(entity.data.type)} to ${user.getUri()} ${chalk.bold.red("failed")}`;
|
||||
getLogger(["federation", "delivery"]).error`${e}`;
|
||||
sentry?.captureException(e);
|
||||
|
||||
|
|
@ -1176,17 +1142,17 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
};
|
||||
}
|
||||
|
||||
public toVersia(): VersiaUser {
|
||||
public toVersia(): VersiaEntities.User {
|
||||
if (this.isRemote()) {
|
||||
throw new Error("Cannot convert remote user to Versia format");
|
||||
}
|
||||
|
||||
const user = this.data;
|
||||
|
||||
return {
|
||||
return new VersiaEntities.User({
|
||||
id: user.id,
|
||||
type: "User",
|
||||
uri: this.getUri().toString(),
|
||||
uri: this.getUri(),
|
||||
bio: {
|
||||
"text/html": {
|
||||
content: user.note,
|
||||
|
|
@ -1202,44 +1168,42 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
featured: new URL(
|
||||
`/users/${user.id}/featured`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
),
|
||||
"pub.versia:likes/Likes": new URL(
|
||||
`/users/${user.id}/likes`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
),
|
||||
"pub.versia:likes/Dislikes": new URL(
|
||||
`/users/${user.id}/dislikes`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
),
|
||||
followers: new URL(
|
||||
`/users/${user.id}/followers`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
),
|
||||
following: new URL(
|
||||
`/users/${user.id}/following`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
),
|
||||
outbox: new URL(
|
||||
`/users/${user.id}/outbox`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
),
|
||||
},
|
||||
inbox: new URL(
|
||||
`/users/${user.id}/inbox`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
inbox: new URL(`/users/${user.id}/inbox`, config.http.base_url),
|
||||
indexable: this.data.isIndexable,
|
||||
username: user.username,
|
||||
manually_approves_followers: this.data.isLocked,
|
||||
avatar: this.avatar?.toVersia(),
|
||||
header: this.header?.toVersia(),
|
||||
avatar: this.avatar?.toVersia().data as z.infer<
|
||||
typeof ImageContentFormatSchema
|
||||
>,
|
||||
header: this.header?.toVersia().data as z.infer<
|
||||
typeof ImageContentFormatSchema
|
||||
>,
|
||||
display_name: user.displayName,
|
||||
fields: user.fields,
|
||||
public_key: {
|
||||
actor: new URL(
|
||||
`/users/${user.id}`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
actor: new URL(`/users/${user.id}`, config.http.base_url),
|
||||
key: user.publicKey,
|
||||
algorithm: "ed25519",
|
||||
},
|
||||
|
|
@ -1250,7 +1214,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public toMention(): z.infer<typeof MentionSchema> {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { mentionValidator } from "@/api";
|
||||
import { sanitizeHtml, sanitizeHtmlInline } from "@/sanitization";
|
||||
import markdownItTaskLists from "@hackmd/markdown-it-task-lists";
|
||||
import type { ContentFormat } from "@versia/federation/types";
|
||||
import { type Note, User, db } from "@versia/kit/db";
|
||||
import { Instances, Users } from "@versia/kit/tables";
|
||||
import type * as VersiaEntities from "@versia/sdk/entities";
|
||||
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import linkifyHtml from "linkify-html";
|
||||
import {
|
||||
|
|
@ -276,21 +276,17 @@ export const parseTextMentions = async (
|
|||
|
||||
// Resolve remote mentions not in database
|
||||
for (const person of notFoundRemoteUsers) {
|
||||
const manager = await author.getFederationRequester();
|
||||
const uri = await User.webFinger(
|
||||
manager,
|
||||
const url = await (await author.federationRequester).resolveWebFinger(
|
||||
person[1] ?? "",
|
||||
person[2] ?? "",
|
||||
);
|
||||
|
||||
if (!uri) {
|
||||
continue;
|
||||
}
|
||||
if (url) {
|
||||
const user = await User.resolve(url);
|
||||
|
||||
const user = await User.resolve(uri);
|
||||
|
||||
if (user) {
|
||||
finalList.push(user);
|
||||
if (user) {
|
||||
finalList.push(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -327,21 +323,21 @@ export const replaceTextMentions = (text: string, mentions: User[]): string => {
|
|||
};
|
||||
|
||||
export const contentToHtml = async (
|
||||
content: ContentFormat,
|
||||
content: VersiaEntities.TextContentFormat,
|
||||
mentions: User[] = [],
|
||||
inline = false,
|
||||
): Promise<string> => {
|
||||
const sanitizer = inline ? sanitizeHtmlInline : sanitizeHtml;
|
||||
let htmlContent = "";
|
||||
|
||||
if (content["text/html"]) {
|
||||
htmlContent = await sanitizer(content["text/html"].content);
|
||||
} else if (content["text/markdown"]) {
|
||||
if (content.data["text/html"]) {
|
||||
htmlContent = await sanitizer(content.data["text/html"].content);
|
||||
} else if (content.data["text/markdown"]) {
|
||||
htmlContent = await sanitizer(
|
||||
await markdownParse(content["text/markdown"].content),
|
||||
await markdownParse(content.data["text/markdown"].content),
|
||||
);
|
||||
} else if (content["text/plain"]?.content) {
|
||||
htmlContent = (await sanitizer(content["text/plain"].content))
|
||||
} else if (content.data["text/plain"]?.content) {
|
||||
htmlContent = (await sanitizer(content.data["text/plain"].content))
|
||||
.split("\n")
|
||||
.map((line) => `<p>${line}</p>`)
|
||||
.join("\n");
|
||||
|
|
|
|||
|
|
@ -1,22 +1,10 @@
|
|||
import { sentry } from "@/sentry";
|
||||
import { type Logger, getLogger } from "@logtape/logtape";
|
||||
import {
|
||||
EntityValidator,
|
||||
RequestParserHandler,
|
||||
SignatureValidator,
|
||||
} from "@versia/federation";
|
||||
import type {
|
||||
Entity,
|
||||
Delete as VersiaDelete,
|
||||
Follow as VersiaFollow,
|
||||
FollowAccept as VersiaFollowAccept,
|
||||
FollowReject as VersiaFollowReject,
|
||||
LikeExtension as VersiaLikeExtension,
|
||||
Note as VersiaNote,
|
||||
User as VersiaUser,
|
||||
} from "@versia/federation/types";
|
||||
import { Instance, Like, Note, Relationship, User } from "@versia/kit/db";
|
||||
import { type Instance, Like, Note, Relationship, User } from "@versia/kit/db";
|
||||
import { Likes, Notes } from "@versia/kit/tables";
|
||||
import { EntitySorter } from "@versia/sdk";
|
||||
import { verify } from "@versia/sdk/crypto";
|
||||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import type { SocketAddress } from "bun";
|
||||
import { Glob } from "bun";
|
||||
import chalk from "chalk";
|
||||
|
|
@ -24,6 +12,7 @@ import { eq } from "drizzle-orm";
|
|||
import { matches } from "ip-matching";
|
||||
import { isValidationError } from "zod-validation-error";
|
||||
import { config } from "~/config.ts";
|
||||
import type { JSONObject } from "~/packages/federation/types.ts";
|
||||
import { ApiError } from "../errors/api-error.ts";
|
||||
|
||||
/**
|
||||
|
|
@ -63,21 +52,13 @@ export class InboxProcessor {
|
|||
* @param requestIp Request IP address. Grabs it from the Hono context if not provided.
|
||||
*/
|
||||
public constructor(
|
||||
private request: {
|
||||
url: URL;
|
||||
method: string;
|
||||
body: string;
|
||||
},
|
||||
private body: Entity,
|
||||
private request: Request,
|
||||
private body: JSONObject,
|
||||
private sender: {
|
||||
instance: Instance;
|
||||
key: string;
|
||||
key: CryptoKey;
|
||||
} | null,
|
||||
private headers: {
|
||||
signature?: string;
|
||||
signedAt?: Date;
|
||||
authorization?: string;
|
||||
},
|
||||
private authorizationHeader?: string,
|
||||
private logger: Logger = getLogger(["federation", "inbox"]),
|
||||
private requestIp: SocketAddress | null = null,
|
||||
) {}
|
||||
|
|
@ -87,40 +68,12 @@ export class InboxProcessor {
|
|||
*
|
||||
* @returns {Promise<boolean>} - Whether the signature is valid.
|
||||
*/
|
||||
private async isSignatureValid(): Promise<boolean> {
|
||||
private isSignatureValid(): Promise<boolean> {
|
||||
if (!this.sender) {
|
||||
throw new Error("Sender is not defined");
|
||||
}
|
||||
|
||||
if (config.debug?.federation) {
|
||||
this.logger.debug`Sender public key: ${chalk.gray(
|
||||
this.sender.key,
|
||||
)}`;
|
||||
}
|
||||
|
||||
const validator = await SignatureValidator.fromStringKey(
|
||||
this.sender.key,
|
||||
);
|
||||
|
||||
if (!(this.headers.signature && this.headers.signedAt)) {
|
||||
throw new Error("Missing signature or signature timestamp");
|
||||
}
|
||||
|
||||
// HACK: Making a fake Request object instead of passing the values directly is necessary because otherwise the validation breaks for some unknown reason
|
||||
const isValid = await validator.validate(
|
||||
new Request(this.request.url, {
|
||||
method: this.request.method,
|
||||
headers: {
|
||||
"Versia-Signature": this.headers.signature,
|
||||
"Versia-Signed-At": (
|
||||
this.headers.signedAt.getTime() / 1000
|
||||
).toString(),
|
||||
},
|
||||
body: this.request.body,
|
||||
}),
|
||||
);
|
||||
|
||||
return isValid;
|
||||
return verify(this.sender.key, this.request);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -131,7 +84,7 @@ export class InboxProcessor {
|
|||
*/
|
||||
private shouldCheckSignature(): boolean {
|
||||
if (config.federation.bridge) {
|
||||
const token = this.headers.authorization?.split("Bearer ")[1];
|
||||
const token = this.authorizationHeader?.split("Bearer ")[1];
|
||||
|
||||
if (token) {
|
||||
return this.isRequestFromBridge(token);
|
||||
|
|
@ -226,58 +179,48 @@ export class InboxProcessor {
|
|||
|
||||
shouldCheckSignature && this.logger.debug`Signature is valid`;
|
||||
|
||||
const validator = new EntityValidator();
|
||||
const handler = new RequestParserHandler(this.body, validator);
|
||||
|
||||
try {
|
||||
return await handler.parseBody<void>({
|
||||
note: (): Promise<void> => this.processNote(),
|
||||
follow: (): Promise<void> => this.processFollowRequest(),
|
||||
followAccept: (): Promise<void> => this.processFollowAccept(),
|
||||
followReject: (): Promise<void> => this.processFollowReject(),
|
||||
"pub.versia:likes/Like": (): Promise<void> =>
|
||||
this.processLikeRequest(),
|
||||
delete: (): Promise<void> => this.processDelete(),
|
||||
user: (): Promise<void> => this.processUserRequest(),
|
||||
unknown: (): void => {
|
||||
new EntitySorter(this.body)
|
||||
.on(VersiaEntities.Note, async (n) => {
|
||||
await Note.fromVersia(n);
|
||||
})
|
||||
.on(VersiaEntities.Follow, (f) => {
|
||||
this.processFollowRequest(f);
|
||||
})
|
||||
.on(VersiaEntities.FollowAccept, (f) => {
|
||||
this.processFollowAccept(f);
|
||||
})
|
||||
.on(VersiaEntities.FollowReject, (f) => {
|
||||
this.processFollowReject(f);
|
||||
})
|
||||
.on(VersiaEntities.Like, (l) => {
|
||||
this.processLikeRequest(l);
|
||||
})
|
||||
.on(VersiaEntities.Delete, (d) => {
|
||||
this.processDelete(d);
|
||||
})
|
||||
.on(VersiaEntities.User, async (u) => {
|
||||
await User.fromVersia(u);
|
||||
})
|
||||
.sort(() => {
|
||||
throw new ApiError(400, "Unknown entity type");
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
return this.handleError(e as Error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Note entity processing.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
private async processNote(): Promise<void> {
|
||||
const note = this.body as VersiaNote;
|
||||
const author = await User.resolve(new URL(note.author));
|
||||
const instance = await Instance.resolve(new URL(note.uri));
|
||||
|
||||
if (!instance) {
|
||||
throw new ApiError(404, "Instance not found");
|
||||
}
|
||||
|
||||
if (!author) {
|
||||
throw new ApiError(404, "Author not found");
|
||||
}
|
||||
|
||||
await Note.fromVersia(note, author, instance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Follow entity processing.
|
||||
*
|
||||
* @param {VersiaFollow} follow - The Follow entity to process.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
private async processFollowRequest(): Promise<void> {
|
||||
const follow = this.body as unknown as VersiaFollow;
|
||||
const author = await User.resolve(new URL(follow.author));
|
||||
const followee = await User.resolve(new URL(follow.followee));
|
||||
private async processFollowRequest(
|
||||
follow: VersiaEntities.Follow,
|
||||
): Promise<void> {
|
||||
const author = await User.resolve(follow.data.author);
|
||||
const followee = await User.resolve(follow.data.followee);
|
||||
|
||||
if (!author) {
|
||||
throw new ApiError(404, "Author not found");
|
||||
|
|
@ -318,12 +261,14 @@ export class InboxProcessor {
|
|||
/**
|
||||
* Handles FollowAccept entity processing
|
||||
*
|
||||
* @param {VersiaFollowAccept} followAccept - The FollowAccept entity to process.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
private async processFollowAccept(): Promise<void> {
|
||||
const followAccept = this.body as unknown as VersiaFollowAccept;
|
||||
const author = await User.resolve(new URL(followAccept.author));
|
||||
const follower = await User.resolve(new URL(followAccept.follower));
|
||||
private async processFollowAccept(
|
||||
followAccept: VersiaEntities.FollowAccept,
|
||||
): Promise<void> {
|
||||
const author = await User.resolve(followAccept.data.author);
|
||||
const follower = await User.resolve(followAccept.data.follower);
|
||||
|
||||
if (!author) {
|
||||
throw new ApiError(404, "Author not found");
|
||||
|
|
@ -351,12 +296,14 @@ export class InboxProcessor {
|
|||
/**
|
||||
* Handles FollowReject entity processing
|
||||
*
|
||||
* @param {VersiaFollowReject} followReject - The FollowReject entity to process.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
private async processFollowReject(): Promise<void> {
|
||||
const followReject = this.body as unknown as VersiaFollowReject;
|
||||
const author = await User.resolve(new URL(followReject.author));
|
||||
const follower = await User.resolve(new URL(followReject.follower));
|
||||
private async processFollowReject(
|
||||
followReject: VersiaEntities.FollowReject,
|
||||
): Promise<void> {
|
||||
const author = await User.resolve(followReject.data.author);
|
||||
const follower = await User.resolve(followReject.data.follower);
|
||||
|
||||
if (!author) {
|
||||
throw new ApiError(404, "Author not found");
|
||||
|
|
@ -384,21 +331,20 @@ export class InboxProcessor {
|
|||
/**
|
||||
* Handles Delete entity processing.
|
||||
*
|
||||
* @param {VersiaDelete} delete_ - The Delete entity to process.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
public async processDelete(): Promise<void> {
|
||||
// JS doesn't allow the use of `delete` as a variable name
|
||||
const delete_ = this.body as unknown as VersiaDelete;
|
||||
const toDelete = delete_.deleted;
|
||||
*/ // JS doesn't allow the use of `delete` as a variable name
|
||||
public async processDelete(delete_: VersiaEntities.Delete): Promise<void> {
|
||||
const toDelete = delete_.data.deleted;
|
||||
|
||||
const author = delete_.author
|
||||
? await User.resolve(new URL(delete_.author))
|
||||
const author = delete_.data.author
|
||||
? await User.resolve(delete_.data.author)
|
||||
: null;
|
||||
|
||||
switch (delete_.deleted_type) {
|
||||
switch (delete_.data.deleted_type) {
|
||||
case "Note": {
|
||||
const note = await Note.fromSql(
|
||||
eq(Notes.uri, toDelete),
|
||||
eq(Notes.uri, toDelete.href),
|
||||
author ? eq(Notes.authorId, author.id) : undefined,
|
||||
);
|
||||
|
||||
|
|
@ -413,7 +359,7 @@ export class InboxProcessor {
|
|||
return;
|
||||
}
|
||||
case "User": {
|
||||
const userToDelete = await User.resolve(new URL(toDelete));
|
||||
const userToDelete = await User.resolve(toDelete);
|
||||
|
||||
if (!userToDelete) {
|
||||
throw new ApiError(404, "User to delete not found");
|
||||
|
|
@ -428,7 +374,7 @@ export class InboxProcessor {
|
|||
}
|
||||
case "pub.versia:likes/Like": {
|
||||
const like = await Like.fromSql(
|
||||
eq(Likes.uri, toDelete),
|
||||
eq(Likes.uri, toDelete.href),
|
||||
author ? eq(Likes.likerId, author.id) : undefined,
|
||||
);
|
||||
|
||||
|
|
@ -445,7 +391,7 @@ export class InboxProcessor {
|
|||
default: {
|
||||
throw new ApiError(
|
||||
400,
|
||||
`Deletion of object ${toDelete} not implemented`,
|
||||
`Deletion of object ${toDelete.href} not implemented`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -454,12 +400,12 @@ export class InboxProcessor {
|
|||
/**
|
||||
* Handles Like entity processing.
|
||||
*
|
||||
* @param {VersiaLikeExtension} like - The Like entity to process.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
private async processLikeRequest(): Promise<void> {
|
||||
const like = this.body as unknown as VersiaLikeExtension;
|
||||
const author = await User.resolve(new URL(like.author));
|
||||
const likedNote = await Note.resolve(new URL(like.liked));
|
||||
private async processLikeRequest(like: VersiaEntities.Like): Promise<void> {
|
||||
const author = await User.resolve(like.data.author);
|
||||
const likedNote = await Note.resolve(like.data.liked);
|
||||
|
||||
if (!author) {
|
||||
throw new ApiError(404, "Author not found");
|
||||
|
|
@ -469,23 +415,7 @@ export class InboxProcessor {
|
|||
throw new ApiError(404, "Liked Note not found");
|
||||
}
|
||||
|
||||
await author.like(likedNote, like.uri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles User entity processing (profile edits).
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
private async processUserRequest(): Promise<void> {
|
||||
const user = this.body as unknown as VersiaUser;
|
||||
const instance = await Instance.resolve(new URL(user.uri));
|
||||
|
||||
if (!instance) {
|
||||
throw new ApiError(404, "Instance not found");
|
||||
}
|
||||
|
||||
await User.fromVersia(user, instance);
|
||||
await author.like(likedNote, like.data.uri);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { User } from "@versia/kit/db";
|
||||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import { Queue } from "bullmq";
|
||||
import { Worker } from "bullmq";
|
||||
import chalk from "chalk";
|
||||
import { config } from "~/config.ts";
|
||||
import type { KnownEntity } from "~/types/api";
|
||||
import type { JSONObject } from "~/packages/federation/types";
|
||||
import { connection } from "~/utils/redis.ts";
|
||||
|
||||
export enum DeliveryJobType {
|
||||
|
|
@ -11,7 +12,7 @@ export enum DeliveryJobType {
|
|||
}
|
||||
|
||||
export type DeliveryJobData = {
|
||||
entity: KnownEntity;
|
||||
entity: JSONObject;
|
||||
recipientId: string;
|
||||
senderId: string;
|
||||
};
|
||||
|
|
@ -55,7 +56,23 @@ export const getDeliveryWorker = (): Worker<
|
|||
`Federating entity [${entity.id}] from @${sender.getAcct()} to @${recipient.getAcct()}`,
|
||||
);
|
||||
|
||||
await sender.federateToUser(entity, recipient);
|
||||
const type = entity.type;
|
||||
const entityCtor = Object.values(VersiaEntities).find(
|
||||
(ctor) => ctor.name === type,
|
||||
) as typeof VersiaEntities.Entity | undefined;
|
||||
|
||||
if (!entityCtor) {
|
||||
throw new Error(
|
||||
`Could not resolve entity type ${chalk.gray(
|
||||
type,
|
||||
)} for entity [${entity.id}]`,
|
||||
);
|
||||
}
|
||||
|
||||
await sender.federateToUser(
|
||||
await entityCtor.fromJSON(entity),
|
||||
recipient,
|
||||
);
|
||||
|
||||
await job.log(
|
||||
`✔ Finished federating entity [${entity.id}]`,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { getLogger } from "@logtape/logtape";
|
||||
import type { Entity } from "@versia/federation/types";
|
||||
import { Instance, User } from "@versia/kit/db";
|
||||
import { Queue } from "bullmq";
|
||||
import { Worker } from "bullmq";
|
||||
import type { SocketAddress } from "bun";
|
||||
import { config } from "~/config.ts";
|
||||
import type { JSONObject } from "~/packages/federation/types.ts";
|
||||
import { connection } from "~/utils/redis.ts";
|
||||
import { ApiError } from "../errors/api-error.ts";
|
||||
import { InboxProcessor } from "../inbox/processor.ts";
|
||||
|
|
@ -14,7 +14,7 @@ export enum InboxJobType {
|
|||
}
|
||||
|
||||
export type InboxJobData = {
|
||||
data: Entity;
|
||||
data: JSONObject;
|
||||
headers: {
|
||||
"versia-signature"?: string;
|
||||
"versia-signed-at"?: number;
|
||||
|
|
@ -46,18 +46,25 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
|
|||
|
||||
await job.log(`Processing entity [${data.id}]`);
|
||||
|
||||
const req = new Request(request.url, {
|
||||
method: request.method,
|
||||
headers: new Headers(
|
||||
Object.entries(headers)
|
||||
.map(([k, v]) => [k, String(v)])
|
||||
.concat([
|
||||
["content-type", "application/json"],
|
||||
]) as [string, string][],
|
||||
),
|
||||
body: request.body,
|
||||
});
|
||||
|
||||
if (headers.authorization) {
|
||||
try {
|
||||
const processor = new InboxProcessor(
|
||||
{
|
||||
...request,
|
||||
url: new URL(request.url),
|
||||
},
|
||||
req,
|
||||
data,
|
||||
null,
|
||||
{
|
||||
authorization: headers.authorization,
|
||||
},
|
||||
headers.authorization,
|
||||
getLogger(["federation", "inbox"]),
|
||||
ip,
|
||||
);
|
||||
|
|
@ -91,13 +98,7 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
|
|||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
"versia-signature": signature,
|
||||
"versia-signed-at": signedAt,
|
||||
"versia-signed-by": signedBy,
|
||||
} = headers as {
|
||||
"versia-signature": string;
|
||||
"versia-signed-at": number;
|
||||
const { "versia-signed-by": signedBy } = headers as {
|
||||
"versia-signed-by": string;
|
||||
};
|
||||
|
||||
|
|
@ -139,24 +140,27 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
|
|||
);
|
||||
}
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
"spki",
|
||||
Buffer.from(
|
||||
sender?.data.publicKey ??
|
||||
remoteInstance.data.publicKey.key,
|
||||
"base64",
|
||||
),
|
||||
"Ed25519",
|
||||
false,
|
||||
["verify"],
|
||||
);
|
||||
|
||||
try {
|
||||
const processor = new InboxProcessor(
|
||||
{
|
||||
...request,
|
||||
url: new URL(request.url),
|
||||
},
|
||||
req,
|
||||
data,
|
||||
{
|
||||
instance: remoteInstance,
|
||||
key:
|
||||
sender?.data.publicKey ??
|
||||
remoteInstance.data.publicKey.key,
|
||||
},
|
||||
{
|
||||
signature,
|
||||
signedAt: new Date(signedAt * 1000),
|
||||
authorization: undefined,
|
||||
key,
|
||||
},
|
||||
undefined,
|
||||
getLogger(["federation", "inbox"]),
|
||||
ip,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,13 @@ import type {
|
|||
Status as StatusSchema,
|
||||
} from "@versia/client/schemas";
|
||||
import type { RolePermission } from "@versia/client/schemas";
|
||||
import type { ContentFormat, InstanceMetadata } from "@versia/federation/types";
|
||||
import type {
|
||||
ContentFormatSchema,
|
||||
ImageContentFormatSchema,
|
||||
InstanceMetadataSchema,
|
||||
NonTextContentFormatSchema,
|
||||
TextContentFormatSchema,
|
||||
} from "@versia/sdk/schemas";
|
||||
import type { Challenge } from "altcha-lib/types";
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import {
|
||||
|
|
@ -361,9 +367,13 @@ export const TokensRelations = relations(Tokens, ({ one }) => ({
|
|||
|
||||
export const Medias = pgTable("Medias", {
|
||||
id: id(),
|
||||
content: jsonb("content").notNull().$type<ContentFormat>(),
|
||||
originalContent: jsonb("original_content").$type<ContentFormat>(),
|
||||
thumbnail: jsonb("thumbnail").$type<ContentFormat>(),
|
||||
content: jsonb("content")
|
||||
.notNull()
|
||||
.$type<z.infer<typeof ContentFormatSchema>>(),
|
||||
originalContent:
|
||||
jsonb("original_content").$type<z.infer<typeof ContentFormatSchema>>(),
|
||||
thumbnail:
|
||||
jsonb("thumbnail").$type<z.infer<typeof ImageContentFormatSchema>>(),
|
||||
blurhash: text("blurhash"),
|
||||
});
|
||||
|
||||
|
|
@ -506,7 +516,7 @@ export const Instances = pgTable("Instances", {
|
|||
baseUrl: text("base_url").notNull(),
|
||||
name: text("name").notNull(),
|
||||
version: text("version").notNull(),
|
||||
logo: jsonb("logo").$type<ContentFormat>(),
|
||||
logo: jsonb("logo").$type<typeof NonTextContentFormatSchema._input>(),
|
||||
disableAutomoderation: boolean("disable_automoderation")
|
||||
.default(false)
|
||||
.notNull(),
|
||||
|
|
@ -515,8 +525,14 @@ export const Instances = pgTable("Instances", {
|
|||
.$type<"versia" | "activitypub">()
|
||||
.default("versia"),
|
||||
inbox: text("inbox"),
|
||||
publicKey: jsonb("public_key").$type<InstanceMetadata["public_key"]>(),
|
||||
extensions: jsonb("extensions").$type<InstanceMetadata["extensions"]>(),
|
||||
publicKey:
|
||||
jsonb("public_key").$type<
|
||||
(typeof InstanceMetadataSchema._input)["public_key"]
|
||||
>(),
|
||||
extensions:
|
||||
jsonb("extensions").$type<
|
||||
(typeof InstanceMetadataSchema._input)["extensions"]
|
||||
>(),
|
||||
});
|
||||
|
||||
export const InstancesRelations = relations(Instances, ({ many }) => ({
|
||||
|
|
@ -549,8 +565,8 @@ export const Users = pgTable(
|
|||
passwordResetToken: text("password_reset_token"),
|
||||
fields: jsonb("fields").notNull().default("[]").$type<
|
||||
{
|
||||
key: ContentFormat;
|
||||
value: ContentFormat;
|
||||
key: z.infer<typeof TextContentFormatSchema>;
|
||||
value: z.infer<typeof TextContentFormatSchema>;
|
||||
}[]
|
||||
>(),
|
||||
endpoints: jsonb("endpoints").$type<Partial<{
|
||||
|
|
|
|||
|
|
@ -92,8 +92,8 @@
|
|||
"@scalar/hono-api-reference": "^0.8.0",
|
||||
"@sentry/bun": "^9.11.0",
|
||||
"@versia/client": "workspace:*",
|
||||
"@versia/federation": "^0.2.1",
|
||||
"@versia/kit": "workspace:*",
|
||||
"@versia/sdk": "workspace:*",
|
||||
"altcha-lib": "^1.2.0",
|
||||
"blurhash": "^2.0.5",
|
||||
"bullmq": "^5.47.2",
|
||||
|
|
|
|||
74
packages/federation/crypto.ts
Normal file
74
packages/federation/crypto.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
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;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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),
|
||||
);
|
||||
};
|
||||
29
packages/federation/entities/collection.ts
Normal file
29
packages/federation/entities/collection.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
82
packages/federation/entities/contentformat.ts
Normal file
82
packages/federation/entities/contentformat.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
16
packages/federation/entities/delete.ts
Normal file
16
packages/federation/entities/delete.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
17
packages/federation/entities/entity.ts
Normal file
17
packages/federation/entities/entity.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
28
packages/federation/entities/extensions/likes.ts
Normal file
28
packages/federation/entities/extensions/likes.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
16
packages/federation/entities/extensions/polls.ts
Normal file
16
packages/federation/entities/extensions/polls.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
16
packages/federation/entities/extensions/reactions.ts
Normal file
16
packages/federation/entities/extensions/reactions.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
16
packages/federation/entities/extensions/reports.ts
Normal file
16
packages/federation/entities/extensions/reports.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
16
packages/federation/entities/extensions/share.ts
Normal file
16
packages/federation/entities/extensions/share.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
61
packages/federation/entities/follow.ts
Normal file
61
packages/federation/entities/follow.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
21
packages/federation/entities/index.ts
Normal file
21
packages/federation/entities/index.ts
Normal 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";
|
||||
31
packages/federation/entities/instancemetadata.ts
Normal file
31
packages/federation/entities/instancemetadata.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
29
packages/federation/entities/note.ts
Normal file
29
packages/federation/entities/note.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
33
packages/federation/entities/user.ts
Normal file
33
packages/federation/entities/user.ts
Normal 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/federation/http.ts
Normal file
203
packages/federation/http.ts
Normal 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 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);
|
||||
}
|
||||
}
|
||||
54
packages/federation/inbox-processor.ts
Normal file
54
packages/federation/inbox-processor.ts
Normal 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 = 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/federation/package.json
Normal file
69
packages/federation/package.json
Normal 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/federation/regex.ts
Normal file
64
packages/federation/regex.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import {
|
||||
charIn,
|
||||
charNotIn,
|
||||
createRegExp,
|
||||
digit,
|
||||
exactly,
|
||||
global,
|
||||
letter,
|
||||
not,
|
||||
oneOrMore,
|
||||
} from "magic-regexp";
|
||||
|
||||
export const semverRegex: RegExp = 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 d = new Date(val);
|
||||
return !Number.isNaN(d.valueOf());
|
||||
};
|
||||
|
||||
export const ianaTimezoneRegex = /^(?:[A-Za-z]+(?:\/[A-Za-z_]+)+|UTC)$/;
|
||||
16
packages/federation/schemas/collection.ts
Normal file
16
packages/federation/schemas/collection.ts
Normal 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),
|
||||
});
|
||||
17
packages/federation/schemas/common.ts
Normal file
17
packages/federation/schemas/common.ts
Normal 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));
|
||||
117
packages/federation/schemas/contentformat.ts
Normal file
117
packages/federation/schemas/contentformat.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
11
packages/federation/schemas/delete.ts
Normal file
11
packages/federation/schemas/delete.ts
Normal 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,
|
||||
});
|
||||
23
packages/federation/schemas/entity.ts
Normal file
23
packages/federation/schemas/entity.ts
Normal 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(),
|
||||
});
|
||||
25
packages/federation/schemas/extensions/emojis.ts
Normal file
25
packages/federation/schemas/extensions/emojis.ts
Normal 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,
|
||||
}),
|
||||
),
|
||||
});
|
||||
41
packages/federation/schemas/extensions/groups.ts
Normal file
41
packages/federation/schemas/extensions/groups.ts
Normal 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,
|
||||
});
|
||||
15
packages/federation/schemas/extensions/likes.ts
Normal file
15
packages/federation/schemas/extensions/likes.ts
Normal 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,
|
||||
});
|
||||
15
packages/federation/schemas/extensions/migration.ts
Normal file
15
packages/federation/schemas/extensions/migration.ts
Normal 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(),
|
||||
});
|
||||
22
packages/federation/schemas/extensions/polls.ts
Normal file
22
packages/federation/schemas/extensions/polls.ts
Normal 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(),
|
||||
});
|
||||
10
packages/federation/schemas/extensions/reactions.ts
Normal file
10
packages/federation/schemas/extensions/reactions.ts
Normal 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),
|
||||
});
|
||||
15
packages/federation/schemas/extensions/reports.ts
Normal file
15
packages/federation/schemas/extensions/reports.ts
Normal 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(),
|
||||
});
|
||||
9
packages/federation/schemas/extensions/share.ts
Normal file
9
packages/federation/schemas/extensions/share.ts
Normal 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,
|
||||
});
|
||||
46
packages/federation/schemas/extensions/vanity.ts
Normal file
46
packages/federation/schemas/extensions/vanity.ts
Normal 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(),
|
||||
});
|
||||
31
packages/federation/schemas/follow.ts
Normal file
31
packages/federation/schemas/follow.ts
Normal 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,
|
||||
});
|
||||
27
packages/federation/schemas/index.ts
Normal file
27
packages/federation/schemas/index.ts
Normal 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";
|
||||
41
packages/federation/schemas/instance.ts
Normal file
41
packages/federation/schemas/instance.ts
Normal 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(),
|
||||
});
|
||||
67
packages/federation/schemas/note.ts
Normal file
67
packages/federation/schemas/note.ts
Normal 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(),
|
||||
});
|
||||
60
packages/federation/schemas/user.ts
Normal file
60
packages/federation/schemas/user.ts
Normal 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(),
|
||||
});
|
||||
19
packages/federation/schemas/webfinger.ts
Normal file
19
packages/federation/schemas/webfinger.ts
Normal 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/federation/types.ts
Normal file
11
packages/federation/types.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
type JSONValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| JSONValue[]
|
||||
| { [key: string]: JSONValue };
|
||||
|
||||
export interface JSONObject {
|
||||
[k: string]: JSONValue;
|
||||
}
|
||||
30
types/api.ts
30
types/api.ts
|
|
@ -1,14 +1,4 @@
|
|||
import type {
|
||||
Delete,
|
||||
Follow,
|
||||
FollowAccept,
|
||||
FollowReject,
|
||||
InstanceMetadata,
|
||||
LikeExtension,
|
||||
Note,
|
||||
Unfollow,
|
||||
User,
|
||||
} from "@versia/federation/types";
|
||||
import type * as VersiaEntities from "@versia/sdk/entities";
|
||||
import type { SocketAddress } from "bun";
|
||||
import type { Hono } from "hono";
|
||||
import type { RouterRoute } from "hono/types";
|
||||
|
|
@ -33,12 +23,12 @@ export interface ApiRouteExports {
|
|||
}
|
||||
|
||||
export type KnownEntity =
|
||||
| Note
|
||||
| InstanceMetadata
|
||||
| User
|
||||
| Follow
|
||||
| FollowAccept
|
||||
| FollowReject
|
||||
| Unfollow
|
||||
| Delete
|
||||
| LikeExtension;
|
||||
| VersiaEntities.Note
|
||||
| VersiaEntities.InstanceMetadata
|
||||
| VersiaEntities.User
|
||||
| VersiaEntities.Follow
|
||||
| VersiaEntities.FollowAccept
|
||||
| VersiaEntities.FollowReject
|
||||
| VersiaEntities.Unfollow
|
||||
| VersiaEntities.Delete
|
||||
| VersiaEntities.Like;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import type { ContentFormat } from "@versia/federation/types";
|
||||
import type { ContentFormatSchema } from "@versia/sdk/schemas";
|
||||
import { htmlToText as htmlToTextLib } from "html-to-text";
|
||||
import { lookup } from "mime-types";
|
||||
import type { z } from "zod";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
export const getBestContentType = (
|
||||
content?: ContentFormat | null,
|
||||
content?: z.infer<typeof ContentFormatSchema> | null,
|
||||
): {
|
||||
content: string;
|
||||
format: string;
|
||||
|
|
@ -32,7 +33,7 @@ export const getBestContentType = (
|
|||
export const urlToContentFormat = (
|
||||
url: URL,
|
||||
contentType?: string,
|
||||
): ContentFormat | null => {
|
||||
): z.infer<typeof ContentFormatSchema> | null => {
|
||||
if (url.href.startsWith("https://api.dicebear.com/")) {
|
||||
return {
|
||||
"image/svg+xml": {
|
||||
|
|
|
|||
Loading…
Reference in a new issue