chore(federation): 👽 Initial Versia Working Draft 4.0 support

This commit is contained in:
Jesse Wierzbinski 2024-08-26 19:06:49 +02:00
parent 9c71c3fe51
commit c3fa867e74
No known key found for this signature in database
22 changed files with 269 additions and 197 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -1,15 +1,16 @@
import type { Undo } from "@lysand-org/federation/types"; import type { Unfollow } from "@versia/federation/types";
import { config } from "~/packages/config-manager/index";
import type { User } from "~/packages/database-interface/user"; import type { User } from "~/packages/database-interface/user";
export const undoFederationRequest = (undoer: User, uri: string): Undo => { export const unfollowFederationRequest = (
unfollower: User,
unfollowing: User,
): Unfollow => {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
return { return {
type: "Undo", type: "Unfollow",
id, id,
author: undoer.getUri(), author: unfollower.getUri(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
object: uri, followee: unfollowing.getUri(),
uri: new URL(`/undos/${id}`, config.http.base_url).toString(),
}; };
}; };

View file

@ -1,4 +1,4 @@
import type { Like } from "@lysand-org/federation/types"; import type { LikeExtension } from "@versia/federation/types";
import { type InferSelectModel, and, eq } from "drizzle-orm"; import { type InferSelectModel, and, eq } from "drizzle-orm";
import { db } from "~/drizzle/db"; import { db } from "~/drizzle/db";
import { Likes, Notifications } from "~/drizzle/schema"; import { Likes, Notifications } from "~/drizzle/schema";
@ -11,15 +11,15 @@ export type LikeType = InferSelectModel<typeof Likes>;
/** /**
* Represents a Like entity in the database. * Represents a Like entity in the database.
*/ */
export const likeToVersia = (like: LikeType): Like => { export const likeToVersia = (like: LikeType): LikeExtension => {
return { return {
id: like.id, id: like.id,
// biome-ignore lint/suspicious/noExplicitAny: to be rewritten // biome-ignore lint/suspicious/noExplicitAny: to be rewritten
author: (like as any).liker?.uri, author: (like as any).liker?.uri,
type: "Like", type: "pub.versia:likes/Like",
created_at: new Date(like.createdAt).toISOString(), created_at: new Date(like.createdAt).toISOString(),
// biome-ignore lint/suspicious/noExplicitAny: to be rewritten // biome-ignore lint/suspicious/noExplicitAny: to be rewritten
object: (like as any).liked?.uri, liked: (like as any).liked?.uri,
uri: new URL(`/objects/${like.id}`, config.http.base_url).toString(), uri: new URL(`/objects/${like.id}`, config.http.base_url).toString(),
}; };
}; };

View file

@ -1,7 +1,7 @@
import { mentionValidator } from "@/api"; import { mentionValidator } from "@/api";
import { sanitizeHtml, sanitizeHtmlInline } from "@/sanitization"; import { sanitizeHtml, sanitizeHtmlInline } from "@/sanitization";
import markdownItTaskLists from "@hackmd/markdown-it-task-lists"; import markdownItTaskLists from "@hackmd/markdown-it-task-lists";
import type { ContentFormat } from "@lysand-org/federation/types"; import type { ContentFormat } from "@versia/federation/types";
import { import {
type InferSelectModel, type InferSelectModel,
and, and,

View file

@ -2,7 +2,7 @@ import type {
Follow, Follow,
FollowAccept, FollowAccept,
FollowReject, FollowReject,
} from "@lysand-org/federation/types"; } from "@versia/federation/types";
import { type InferSelectModel, eq, sql } from "drizzle-orm"; import { type InferSelectModel, eq, sql } from "drizzle-orm";
import { db } from "~/drizzle/db"; import { db } from "~/drizzle/db";
import { import {
@ -12,7 +12,6 @@ import {
Tokens, Tokens,
type Users, type Users,
} from "~/drizzle/schema"; } from "~/drizzle/schema";
import { config } from "~/packages/config-manager/index";
import type { EmojiWithInstance } from "~/packages/database-interface/emoji"; import type { EmojiWithInstance } from "~/packages/database-interface/emoji";
import { User } from "~/packages/database-interface/user"; import { User } from "~/packages/database-interface/user";
import type { Application } from "./application"; import type { Application } from "./application";
@ -282,7 +281,6 @@ export const followRequestToVersia = (
author: follower.getUri(), author: follower.getUri(),
followee: followee.getUri(), followee: followee.getUri(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
uri: new URL(`/follows/${id}`, config.http.base_url).toString(),
}; };
}; };
@ -310,7 +308,6 @@ export const followAcceptToVersia = (
author: followee.getUri(), author: followee.getUri(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
follower: follower.getUri(), follower: follower.getUri(),
uri: new URL(`/follows/${id}`, config.http.base_url).toString(),
}; };
}; };

