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

View file

@ -3,7 +3,7 @@ import type {
AsyncAttachment as ApiAsyncAttachment,
Attachment as ApiAttachment,
} from "@lysand-org/client/types";
import type { ContentFormat } from "@lysand-org/federation/types";
import type { ContentFormat } from "@versia/federation/types";
import {
type InferInsertModel,
type InferSelectModel,
@ -201,7 +201,9 @@ export class Attachment extends BaseInterface<typeof Attachments> {
return {
[this.data.mimeType]: {
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,
duration: this.data.duration ?? undefined,
fps: this.data.fps ?? undefined,
@ -233,7 +235,7 @@ export class Attachment extends BaseInterface<typeof Attachments> {
size: value.size || undefined,
width: value.width || 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 { proxyUrl } from "@/response";
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 {
type InferInsertModel,
type InferSelectModel,
@ -196,6 +196,7 @@ export class Emoji extends BaseInterface<typeof Emojis, EmojiWithInstance> {
[this.data.contentType]: {
content: this.data.url,
description: this.data.alt || undefined,
remote: true,
},
},
};

View file

@ -3,8 +3,8 @@ import {
EntityValidator,
type ResponseError,
type ValidationError,
} from "@lysand-org/federation";
import type { ServerMetadata } from "@lysand-org/federation/types";
} from "@versia/federation";
import type { InstanceMetadata } from "@versia/federation/types";
import chalk from "chalk";
import {
type InferInsertModel,
@ -132,7 +132,7 @@ export class Instance extends BaseInterface<typeof Instances> {
}
static async fetchMetadata(url: string): Promise<{
metadata: ServerMetadata;
metadata: InstanceMetadata;
protocol: "versia" | "activitypub";
} | null> {
const origin = new URL(url).origin;
@ -167,7 +167,7 @@ export class Instance extends BaseInterface<typeof Instances> {
}
try {
const metadata = await new EntityValidator().ServerMetadata(
const metadata = await new EntityValidator().InstanceMetadata(
data,
);
@ -188,7 +188,7 @@ export class Instance extends BaseInterface<typeof Instances> {
private static async fetchActivityPubMetadata(
url: string,
): Promise<ServerMetadata | null> {
): Promise<InstanceMetadata | null> {
const origin = new URL(url).origin;
const wellKnownUrl = new URL("/.well-known/nodeinfo", origin);
@ -285,14 +285,30 @@ export class Instance extends BaseInterface<typeof Instances> {
return {
name:
metadata.metadata.nodeName || metadata.metadata.title || "",
version: metadata.software.version,
description:
metadata.metadata.nodeDescription ||
metadata.metadata.description ||
"",
logo: undefined,
type: "ServerMetadata",
supported_extensions: [],
description: {
"text/plain": {
content:
metadata.metadata.nodeDescription ||
metadata.metadata.description ||
"",
remote: false,
},
},
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) {
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({
baseUrl: host,
name: metadata.name,
version: metadata.version,
version: metadata.software.version,
logo: metadata.logo,
protocol: protocol,
});

View file

@ -8,11 +8,12 @@ import type {
Attachment as ApiAttachment,
Status as ApiStatus,
} from "@lysand-org/client/types";
import { EntityValidator } from "@lysand-org/federation";
import { EntityValidator } from "@versia/federation";
import type {
ContentFormat,
Delete as VersiaDelete,
Note as VersiaNote,
} from "@lysand-org/federation/types";
} from "@versia/federation/types";
import {
type InferInsertModel,
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 = {
author,
content: note.content ?? {
"text/plain": {
content: "",
remote: false,
},
},
visibility: note.visibility as ApiStatus["visibility"],
visibility: visibility as ApiStatus["visibility"],
isSensitive: note.is_sensitive ?? false,
spoilerText: note.subject ?? "",
emojis,
@ -885,6 +898,19 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
).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
* @returns The note in the Versia format
@ -900,9 +926,11 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
content: {
"text/html": {
content: status.content,
remote: false,
},
"text/plain": {
content: htmlToText(status.content),
remote: false,
},
},
attachments: (status.attachments ?? []).map((attachment) =>
@ -917,11 +945,8 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
replies_to:
Note.getUri(status.replyId, status.reply?.uri) ?? undefined,
subject: status.spoilerText,
visibility: status.visibility as
| "public"
| "unlisted"
| "private"
| "direct",
// TODO: Refactor as part of groups
group: status.visibility === "public" ? "public" : "followers",
extensions: {
"org.lysand:custom_emojis": {
emojis: status.emojis.map((emoji) =>

View file

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