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 { config } from "~/packages/config-manager/index";
import type { Unfollow } from "@versia/federation/types";
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();
return {
type: "Undo",
type: "Unfollow",
id,
author: undoer.getUri(),
author: unfollower.getUri(),
created_at: new Date().toISOString(),
object: uri,
uri: new URL(`/undos/${id}`, config.http.base_url).toString(),
followee: unfollowing.getUri(),
};
};

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 { db } from "~/drizzle/db";
import { Likes, Notifications } from "~/drizzle/schema";
@ -11,15 +11,15 @@ export type LikeType = InferSelectModel<typeof Likes>;
/**
* Represents a Like entity in the database.
*/
export const likeToVersia = (like: LikeType): Like => {
export const likeToVersia = (like: LikeType): LikeExtension => {
return {
id: like.id,
// biome-ignore lint/suspicious/noExplicitAny: to be rewritten
author: (like as any).liker?.uri,
type: "Like",
type: "pub.versia:likes/Like",
created_at: new Date(like.createdAt).toISOString(),
// 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(),
};
};

View file

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

View file

@ -2,7 +2,7 @@ import type {
Follow,
FollowAccept,
FollowReject,
} from "@lysand-org/federation/types";
} from "@versia/federation/types";
import { type InferSelectModel, eq, sql } from "drizzle-orm";
import { db } from "~/drizzle/db";
import {
@ -12,7 +12,6 @@ import {
Tokens,
type Users,
} from "~/drizzle/schema";
import { config } from "~/packages/config-manager/index";
import type { EmojiWithInstance } from "~/packages/database-interface/emoji";
import { User } from "~/packages/database-interface/user";
import type { Application } from "./application";
@ -282,7 +281,6 @@ export const followRequestToVersia = (
author: follower.getUri(),
followee: followee.getUri(),
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(),
created_at: new Date().toISOString(),
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 { ContentFormat } from "@lysand-org/federation/types";
import type { ContentFormat } from "@versia/federation/types";
import type { Challenge } from "altcha-lib/types";
import { relations, sql } from "drizzle-orm";
import {
@ -381,9 +381,9 @@ export const Users = pgTable(
}[]
>(),
endpoints: jsonb("endpoints").$type<Partial<{
dislikes: string;
dislikes?: string;
featured: string;
likes: string;
likes?: string;
followers: string;
following: string;
inbox: string;

View file

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

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": {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,8 +7,8 @@ import {
EntityValidator,
RequestParserHandler,
SignatureValidator,
} from "@lysand-org/federation";
import type { Entity } from "@lysand-org/federation/types";
} from "@versia/federation";
import type { Entity } from "@versia/federation/types";
import type { SocketAddress } from "bun";
import { eq } from "drizzle-orm";
import { matches } from "ip-matching";
@ -39,8 +39,9 @@ export const schemas = {
uuid: z.string().uuid(),
}),
header: z.object({
signature: z.string(),
date: z.string(),
"X-Signature": z.string(),
"X-Nonce": z.string(),
"X-Signed-By": z.string().url().or(z.literal("instance")),
authorization: z.string().optional(),
}),
body: z.any(),
@ -55,8 +56,12 @@ export default apiRoute((app) =>
zValidator("json", schemas.body, handleZodError),
async (context) => {
const { uuid } = context.req.valid("param");
const { signature, date, authorization } =
context.req.valid("header");
const {
"X-Signature": signature,
"X-Nonce": nonce,
"X-Signed-By": signedBy,
authorization,
} = context.req.valid("header");
const logger = getLogger(["federation", "inbox"]);
const body: Entity = await context.req.valid("json");
@ -128,19 +133,24 @@ export default apiRoute((app) =>
}
}
const keyId = signature
.split("keyId=")[1]
.split(",")[0]
.replace(/"/g, "");
const sender = await User.resolve(keyId);
const sender = await User.resolve(signedBy);
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
if (
config.federation.blocked.find(
(blocked) =>
blocked.includes(origin) || origin.includes(blocked),
blocked.includes(hostname) ||
hostname.includes(blocked),
)
) {
// Pretend to accept request
@ -151,7 +161,7 @@ export default apiRoute((app) =>
if (checkSignature) {
if (!sender) {
return context.json(
{ error: "Could not resolve keyId" },
{ error: "Could not resolve sender" },
400,
);
}
@ -165,23 +175,13 @@ export default apiRoute((app) =>
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
.validate(
new Request(reqUrl, {
new Request(context.req.url, {
method: context.req.method,
headers: {
Signature: signature,
Date: date,
"X-Signature": signature,
"X-Date": nonce,
},
body: await context.req.text(),
}),
@ -193,7 +193,7 @@ export default apiRoute((app) =>
});
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);
},
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
const toDelete = undo.object;
const toDelete = delete_.target;
// Try and find a follow, note, or user with the given URI
// Note
const note = await Note.fromSql(
eq(Notes.uri, toDelete),
eq(Notes.authorId, user.id),
);
switch (delete_.deleted_type) {
case "Note": {
const note = await Note.fromSql(
eq(Notes.uri, toDelete),
eq(Notes.authorId, user.id),
);
if (note) {
await note.delete();
return response("Note deleted", 200);
}
if (note) {
await note.delete();
return response("Note deleted", 200);
}
// Follow (unfollow/cancel follow request)
// 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);
break;
}
return context.json(
{
error: "Cannot delete other users than self",
},
400,
);
}
case "User": {
const otherUser = await User.resolve(toDelete);
return context.json(
{
error: `Deletetion of object ${toDelete} not implemented`,
},
400,
);
if (otherUser) {
if (otherUser.id === user.id) {
// Delete own account
await user.delete();
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) => {
// Refetch user to ensure we have the latest data
@ -390,27 +395,6 @@ export default apiRoute((app) =>
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) {

View file

@ -1,6 +1,6 @@
import { apiRoute, applyConfig } from "@/api";
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 { config } from "~/packages/config-manager";
@ -19,14 +19,30 @@ export const meta = applyConfig({
export default apiRoute((app) =>
app.on(meta.allowedMethods, meta.route, (context) => {
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,
version: pkg.version,
description: config.instance.description,
logo: urlToContentFormat(config.instance.logo) ?? undefined,
banner: urlToContentFormat(config.instance.banner) ?? undefined,
supported_extensions: ["org.lysand:custom_emojis"],
website: "https://versia.pub",
} satisfies ServerMetadata);
description: {
"text/plain": {
content: config.instance.description,
remote: false,
},
},
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";
import { zValidator } from "@hono/zod-validator";
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 { lookup } from "mime-types";
import { z } from "zod";

View file

@ -1,5 +1,16 @@
import type { Hono } from "@hono/hono";
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 { RolePermissions } from "~/drizzle/schema";
@ -40,3 +51,14 @@ export interface ApiRouteExports {
};
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 { config } from "~/packages/config-manager";
@ -31,6 +31,7 @@ export const urlToContentFormat = (url?: string): ContentFormat | null => {
return {
"image/svg+xml": {
content: url,
remote: true,
},
};
}
@ -41,6 +42,7 @@ export const urlToContentFormat = (url?: string): ContentFormat | null => {
return {
[mimeType]: {
content: url,
remote: true,
},
};
};