View file

@ -1,5 +1,5 @@
import type { Source as ApiSource } from "@lysand-org/client/types"; import type { Source as ApiSource } from "@lysand-org/client/types";
import type { ContentFormat } from "@lysand-org/federation/types"; import type { ContentFormat } from "@versia/federation/types";
import type { Challenge } from "altcha-lib/types"; import type { Challenge } from "altcha-lib/types";
import { relations, sql } from "drizzle-orm"; import { relations, sql } from "drizzle-orm";
import { import {
@ -381,9 +381,9 @@ export const Users = pgTable(
}[] }[]
>(), >(),
endpoints: jsonb("endpoints").$type<Partial<{ endpoints: jsonb("endpoints").$type<Partial<{
dislikes: string; dislikes?: string;
featured: string; featured: string;
likes: string; likes?: string;
followers: string; followers: string;
following: string; following: string;
inbox: string; inbox: string;

View file

@ -110,6 +110,7 @@
"@oclif/core": "^4.0.19", "@oclif/core": "^4.0.19",
"@sentry/bun": "^8.26.0", "@sentry/bun": "^8.26.0",
"@tufjs/canonical-json": "^2.0.0", "@tufjs/canonical-json": "^2.0.0",
"@versia/federation": "^0.1.0-rc.0",
"altcha-lib": "^0.5.1", "altcha-lib": "^0.5.1",
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
"bullmq": "^5.12.10", "bullmq": "^5.12.10",

View file

@ -3,7 +3,7 @@ import type {
AsyncAttachment as ApiAsyncAttachment, AsyncAttachment as ApiAsyncAttachment,
Attachment as ApiAttachment, Attachment as ApiAttachment,
} from "@lysand-org/client/types"; } from "@lysand-org/client/types";
import type { ContentFormat } from "@lysand-org/federation/types"; import type { ContentFormat } from "@versia/federation/types";
import { import {
type InferInsertModel, type InferInsertModel,
type InferSelectModel, type InferSelectModel,
@ -201,7 +201,9 @@ export class Attachment extends BaseInterface<typeof Attachments> {
return { return {
[this.data.mimeType]: { [this.data.mimeType]: {
content: this.data.url, content: this.data.url,
blurhash: this.data.blurhash ?? undefined, remote: true,
// TODO: Replace BlurHash with thumbhash
// thumbhash: this.data.blurhash ?? undefined,
description: this.data.description ?? undefined, description: this.data.description ?? undefined,
duration: this.data.duration ?? undefined, duration: this.data.duration ?? undefined,
fps: this.data.fps ?? undefined, fps: this.data.fps ?? undefined,
@ -233,7 +235,7 @@ export class Attachment extends BaseInterface<typeof Attachments> {
size: value.size || undefined, size: value.size || undefined,
width: value.width || undefined, width: value.width || undefined,
sha256: value.hash?.sha256 || undefined, sha256: value.hash?.sha256 || undefined,
blurhash: value.blurhash || undefined, // blurhash: value.blurhash || undefined,
}); });
} }
} }

View file

@ -1,7 +1,7 @@
import { emojiValidatorWithColons } from "@/api"; import { emojiValidatorWithColons } from "@/api";
import { proxyUrl } from "@/response"; import { proxyUrl } from "@/response";
import type { Emoji as ApiEmoji } from "@lysand-org/client/types"; import type { Emoji as ApiEmoji } from "@lysand-org/client/types";
import type { CustomEmojiExtension } from "@lysand-org/federation/types"; import type { CustomEmojiExtension } from "@versia/federation/types";
import { import {
type InferInsertModel, type InferInsertModel,
type InferSelectModel, type InferSelectModel,
@ -196,6 +196,7 @@ export class Emoji extends BaseInterface<typeof Emojis, EmojiWithInstance> {
[this.data.contentType]: { [this.data.contentType]: {
content: this.data.url, content: this.data.url,
description: this.data.alt || undefined, description: this.data.alt || undefined,
remote: true,
}, },
}, },
}; };

View file

@ -3,8 +3,8 @@ import {
EntityValidator, EntityValidator,
type ResponseError, type ResponseError,
type ValidationError, type ValidationError,
} from "@lysand-org/federation"; } from "@versia/federation";
import type { ServerMetadata } from "@lysand-org/federation/types"; import type { InstanceMetadata } from "@versia/federation/types";
import chalk from "chalk"; import chalk from "chalk";
import { import {
type InferInsertModel, type InferInsertModel,
@ -132,7 +132,7 @@ export class Instance extends BaseInterface<typeof Instances> {
} }
static async fetchMetadata(url: string): Promise<{ static async fetchMetadata(url: string): Promise<{
metadata: ServerMetadata; metadata: InstanceMetadata;
protocol: "versia" | "activitypub"; protocol: "versia" | "activitypub";
} | null> { } | null> {
const origin = new URL(url).origin; const origin = new URL(url).origin;
@ -167,7 +167,7 @@ export class Instance extends BaseInterface<typeof Instances> {
} }
try { try {
const metadata = await new EntityValidator().ServerMetadata( const metadata = await new EntityValidator().InstanceMetadata(
data, data,
); );
@ -188,7 +188,7 @@ export class Instance extends BaseInterface<typeof Instances> {
private static async fetchActivityPubMetadata( private static async fetchActivityPubMetadata(
url: string, url: string,
): Promise<ServerMetadata | null> { ): Promise<InstanceMetadata | null> {
const origin = new URL(url).origin; const origin = new URL(url).origin;
const wellKnownUrl = new URL("/.well-known/nodeinfo", origin); const wellKnownUrl = new URL("/.well-known/nodeinfo", origin);
@ -285,14 +285,30 @@ export class Instance extends BaseInterface<typeof Instances> {
return { return {
name: name:
metadata.metadata.nodeName || metadata.metadata.title || "", metadata.metadata.nodeName || metadata.metadata.title || "",
version: metadata.software.version, description: {
description: "text/plain": {
metadata.metadata.nodeDescription || content:
metadata.metadata.description || metadata.metadata.nodeDescription ||
"", metadata.metadata.description ||
logo: undefined, "",
type: "ServerMetadata", remote: false,
supported_extensions: [], },
},
type: "InstanceMetadata",
software: {
name: "Unknown ActivityPub software",
version: metadata.software.version,
},
created_at: new Date().toISOString(),
public_key: {
key: "",
algorithm: "ed25519",
},
host: new URL(url).host,
compatibility: {
extensions: [],
versions: [],
},
}; };
} catch (error) { } catch (error) {
logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold( logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
@ -326,7 +342,7 @@ export class Instance extends BaseInterface<typeof Instances> {
return Instance.insert({ return Instance.insert({
baseUrl: host, baseUrl: host,
name: metadata.name, name: metadata.name,
version: metadata.version, version: metadata.software.version,
logo: metadata.logo, logo: metadata.logo,
protocol: protocol, protocol: protocol,
}); });

View file

@ -8,11 +8,12 @@ import type {
Attachment as ApiAttachment, Attachment as ApiAttachment,
Status as ApiStatus, Status as ApiStatus,
} from "@lysand-org/client/types"; } from "@lysand-org/client/types";
import { EntityValidator } from "@lysand-org/federation"; import { EntityValidator } from "@versia/federation";
import type { import type {
ContentFormat, ContentFormat,
Delete as VersiaDelete,
Note as VersiaNote, Note as VersiaNote,
} from "@lysand-org/federation/types"; } from "@versia/federation/types";
import { import {
type InferInsertModel, type InferInsertModel,
type SQL, type SQL,
@ -666,14 +667,26 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
} }
} }
let visibility = note.group
? ["public", "followers"].includes(note.group)
? (note.group as "public" | "private")
: ("url" as const)
: ("direct" as const);
if (visibility === "url") {
// TODO: Implement groups
visibility = "direct";
}
const newData = { const newData = {
author, author,
content: note.content ?? { content: note.content ?? {
"text/plain": { "text/plain": {
content: "", content: "",
remote: false,
}, },
}, },
visibility: note.visibility as ApiStatus["visibility"], visibility: visibility as ApiStatus["visibility"],
isSensitive: note.is_sensitive ?? false, isSensitive: note.is_sensitive ?? false,
spoilerText: note.subject ?? "", spoilerText: note.subject ?? "",
emojis, emojis,
@ -885,6 +898,19 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
).toString(); ).toString();
} }
deleteToVersia(): VersiaDelete {
const id = crypto.randomUUID();
return {
type: "Delete",
id,
author: this.author.getUri(),
deleted_type: "Note",
target: this.getUri(),
created_at: new Date().toISOString(),
};
}
/** /**
* Convert a note to the Versia format * Convert a note to the Versia format
* @returns The note in the Versia format * @returns The note in the Versia format
@ -900,9 +926,11 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
content: { content: {
"text/html": { "text/html": {
content: status.content, content: status.content,
remote: false,
}, },
"text/plain": { "text/plain": {
content: htmlToText(status.content), content: htmlToText(status.content),
remote: false,
}, },
}, },
attachments: (status.attachments ?? []).map((attachment) => attachments: (status.attachments ?? []).map((attachment) =>
@ -917,11 +945,8 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
replies_to: replies_to:
Note.getUri(status.replyId, status.reply?.uri) ?? undefined, Note.getUri(status.replyId, status.reply?.uri) ?? undefined,
subject: status.spoilerText, subject: status.spoilerText,
visibility: status.visibility as // TODO: Refactor as part of groups
| "public" group: status.visibility === "public" ? "public" : "followers",
| "unlisted"
| "private"
| "direct",
extensions: { extensions: {
"org.lysand:custom_emojis": { "org.lysand:custom_emojis": {
emojis: status.emojis.map((emoji) => emojis: status.emojis.map((emoji) =>

View file

@ -13,8 +13,8 @@ import {
FederationRequester, FederationRequester,
type HttpVerb, type HttpVerb,
SignatureConstructor, SignatureConstructor,
} from "@lysand-org/federation"; } from "@versia/federation";
import type { Entity, User as VersiaUser } from "@lysand-org/federation/types"; import type { User as VersiaUser } from "@versia/federation/types";
import chalk from "chalk"; import chalk from "chalk";
import { import {
type InferInsertModel, type InferInsertModel,
@ -48,7 +48,8 @@ import {
Users, Users,
} from "~/drizzle/schema"; } from "~/drizzle/schema";
import { type Config, config } from "~/packages/config-manager"; import { type Config, config } from "~/packages/config-manager";
import { undoFederationRequest } from "../../classes/functions/federation.ts"; import type { KnownEntity } from "~/types/api.ts";
import { unfollowFederationRequest } from "../../classes/functions/federation.ts";
import { BaseInterface } from "./base"; import { BaseInterface } from "./base";
import { Emoji } from "./emoji"; import { Emoji } from "./emoji";
import { Instance } from "./instance"; import { Instance } from "./instance";
@ -259,13 +260,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
if (followee.isRemote()) { if (followee.isRemote()) {
// TODO: This should reschedule for a later time and maybe notify the server admin if it fails too often // TODO: This should reschedule for a later time and maybe notify the server admin if it fails too often
const { ok } = await this.federateToUser( const { ok } = await this.federateToUser(
undoFederationRequest( unfollowFederationRequest(this, followee),
this,
new URL(
`/follows/${relationship.id}`,
config.http.base_url,
).toString(),
),
followee, followee,
); );
@ -450,7 +445,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
const user = await User.fromVersia(data, instance); const user = await User.fromVersia(data, instance);
const userEmojis = const userEmojis =
data.extensions?.["org.lysand:custom_emojis"]?.emojis ?? []; data.extensions?.["pub.versia:custom_emojis"]?.emojis ?? [];
const emojis = await Promise.all( const emojis = await Promise.all(
userEmojis.map((emoji) => Emoji.fromVersia(emoji, instance.id)), userEmojis.map((emoji) => Emoji.fromVersia(emoji, instance.id)),
); );
@ -484,13 +479,14 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
uri: user.uri, uri: user.uri,
createdAt: new Date(user.created_at).toISOString(), createdAt: new Date(user.created_at).toISOString(),
endpoints: { endpoints: {
dislikes: user.dislikes, dislikes:
featured: user.featured, user.collections["pub.versia:likes/Dislikes"] ?? undefined,
likes: user.likes, featured: user.collections.featured,
followers: user.followers, likes: user.collections["pub.versia:likes/Likes"] ?? undefined,
following: user.following, followers: user.collections.followers,
following: user.collections.following,
inbox: user.inbox, inbox: user.inbox,
outbox: user.outbox, outbox: user.collections.outbox,
}, },
fields: user.fields ?? [], fields: user.fields ?? [],
updatedAt: new Date(user.created_at).toISOString(), updatedAt: new Date(user.created_at).toISOString(),
@ -503,7 +499,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
: "", : "",
displayName: user.display_name ?? "", displayName: user.display_name ?? "",
note: getBestContentType(user.bio).content, note: getBestContentType(user.bio).content,
publicKey: user.public_key.public_key, publicKey: user.public_key.key,
source: { source: {
language: null, language: null,
note: "", note: "",
@ -722,7 +718,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
* @returns The signed string and headers to send with the request * @returns The signed string and headers to send with the request
*/ */
async sign( async sign(
entity: Entity, entity: KnownEntity,
signatureUrl: string | URL, signatureUrl: string | URL,
signatureMethod: HttpVerb = "POST", signatureMethod: HttpVerb = "POST",
): Promise<{ ): Promise<{
@ -772,7 +768,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
* *
* @param entity Entity to federate * @param entity Entity to federate
*/ */
async federateToFollowers(entity: Entity): Promise<void> { async federateToFollowers(entity: KnownEntity): Promise<void> {
// Get followers // Get followers
const followers = await User.manyFromSql( const followers = await User.manyFromSql(
and( and(
@ -793,7 +789,10 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
* @param user User to federate to * @param user User to federate to
* @returns Whether the federation was successful * @returns Whether the federation was successful
*/ */
async federateToUser(entity: Entity, user: User): Promise<{ ok: boolean }> { async federateToUser(
entity: KnownEntity,
user: User,
): Promise<{ ok: boolean }> {
const { headers } = await this.sign( const { headers } = await this.sign(
entity, entity,
user.data.endpoints?.inbox ?? "", user.data.endpoints?.inbox ?? "",
@ -908,40 +907,44 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
bio: { bio: {
"text/html": { "text/html": {
content: user.note, content: user.note,
remote: false,
}, },
"text/plain": { "text/plain": {
content: htmlToText(user.note), content: htmlToText(user.note),
remote: false,
}, },
}, },
created_at: new Date(user.createdAt).toISOString(), created_at: new Date(user.createdAt).toISOString(),
dislikes: new URL( collections: {
`/users/${user.id}/dislikes`, featured: new URL(
config.http.base_url, `/users/${user.id}/featured`,
).toString(), config.http.base_url,
featured: new URL( ).toString(),
`/users/${user.id}/featured`, "pub.versia:likes/Likes": new URL(
config.http.base_url, `/users/${user.id}/likes`,
).toString(), config.http.base_url,
likes: new URL( ).toString(),
`/users/${user.id}/likes`, "pub.versia:likes/Dislikes": new URL(
config.http.base_url, `/users/${user.id}/dislikes`,
).toString(), config.http.base_url,
followers: new URL( ).toString(),
`/users/${user.id}/followers`, followers: new URL(
config.http.base_url, `/users/${user.id}/followers`,
).toString(), config.http.base_url,
following: new URL( ).toString(),
`/users/${user.id}/following`, following: new URL(
config.http.base_url, `/users/${user.id}/following`,
).toString(), config.http.base_url,
).toString(),
outbox: new URL(
`/users/${user.id}/outbox`,
config.http.base_url,
).toString(),
},
inbox: new URL( inbox: new URL(
`/users/${user.id}/inbox`, `/users/${user.id}/inbox`,
config.http.base_url, config.http.base_url,
).toString(), ).toString(),
outbox: new URL(
`/users/${user.id}/outbox`,
config.http.base_url,
).toString(),
indexable: false, indexable: false,
username: user.username, username: user.username,
avatar: urlToContentFormat(this.getAvatarUrl(config)) ?? undefined, avatar: urlToContentFormat(this.getAvatarUrl(config)) ?? undefined,
@ -953,7 +956,8 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
`/users/${user.id}`, `/users/${user.id}`,
config.http.base_url, config.http.base_url,
).toString(), ).toString(),
public_key: user.publicKey, key: user.publicKey,
algorithm: "ed25519",
}, },
extensions: { extensions: {
"org.lysand:custom_emojis": { "org.lysand:custom_emojis": {

View file

@ -168,6 +168,7 @@ export default apiRoute((app) =>
self.note = await contentToHtml({ self.note = await contentToHtml({
"text/markdown": { "text/markdown": {
content: note, content: note,
remote: false,
}, },
}); });
} }
@ -235,6 +236,7 @@ export default apiRoute((app) =>
{ {
"text/markdown": { "text/markdown": {
content: field.name, content: field.name,
remote: false,
}, },
}, },
undefined, undefined,
@ -245,6 +247,7 @@ export default apiRoute((app) =>
{ {
"text/markdown": { "text/markdown": {
content: field.value, content: field.value,
remote: false,
}, },
}, },
undefined, undefined,
@ -262,11 +265,13 @@ export default apiRoute((app) =>
key: { key: {
"text/html": { "text/html": {
content: parsedName, content: parsedName,
remote: false,
}, },
}, },
value: { value: {
"text/html": { "text/html": {
content: parsedValue, content: parsedValue,
remote: false,
}, },
}, },
}); });

View file

@ -9,7 +9,6 @@ import {
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import ISO6391 from "iso-639-1"; import ISO6391 from "iso-639-1";
import { z } from "zod"; import { z } from "zod";
import { undoFederationRequest } from "~/classes/functions/federation";
import { RolePermissions } from "~/drizzle/schema"; import { RolePermissions } from "~/drizzle/schema";
import { config } from "~/packages/config-manager/index"; import { config } from "~/packages/config-manager/index";
import { Attachment } from "~/packages/database-interface/attachment"; import { Attachment } from "~/packages/database-interface/attachment";
@ -136,9 +135,7 @@ export default apiRoute((app) =>
await note.delete(); await note.delete();
await user.federateToFollowers( await user.federateToFollowers(note.deleteToVersia());
undoFederationRequest(user, note.getUri()),
);
return context.json(await note.toApi(user), 200); return context.json(await note.toApi(user), 200);
} }
@ -169,6 +166,7 @@ export default apiRoute((app) =>
? { ? {
[content_type]: { [content_type]: {
content: statusText, content: statusText,
remote: false,
}, },
} }
: undefined, : undefined,

View file

@ -2,7 +2,6 @@ import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { undoFederationRequest } from "~/classes/functions/federation";
import { Notes, RolePermissions } from "~/drizzle/schema"; import { Notes, RolePermissions } from "~/drizzle/schema";
import { Note } from "~/packages/database-interface/note"; import { Note } from "~/packages/database-interface/note";
@ -63,9 +62,7 @@ export default apiRoute((app) =>
await existingReblog.delete(); await existingReblog.delete();
await user.federateToFollowers( await user.federateToFollowers(existingReblog.deleteToVersia());
undoFederationRequest(user, existingReblog.getUri()),
);
const newNote = await Note.fromId(id, user.id); const newNote = await Note.fromId(id, user.id);

View file

@ -155,6 +155,7 @@ export default apiRoute((app) =>
content: { content: {
[content_type]: { [content_type]: {
content: status ?? "", content: status ?? "",
remote: false,
}, },
}, },
visibility, visibility,

View file

@ -1,7 +1,6 @@
import { apiRoute, applyConfig, handleZodError } from "@/api"; import { apiRoute, applyConfig, handleZodError } from "@/api";
import { response } from "@/response"; import { response } from "@/response";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import type { Entity } from "@lysand-org/federation/types";
import { and, eq, inArray, sql } from "drizzle-orm"; import { and, eq, inArray, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { type LikeType, likeToVersia } from "~/classes/functions/like"; import { type LikeType, likeToVersia } from "~/classes/functions/like";
@ -10,6 +9,7 @@ import { Notes } from "~/drizzle/schema";
import { config } from "~/packages/config-manager"; import { config } from "~/packages/config-manager";
import { Note } from "~/packages/database-interface/note"; import { Note } from "~/packages/database-interface/note";
import { User } from "~/packages/database-interface/user"; import { User } from "~/packages/database-interface/user";
import type { KnownEntity } from "~/types/api";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -39,7 +39,7 @@ export default apiRoute((app) =>
let foundObject: Note | LikeType | null = null; let foundObject: Note | LikeType | null = null;
let foundAuthor: User | null = null; let foundAuthor: User | null = null;
let apiObject: Entity | null = null; let apiObject: KnownEntity | null = null;
foundObject = await Note.fromSql( foundObject = await Note.fromSql(
and( and(

View file

@ -7,8 +7,8 @@ import {
EntityValidator, EntityValidator,
RequestParserHandler, RequestParserHandler,
SignatureValidator, SignatureValidator,
} from "@lysand-org/federation"; } from "@versia/federation";
import type { Entity } from "@lysand-org/federation/types"; import type { Entity } from "@versia/federation/types";
import type { SocketAddress } from "bun"; import type { SocketAddress } from "bun";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { matches } from "ip-matching"; import { matches } from "ip-matching";
@ -39,8 +39,9 @@ export const schemas = {
uuid: z.string().uuid(), uuid: z.string().uuid(),
}), }),
header: z.object({ header: z.object({
signature: z.string(), "X-Signature": z.string(),
date: z.string(), "X-Nonce": z.string(),
"X-Signed-By": z.string().url().or(z.literal("instance")),
authorization: z.string().optional(), authorization: z.string().optional(),
}), }),
body: z.any(), body: z.any(),
@ -55,8 +56,12 @@ export default apiRoute((app) =>
zValidator("json", schemas.body, handleZodError), zValidator("json", schemas.body, handleZodError),
async (context) => { async (context) => {
const { uuid } = context.req.valid("param"); const { uuid } = context.req.valid("param");
const { signature, date, authorization } = const {
context.req.valid("header"); "X-Signature": signature,
"X-Nonce": nonce,
"X-Signed-By": signedBy,
authorization,
} = context.req.valid("header");
const logger = getLogger(["federation", "inbox"]); const logger = getLogger(["federation", "inbox"]);
const body: Entity = await context.req.valid("json"); const body: Entity = await context.req.valid("json");
@ -128,19 +133,24 @@ export default apiRoute((app) =>
} }
} }
const keyId = signature const sender = await User.resolve(signedBy);
.split("keyId=")[1]
.split(",")[0]
.replace(/"/g, "");
const sender = await User.resolve(keyId);
const origin = new URL(keyId).origin; if (sender?.isLocal()) {
return context.json(
{ error: "Cannot send federation requests to local users" },
400,
);
}
const hostname = new URL(sender?.data.instance?.baseUrl ?? "")
.hostname;
// Check if Origin is defederated // Check if Origin is defederated
if ( if (
config.federation.blocked.find( config.federation.blocked.find(
(blocked) => (blocked) =>
blocked.includes(origin) || origin.includes(blocked), blocked.includes(hostname) ||
hostname.includes(blocked),
) )
) { ) {
// Pretend to accept request // Pretend to accept request
@ -151,7 +161,7 @@ export default apiRoute((app) =>
if (checkSignature) { if (checkSignature) {
if (!sender) { if (!sender) {
return context.json( return context.json(
{ error: "Could not resolve keyId" }, { error: "Could not resolve sender" },
400, 400,
); );
} }
@ -165,23 +175,13 @@ export default apiRoute((app) =>
sender.data.publicKey, sender.data.publicKey,
); );
// If base_url uses https and request uses http, rewrite request to use https
// This fixes reverse proxy errors
const reqUrl = new URL(context.req.url);
if (
new URL(config.http.base_url).protocol === "https:" &&
reqUrl.protocol === "http:"
) {
reqUrl.protocol = "https:";
}
const isValid = await validator const isValid = await validator
.validate( .validate(
new Request(reqUrl, { new Request(context.req.url, {
method: context.req.method, method: context.req.method,
headers: { headers: {
Signature: signature, "X-Signature": signature,
Date: date, "X-Date": nonce,
}, },
body: await context.req.text(), body: await context.req.text(),
}), }),
@ -193,7 +193,7 @@ export default apiRoute((app) =>
}); });
if (!isValid) { if (!isValid) {
return context.json({ error: "Invalid signature" }, 400); return context.json({ error: "Invalid signature" }, 401);
} }
} }
@ -332,48 +332,53 @@ export default apiRoute((app) =>
return response("Follow request rejected", 200); return response("Follow request rejected", 200);
}, },
undo: async (undo) => { // "delete" is a reserved keyword in JS
delete: async (delete_) => {
// Delete the specified object from database, if it exists and belongs to the user // Delete the specified object from database, if it exists and belongs to the user
const toDelete = undo.object; const toDelete = delete_.target;
// Try and find a follow, note, or user with the given URI switch (delete_.deleted_type) {
// Note case "Note": {
const note = await Note.fromSql( const note = await Note.fromSql(
eq(Notes.uri, toDelete), eq(Notes.uri, toDelete),
eq(Notes.authorId, user.id), eq(Notes.authorId, user.id),
); );
if (note) { if (note) {
await note.delete(); await note.delete();
return response("Note deleted", 200); return response("Note deleted", 200);
} }
// Follow (unfollow/cancel follow request) break;
// TODO: Remember to store URIs of follow requests/objects in the future
// User
const otherUser = await User.resolve(toDelete);
if (otherUser) {
if (otherUser.id === user.id) {
// Delete own account
await user.delete();
return response("Account deleted", 200);
} }
return context.json( case "User": {
{ const otherUser = await User.resolve(toDelete);
error: "Cannot delete other users than self",
},
400,
);
}
return context.json( if (otherUser) {
{ if (otherUser.id === user.id) {
error: `Deletetion of object ${toDelete} not implemented`, // Delete own account
}, await user.delete();
400, return response("Account deleted", 200);
); }
return context.json(
{
error: "Cannot delete other users than self",
},
400,
);
}
break;
}
default: {
return context.json(
{
error: `Deletetion of object ${toDelete} not implemented`,
},
400,
);
}
}
}, },
user: async (user) => { user: async (user) => {
// Refetch user to ensure we have the latest data // Refetch user to ensure we have the latest data
@ -390,27 +395,6 @@ export default apiRoute((app) =>
return response("User refreshed", 200); return response("User refreshed", 200);
}, },
patch: async (patch) => {
// Update the specified note in the database, if it exists and belongs to the user
const toPatch = patch.patched_id;
const note = await Note.fromSql(
eq(Notes.uri, toPatch),
eq(Notes.authorId, user.id),
);
// Refetch note
if (!note) {
return context.json(
{ error: "Note not found" },
404,
);
}
await note.updateFromRemote();
return response("Note updated", 200);
},
}); });
if (result) { if (result) {

View file

@ -1,6 +1,6 @@
import { apiRoute, applyConfig } from "@/api"; import { apiRoute, applyConfig } from "@/api";
import { urlToContentFormat } from "@/content_types"; import { urlToContentFormat } from "@/content_types";
import type { ServerMetadata } from "@lysand-org/federation/types"; import type { InstanceMetadata } from "@versia/federation/types";
import pkg from "~/package.json"; import pkg from "~/package.json";
import { config } from "~/packages/config-manager"; import { config } from "~/packages/config-manager";
@ -19,14 +19,30 @@ export const meta = applyConfig({
export default apiRoute((app) => export default apiRoute((app) =>
app.on(meta.allowedMethods, meta.route, (context) => { app.on(meta.allowedMethods, meta.route, (context) => {
return context.json({ return context.json({
type: "ServerMetadata", type: "InstanceMetadata",
compatibility: {
extensions: ["pub.versia:custom_emojis"],
versions: ["0.3.1", "0.4.0"],
},
host: new URL(config.http.base_url).host,
name: config.instance.name, name: config.instance.name,
version: pkg.version, description: {
description: config.instance.description, "text/plain": {
logo: urlToContentFormat(config.instance.logo) ?? undefined, content: config.instance.description,
banner: urlToContentFormat(config.instance.banner) ?? undefined, remote: false,
supported_extensions: ["org.lysand:custom_emojis"], },
website: "https://versia.pub", },
} satisfies ServerMetadata); public_key: {
key: config.instance.keys.public,
algorithm: "ed25519",
},
software: {
name: "Versia Server",
version: pkg.version,
},
banner: urlToContentFormat(config.instance.banner),
logo: urlToContentFormat(config.instance.logo),
created_at: "2021-10-01T00:00:00Z",
} satisfies InstanceMetadata);
}), }),
); );

View file

@ -7,7 +7,7 @@ import {
} from "@/api"; } from "@/api";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { getLogger } from "@logtape/logtape"; import { getLogger } from "@logtape/logtape";
import type { ResponseError } from "@lysand-org/federation"; import type { ResponseError } from "@versia/federation";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import { lookup } from "mime-types"; import { lookup } from "mime-types";
import { z } from "zod"; import { z } from "zod";

View file

@ -1,5 +1,16 @@
import type { Hono } from "@hono/hono"; import type { Hono } from "@hono/hono";
import type { RouterRoute } from "@hono/hono/types"; import type { RouterRoute } from "@hono/hono/types";
import type {
Delete,
Follow,
FollowAccept,
FollowReject,
InstanceMetadata,
LikeExtension,
Note,
Unfollow,
User,
} from "@versia/federation/types";
import type { z } from "zod"; import type { z } from "zod";
import type { RolePermissions } from "~/drizzle/schema"; import type { RolePermissions } from "~/drizzle/schema";
@ -40,3 +51,14 @@ export interface ApiRouteExports {
}; };
default: (app: Hono) => RouterRoute; default: (app: Hono) => RouterRoute;
} }
export type KnownEntity =
| Note
| InstanceMetadata
| User
| Follow
| FollowAccept
| FollowReject
| Unfollow
| Delete
| LikeExtension;

View file

@ -1,4 +1,4 @@
import type { ContentFormat } from "@lysand-org/federation/types"; import type { ContentFormat } from "@versia/federation/types";
import { lookup } from "mime-types"; import { lookup } from "mime-types";
import { config } from "~/packages/config-manager"; import { config } from "~/packages/config-manager";
@ -31,6 +31,7 @@ export const urlToContentFormat = (url?: string): ContentFormat | null => {
return { return {
"image/svg+xml": { "image/svg+xml": {
content: url, content: url,
remote: true,
}, },
}; };
} }
@ -41,6 +42,7 @@ export const urlToContentFormat = (url?: string): ContentFormat | null => {
return { return {
[mimeType]: { [mimeType]: {
content: url, content: url,
remote: true,
}, },
}; };
}; };