mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
chore(federation): 👽 Initial Versia Working Draft 4.0 support
This commit is contained in:
parent
9c71c3fe51
commit
c3fa867e74
|
|
@ -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(),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ export default apiRoute((app) =>
|
|||
content: {
|
||||
[content_type]: {
|
||||
content: status ?? "",
|
||||
remote: false,
|
||||
},
|
||||
},
|
||||
visibility,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
22
types/api.ts
22
types/api.ts
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue