refactor: 🚚 Organize code into sub-packages, instead of a single large package

This commit is contained in:
Jesse Wierzbinski 2025-06-15 04:38:20 +02:00
parent 79742f47dc
commit a6d3ebbeef
No known key found for this signature in database
366 changed files with 942 additions and 833 deletions

View file

@ -0,0 +1,164 @@
import type {
Application as ApplicationSchema,
CredentialApplication,
} from "@versia/client/schemas";
import { db, Token } from "@versia/kit/db";
import { Applications } from "@versia/kit/tables";
import {
desc,
eq,
type InferInsertModel,
type InferSelectModel,
inArray,
type SQL,
} from "drizzle-orm";
import type { z } from "zod";
import { BaseInterface } from "./base.ts";
type ApplicationType = InferSelectModel<typeof Applications>;
export class Application extends BaseInterface<typeof Applications> {
public static $type: ApplicationType;
public async reload(): Promise<void> {
const reloaded = await Application.fromId(this.data.id);
if (!reloaded) {
throw new Error("Failed to reload application");
}
this.data = reloaded.data;
}
public static async fromId(id: string | null): Promise<Application | null> {
if (!id) {
return null;
}
return await Application.fromSql(eq(Applications.id, id));
}
public static async fromIds(ids: string[]): Promise<Application[]> {
return await Application.manyFromSql(inArray(Applications.id, ids));
}
public static async fromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Applications.id),
): Promise<Application | null> {
const found = await db.query.Applications.findFirst({
where: sql,
orderBy,
});
if (!found) {
return null;
}
return new Application(found);
}
public static async manyFromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Applications.id),
limit?: number,
offset?: number,
extra?: Parameters<typeof db.query.Applications.findMany>[0],
): Promise<Application[]> {
const found = await db.query.Applications.findMany({
where: sql,
orderBy,
limit,
offset,
with: extra?.with,
});
return found.map((s) => new Application(s));
}
public static async getFromToken(
token: string,
): Promise<Application | null> {
const result = await Token.fromAccessToken(token);
return result?.data.application
? new Application(result.data.application)
: null;
}
public static fromClientId(clientId: string): Promise<Application | null> {
return Application.fromSql(eq(Applications.clientId, clientId));
}
public async update(
newApplication: Partial<ApplicationType>,
): Promise<ApplicationType> {
await db
.update(Applications)
.set(newApplication)
.where(eq(Applications.id, this.id));
const updated = await Application.fromId(this.data.id);
if (!updated) {
throw new Error("Failed to update application");
}
this.data = updated.data;
return updated.data;
}
public save(): Promise<ApplicationType> {
return this.update(this.data);
}
public async delete(ids?: string[]): Promise<void> {
if (Array.isArray(ids)) {
await db.delete(Applications).where(inArray(Applications.id, ids));
} else {
await db.delete(Applications).where(eq(Applications.id, this.id));
}
}
public static async insert(
data: InferInsertModel<typeof Applications>,
): Promise<Application> {
const inserted = (
await db.insert(Applications).values(data).returning()
)[0];
const application = await Application.fromId(inserted.id);
if (!application) {
throw new Error("Failed to insert application");
}
return application;
}
public get id(): string {
return this.data.id;
}
public toApi(): z.infer<typeof ApplicationSchema> {
return {
name: this.data.name,
website: this.data.website,
scopes: this.data.scopes.split(" "),
redirect_uri: this.data.redirectUri,
redirect_uris: this.data.redirectUri.split("\n"),
};
}
public toApiCredential(): z.infer<typeof CredentialApplication> {
return {
name: this.data.name,
website: this.data.website,
client_id: this.data.clientId,
client_secret: this.data.secret,
client_secret_expires_at: "0",
scopes: this.data.scopes.split(" "),
redirect_uri: this.data.redirectUri,
redirect_uris: this.data.redirectUri.split("\n"),
};
}
}

View file

@ -0,0 +1,54 @@
import type { InferModelFromColumns, InferSelectModel } from "drizzle-orm";
import type { PgTableWithColumns } from "drizzle-orm/pg-core";
/**
* BaseInterface is an abstract class that provides a common interface for all models.
* It includes methods for saving, deleting, updating, and reloading data.
*
* @template Table - The type of the table with columns.
* @template Columns - The type of the columns inferred from the table.
*/
export abstract class BaseInterface<
// biome-ignore lint/suspicious/noExplicitAny: This is just an extended interface
Table extends PgTableWithColumns<any>,
Columns = InferModelFromColumns<Table["_"]["columns"]>,
> {
/**
* Constructs a new instance of the BaseInterface.
*
* @param data - The data for the model.
*/
public constructor(public data: Columns) {}
/**
* Saves the current state of the model to the database.
*
* @returns A promise that resolves with the saved model.
*/
public abstract save(): Promise<Columns>;
/**
* Deletes the model from the database.
*
* @param ids - The ids of the models to delete. If not provided, the current model will be deleted.
* @returns A promise that resolves when the deletion is complete.
*/
public abstract delete(ids?: string[]): Promise<void>;
/**
* Updates the model with new data.
*
* @param newData - The new data for the model.
* @returns A promise that resolves with the updated model.
*/
public abstract update(
newData: Partial<InferSelectModel<Table>>,
): Promise<Columns>;
/**
* Reloads the model from the database.
*
* @returns A promise that resolves when the reloading is complete.
*/
public abstract reload(): Promise<void>;
}

View file

@ -0,0 +1,238 @@
import {
type CustomEmoji,
emojiWithColonsRegex,
emojiWithIdentifiersRegex,
} from "@versia/client/schemas";
import { db, type Instance, Media } from "@versia/kit/db";
import { Emojis, type Instances, type Medias } from "@versia/kit/tables";
import * as VersiaEntities from "@versia/sdk/entities";
import type { ImageContentFormatSchema } from "@versia/sdk/schemas";
import { randomUUIDv7 } from "bun";
import {
and,
desc,
eq,
type InferInsertModel,
type InferSelectModel,
inArray,
isNull,
type SQL,
} from "drizzle-orm";
import type { z } from "zod";
import { BaseInterface } from "./base.ts";
type EmojiType = InferSelectModel<typeof Emojis> & {
media: InferSelectModel<typeof Medias>;
instance: InferSelectModel<typeof Instances> | null;
};
export class Emoji extends BaseInterface<typeof Emojis, EmojiType> {
public static $type: EmojiType;
public media: Media;
public constructor(data: EmojiType) {
super(data);
this.media = new Media(data.media);
}
public async reload(): Promise<void> {
const reloaded = await Emoji.fromId(this.data.id);
if (!reloaded) {
throw new Error("Failed to reload emoji");
}
this.data = reloaded.data;
}
public static async fromId(id: string | null): Promise<Emoji | null> {
if (!id) {
return null;
}
return await Emoji.fromSql(eq(Emojis.id, id));
}
public static async fromIds(ids: string[]): Promise<Emoji[]> {
return await Emoji.manyFromSql(inArray(Emojis.id, ids));
}
public static async fromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Emojis.id),
): Promise<Emoji | null> {
const found = await db.query.Emojis.findFirst({
where: sql,
orderBy,
with: {
instance: true,
media: true,
},
});
if (!found) {
return null;
}
return new Emoji(found);
}
public static async manyFromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Emojis.id),
limit?: number,
offset?: number,
extra?: Parameters<typeof db.query.Emojis.findMany>[0],
): Promise<Emoji[]> {
const found = await db.query.Emojis.findMany({
where: sql,
orderBy,
limit,
offset,
with: { ...extra?.with, instance: true, media: true },
});
return found.map((s) => new Emoji(s));
}
public async update(newEmoji: Partial<EmojiType>): Promise<EmojiType> {
await db.update(Emojis).set(newEmoji).where(eq(Emojis.id, this.id));
const updated = await Emoji.fromId(this.data.id);
if (!updated) {
throw new Error("Failed to update emoji");
}
this.data = updated.data;
return updated.data;
}
public save(): Promise<EmojiType> {
return this.update(this.data);
}
public async delete(ids?: string[]): Promise<void> {
if (Array.isArray(ids)) {
await db.delete(Emojis).where(inArray(Emojis.id, ids));
} else {
await db.delete(Emojis).where(eq(Emojis.id, this.id));
}
}
public static async insert(
data: InferInsertModel<typeof Emojis>,
): Promise<Emoji> {
const inserted = (await db.insert(Emojis).values(data).returning())[0];
const emoji = await Emoji.fromId(inserted.id);
if (!emoji) {
throw new Error("Failed to insert emoji");
}
return emoji;
}
public static async fetchFromRemote(
emojiToFetch: {
name: string;
url: z.infer<typeof ImageContentFormatSchema>;
},
instance: Instance,
): Promise<Emoji> {
const existingEmoji = await Emoji.fromSql(
and(
eq(Emojis.shortcode, emojiToFetch.name),
eq(Emojis.instanceId, instance.id),
),
);
if (existingEmoji) {
return existingEmoji;
}
return await Emoji.fromVersia(emojiToFetch, instance);
}
public get id(): string {
return this.data.id;
}
/**
* Parse emojis from text
*
* @param text The text to parse
* @returns An array of emojis
*/
public static parseFromText(text: string): Promise<Emoji[]> {
const matches = text.match(emojiWithColonsRegex);
if (!matches || matches.length === 0) {
return Promise.resolve([]);
}
return Emoji.manyFromSql(
and(
inArray(
Emojis.shortcode,
matches.map((match) => match.replace(/:/g, "")),
),
isNull(Emojis.instanceId),
),
);
}
public toApi(): z.infer<typeof CustomEmoji> {
return {
id: this.id,
shortcode: this.data.shortcode,
static_url: this.media.getUrl().proxied,
url: this.media.getUrl().proxied,
visible_in_picker: this.data.visibleInPicker,
category: this.data.category,
global: this.data.ownerId === null,
description:
this.media.data.content[this.media.getPreferredMimeType()]
.description ?? null,
};
}
public toVersia(): {
name: string;
url: z.infer<typeof ImageContentFormatSchema>;
} {
return {
name: `:${this.data.shortcode}:`,
url: this.media.toVersia().data as z.infer<
typeof ImageContentFormatSchema
>,
};
}
public static async fromVersia(
emoji: {
name: string;
url: z.infer<typeof ImageContentFormatSchema>;
},
instance: Instance,
): Promise<Emoji> {
// Extracts the shortcode from the emoji name (e.g. :shortcode: -> shortcode)
const shortcode = [...emoji.name.matchAll(emojiWithIdentifiersRegex)][0]
.groups.shortcode;
if (!shortcode) {
throw new Error("Could not extract shortcode from emoji name");
}
const media = await Media.fromVersia(
new VersiaEntities.ImageContentFormat(emoji.url),
);
return Emoji.insert({
id: randomUUIDv7(),
shortcode,
mediaId: media.id,
visibleInPicker: true,
instanceId: instance.id,
});
}
}

View file

@ -0,0 +1,15 @@
export { db, setupDatabase } from "../tables/db.ts";
export { Application } from "./application.ts";
export { Emoji } from "./emoji.ts";
export { Instance } from "./instance.ts";
export { Like } from "./like.ts";
export { Media } from "./media.ts";
export { Note } from "./note.ts";
export { Notification } from "./notification.ts";
export { PushSubscription } from "./pushsubscription.ts";
export { Reaction } from "./reaction.ts";
export { Relationship } from "./relationship.ts";
export { Role } from "./role.ts";
export { Timeline } from "./timeline.ts";
export { Token } from "./token.ts";
export { User } from "./user.ts";

View file

@ -0,0 +1,372 @@
import { getLogger } from "@logtape/logtape";
import { ApiError } from "@versia/kit";
import { db } from "@versia/kit/db";
import { Instances } from "@versia/kit/tables";
import * as VersiaEntities from "@versia/sdk/entities";
import { config } from "@versia-server/config";
import { randomUUIDv7 } from "bun";
import chalk from "chalk";
import {
desc,
eq,
type InferInsertModel,
type InferSelectModel,
inArray,
type SQL,
} from "drizzle-orm";
import { BaseInterface } from "./base.ts";
import { User } from "./user.ts";
type InstanceType = InferSelectModel<typeof Instances>;
export class Instance extends BaseInterface<typeof Instances> {
public static $type: InstanceType;
public async reload(): Promise<void> {
const reloaded = await Instance.fromId(this.data.id);
if (!reloaded) {
throw new Error("Failed to reload instance");
}
this.data = reloaded.data;
}
public static async fromId(id: string | null): Promise<Instance | null> {
if (!id) {
return null;
}
return await Instance.fromSql(eq(Instances.id, id));
}
public static async fromIds(ids: string[]): Promise<Instance[]> {
return await Instance.manyFromSql(inArray(Instances.id, ids));
}
public static async fromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Instances.id),
): Promise<Instance | null> {
const found = await db.query.Instances.findFirst({
where: sql,
orderBy,
});
if (!found) {
return null;
}
return new Instance(found);
}
public static async manyFromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Instances.id),
limit?: number,
offset?: number,
extra?: Parameters<typeof db.query.Instances.findMany>[0],
): Promise<Instance[]> {
const found = await db.query.Instances.findMany({
where: sql,
orderBy,
limit,
offset,
with: extra?.with,
});
return found.map((s) => new Instance(s));
}
public async update(
newInstance: Partial<InstanceType>,
): Promise<InstanceType> {
await db
.update(Instances)
.set(newInstance)
.where(eq(Instances.id, this.id));
const updated = await Instance.fromId(this.data.id);
if (!updated) {
throw new Error("Failed to update instance");
}
this.data = updated.data;
return updated.data;
}
public save(): Promise<InstanceType> {
return this.update(this.data);
}
public async delete(ids?: string[]): Promise<void> {
if (Array.isArray(ids)) {
await db.delete(Instances).where(inArray(Instances.id, ids));
} else {
await db.delete(Instances).where(eq(Instances.id, this.id));
}
}
public static async fromUser(user: User): Promise<Instance | null> {
if (!user.data.instanceId) {
return null;
}
return await Instance.fromId(user.data.instanceId);
}
public static async insert(
data: InferInsertModel<typeof Instances>,
): Promise<Instance> {
const inserted = (
await db.insert(Instances).values(data).returning()
)[0];
const instance = await Instance.fromId(inserted.id);
if (!instance) {
throw new Error("Failed to insert instance");
}
return instance;
}
public get id(): string {
return this.data.id;
}
public static async fetchMetadata(url: URL): Promise<{
metadata: VersiaEntities.InstanceMetadata;
protocol: "versia" | "activitypub";
}> {
const origin = new URL(url).origin;
const wellKnownUrl = new URL("/.well-known/versia", origin);
try {
const metadata = await User.federationRequester.fetchEntity(
wellKnownUrl,
VersiaEntities.InstanceMetadata,
);
return { metadata, protocol: "versia" };
} catch {
// If the server doesn't have a Versia well-known endpoint, it's not a Versia instance
// Try to resolve ActivityPub metadata instead
const data = await Instance.fetchActivityPubMetadata(url);
if (!data) {
throw new ApiError(
404,
`Instance at ${origin} is not reachable or does not exist`,
);
}
return {
metadata: data,
protocol: "activitypub",
};
}
}
private static async fetchActivityPubMetadata(
url: URL,
): Promise<VersiaEntities.InstanceMetadata | null> {
const origin = new URL(url).origin;
const wellKnownUrl = new URL("/.well-known/nodeinfo", origin);
// Go to endpoint, then follow the links to the actual metadata
const logger = getLogger(["federation", "resolvers"]);
try {
const { json, ok, status } = await fetch(wellKnownUrl, {
// @ts-expect-error Bun extension
proxy: config.http.proxy_address,
});
if (!ok) {
logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
origin,
)} - HTTP ${status}`;
return null;
}
const wellKnown = (await json()) as {
links: { rel: string; href: string }[];
};
if (!wellKnown.links) {
logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
origin,
)} - No links found`;
return null;
}
const metadataUrl = wellKnown.links.find(
(link: { rel: string }) =>
link.rel ===
"http://nodeinfo.diaspora.software/ns/schema/2.0",
);
if (!metadataUrl) {
logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
origin,
)} - No metadata URL found`;
return null;
}
const {
json: json2,
ok: ok2,
status: status2,
} = await fetch(metadataUrl.href, {
// @ts-expect-error Bun extension
proxy: config.http.proxy_address,
});
if (!ok2) {
logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
origin,
)} - HTTP ${status2}`;
return null;
}
const metadata = (await json2()) as {
metadata: {
nodeName?: string;
title?: string;
nodeDescription?: string;
description?: string;
};
software: { version: string };
};
return new VersiaEntities.InstanceMetadata({
name:
metadata.metadata.nodeName || metadata.metadata.title || "",
description:
metadata.metadata.nodeDescription ||
metadata.metadata.description,
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(
origin,
)} - Error! ${error}`;
return null;
}
}
public static resolveFromHost(host: string): Promise<Instance> {
if (host.startsWith("http")) {
const url = new URL(host);
return Instance.resolve(url);
}
const url = new URL(`https://${host}`);
return Instance.resolve(url);
}
public static async resolve(url: URL): Promise<Instance> {
const host = url.host;
const existingInstance = await Instance.fromSql(
eq(Instances.baseUrl, host),
);
if (existingInstance) {
return existingInstance;
}
const output = await Instance.fetchMetadata(url);
const { metadata, protocol } = output;
return Instance.insert({
id: randomUUIDv7(),
baseUrl: host,
name: metadata.data.name,
version: metadata.data.software.version,
logo: metadata.data.logo,
protocol,
publicKey: metadata.data.public_key,
inbox: metadata.data.shared_inbox ?? null,
extensions: metadata.data.extensions ?? null,
});
}
public async updateFromRemote(): Promise<Instance> {
const logger = getLogger(["federation", "resolvers"]);
const output = await Instance.fetchMetadata(
new URL(`https://${this.data.baseUrl}`),
);
if (!output) {
logger.error`Failed to update instance ${chalk.bold(
this.data.baseUrl,
)}`;
throw new Error("Failed to update instance");
}
const { metadata, protocol } = output;
await this.update({
name: metadata.data.name,
version: metadata.data.software.version,
logo: metadata.data.logo,
protocol,
publicKey: metadata.data.public_key,
inbox: metadata.data.shared_inbox ?? null,
extensions: metadata.data.extensions ?? null,
});
return this;
}
public async sendMessage(content: string): Promise<void> {
const logger = getLogger(["federation", "messaging"]);
if (
!this.data.extensions?.["pub.versia:instance_messaging"]?.endpoint
) {
logger.info`Instance ${chalk.gray(
this.data.baseUrl,
)} does not support Instance Messaging, skipping message`;
return;
}
const endpoint = new URL(
this.data.extensions["pub.versia:instance_messaging"].endpoint,
);
await fetch(endpoint.href, {
method: "POST",
headers: {
"Content-Type": "text/plain",
},
body: content,
});
}
public static getCount(): Promise<number> {
return db.$count(Instances);
}
}

View file

@ -0,0 +1,182 @@
import { db } from "@versia/kit/db";
import {
Likes,
type Notes,
Notifications,
type Users,
} from "@versia/kit/tables";
import * as VersiaEntities from "@versia/sdk/entities";
import { config } from "@versia-server/config";
import {
and,
desc,
eq,
type InferInsertModel,
type InferSelectModel,
inArray,
type SQL,
} from "drizzle-orm";
import { BaseInterface } from "./base.ts";
import { User } from "./user.ts";
type LikeType = InferSelectModel<typeof Likes> & {
liker: InferSelectModel<typeof Users>;
liked: InferSelectModel<typeof Notes>;
};
export class Like extends BaseInterface<typeof Likes, LikeType> {
public static $type: LikeType;
public async reload(): Promise<void> {
const reloaded = await Like.fromId(this.data.id);
if (!reloaded) {
throw new Error("Failed to reload like");
}
this.data = reloaded.data;
}
public static async fromId(id: string | null): Promise<Like | null> {
if (!id) {
return null;
}
return await Like.fromSql(eq(Likes.id, id));
}
public static async fromIds(ids: string[]): Promise<Like[]> {
return await Like.manyFromSql(inArray(Likes.id, ids));
}
public static async fromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Likes.id),
): Promise<Like | null> {
const found = await db.query.Likes.findFirst({
where: sql,
orderBy,
with: {
liked: true,
liker: true,
},
});
if (!found) {
return null;
}
return new Like(found);
}
public static async manyFromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Likes.id),
limit?: number,
offset?: number,
extra?: Parameters<typeof db.query.Likes.findMany>[0],
): Promise<Like[]> {
const found = await db.query.Likes.findMany({
where: sql,
orderBy,
limit,
offset,
with: {
liked: true,
liker: true,
...extra?.with,
},
});
return found.map((s) => new Like(s));
}
public async update(newRole: Partial<LikeType>): Promise<LikeType> {
await db.update(Likes).set(newRole).where(eq(Likes.id, this.id));
const updated = await Like.fromId(this.data.id);
if (!updated) {
throw new Error("Failed to update like");
}
return updated.data;
}
public save(): Promise<LikeType> {
return this.update(this.data);
}
public async delete(): Promise<void> {
await db.delete(Likes).where(eq(Likes.id, this.id));
}
public static async insert(
data: InferInsertModel<typeof Likes>,
): Promise<Like> {
const inserted = (await db.insert(Likes).values(data).returning())[0];
const like = await Like.fromId(inserted.id);
if (!like) {
throw new Error("Failed to insert like");
}
return like;
}
public get id(): string {
return this.data.id;
}
public async clearRelatedNotifications(): Promise<void> {
await db
.delete(Notifications)
.where(
and(
eq(Notifications.accountId, this.id),
eq(Notifications.type, "favourite"),
eq(Notifications.notifiedId, this.data.liked.authorId),
eq(Notifications.noteId, this.data.liked.id),
),
);
}
public getUri(): URL {
return new URL(`/likes/${this.data.id}`, config.http.base_url);
}
public toVersia(): VersiaEntities.Like {
return new VersiaEntities.Like({
id: this.data.id,
author: User.getUri(
this.data.liker.id,
this.data.liker.uri ? new URL(this.data.liker.uri) : null,
).href,
type: "pub.versia:likes/Like",
created_at: new Date(this.data.createdAt).toISOString(),
liked: this.data.liked.uri
? new URL(this.data.liked.uri).href
: new URL(`/notes/${this.data.liked.id}`, config.http.base_url)
.href,
uri: this.getUri().href,
});
}
public unlikeToVersia(unliker?: User): VersiaEntities.Delete {
return new VersiaEntities.Delete({
type: "Delete",
id: crypto.randomUUID(),
created_at: new Date().toISOString(),
author: User.getUri(
unliker?.id ?? this.data.liker.id,
unliker?.data.uri
? new URL(unliker.data.uri)
: this.data.liker.uri
? new URL(this.data.liker.uri)
: null,
).href,
deleted_type: "pub.versia:likes/Like",
deleted: this.getUri().href,
});
}
}

View file

@ -0,0 +1,555 @@
import { join } from "node:path";
import type { Attachment as AttachmentSchema } from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { db } from "@versia/kit/db";
import { Medias } from "@versia/kit/tables";
import * as VersiaEntities from "@versia/sdk/entities";
import type {
ContentFormatSchema,
ImageContentFormatSchema,
} from "@versia/sdk/schemas";
import { config, ProxiableUrl } from "@versia-server/config";
import { MediaBackendType } from "@versia-server/config/schema";
import { randomUUIDv7, S3Client, SHA256, write } from "bun";
import {
desc,
eq,
type InferInsertModel,
type InferSelectModel,
inArray,
type SQL,
} from "drizzle-orm";
import sharp from "sharp";
import type { z } from "zod";
import { mimeLookup } from "@/content_types.ts";
import { getMediaHash } from "../../../classes/media/media-hasher.ts";
import { MediaJobType, mediaQueue } from "../../../classes/queues/media.ts";
import { BaseInterface } from "./base.ts";
type MediaType = InferSelectModel<typeof Medias>;
export class Media extends BaseInterface<typeof Medias> {
public static $type: MediaType;
public async reload(): Promise<void> {
const reloaded = await Media.fromId(this.data.id);
if (!reloaded) {
throw new Error("Failed to reload attachment");
}
this.data = reloaded.data;
}
public static async fromId(id: string | null): Promise<Media | null> {
if (!id) {
return null;
}
return await Media.fromSql(eq(Medias.id, id));
}
public static async fromIds(ids: string[]): Promise<Media[]> {
return await Media.manyFromSql(inArray(Medias.id, ids));
}
public static async fromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Medias.id),
): Promise<Media | null> {
const found = await db.query.Medias.findFirst({
where: sql,
orderBy,
});
if (!found) {
return null;
}
return new Media(found);
}
public static async manyFromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Medias.id),
limit?: number,
offset?: number,
extra?: Parameters<typeof db.query.Medias.findMany>[0],
): Promise<Media[]> {
const found = await db.query.Medias.findMany({
where: sql,
orderBy,
limit,
offset,
with: extra?.with,
});
return found.map((s) => new Media(s));
}
public async update(newAttachment: Partial<MediaType>): Promise<MediaType> {
await db
.update(Medias)
.set(newAttachment)
.where(eq(Medias.id, this.id));
const updated = await Media.fromId(this.data.id);
if (!updated) {
throw new Error("Failed to update attachment");
}
this.data = updated.data;
return updated.data;
}
public save(): Promise<MediaType> {
return this.update(this.data);
}
public async delete(ids?: string[]): Promise<void> {
if (Array.isArray(ids)) {
await db.delete(Medias).where(inArray(Medias.id, ids));
} else {
await db.delete(Medias).where(eq(Medias.id, this.id));
}
// TODO: Also delete the file from the media manager
}
public static async insert(
data: InferInsertModel<typeof Medias>,
): Promise<Media> {
const inserted = (await db.insert(Medias).values(data).returning())[0];
const attachment = await Media.fromId(inserted.id);
if (!attachment) {
throw new Error("Failed to insert attachment");
}
return attachment;
}
private static async upload(file: File): Promise<{
path: string;
}> {
const fileName = file.name ?? randomUUIDv7();
const hash = await getMediaHash(file);
switch (config.media.backend) {
case MediaBackendType.Local: {
const path = join(config.media.uploads_path, hash, fileName);
await write(path, file);
return { path: join(hash, fileName) };
}
case MediaBackendType.S3: {
const path = join(hash, fileName);
if (!config.s3) {
throw new ApiError(500, "S3 configuration missing");
}
const client = new S3Client({
endpoint: config.s3.endpoint.origin,
region: config.s3.region,
bucket: config.s3.bucket_name,
accessKeyId: config.s3.access_key,
secretAccessKey: config.s3.secret_access_key,
virtualHostedStyle: !config.s3.path_style,
});
await client.write(path, file);
const finalPath = config.s3.path
? join(config.s3.path, path)
: path;
return { path: finalPath };
}
}
}
public static async fromFile(
file: File,
options?: {
description?: string;
thumbnail?: File;
},
): Promise<Media> {
Media.checkFile(file);
const { path } = await Media.upload(file);
const url = Media.getUrl(path);
let thumbnailUrl: URL | null = null;
if (options?.thumbnail) {
const { path } = await Media.upload(options.thumbnail);
thumbnailUrl = Media.getUrl(path);
}
const content = await Media.fileToContentFormat(file, url, {
description: options?.description,
});
const thumbnailContent =
thumbnailUrl && options?.thumbnail
? await Media.fileToContentFormat(
options.thumbnail,
thumbnailUrl,
{
description: options?.description,
},
)
: undefined;
const newAttachment = await Media.insert({
id: randomUUIDv7(),
content,
thumbnail: thumbnailContent as z.infer<
typeof ImageContentFormatSchema
>,
});
if (config.media.conversion.convert_images) {
await mediaQueue.add(MediaJobType.ConvertMedia, {
attachmentId: newAttachment.id,
filename: file.name,
});
}
await mediaQueue.add(MediaJobType.CalculateMetadata, {
attachmentId: newAttachment.id,
filename: file.name,
});
return newAttachment;
}
/**
* Creates and adds a new media attachment from a URL
* @param uri
* @param options
* @returns
*/
public static async fromUrl(
uri: URL,
options?: {
description?: string;
},
): Promise<Media> {
const mimeType = await mimeLookup(uri);
const content: z.infer<typeof ContentFormatSchema> = {
[mimeType]: {
content: uri.toString(),
remote: true,
description: options?.description,
},
};
const newAttachment = await Media.insert({
id: randomUUIDv7(),
content,
});
await mediaQueue.add(MediaJobType.CalculateMetadata, {
attachmentId: newAttachment.id,
// CalculateMetadata doesn't use the filename, but the type is annoying
// and requires it anyway
filename: "blank",
});
return newAttachment;
}
private static checkFile(file: File): void {
if (file.size > config.validation.media.max_bytes) {
throw new ApiError(
413,
`File too large, max size is ${config.validation.media.max_bytes} bytes`,
);
}
if (
config.validation.media.allowed_mime_types.length > 0 &&
!config.validation.media.allowed_mime_types.includes(file.type)
) {
throw new ApiError(
415,
`File type ${file.type} is not allowed`,
`Allowed types: ${config.validation.media.allowed_mime_types.join(
", ",
)}`,
);
}
}
public async updateFromFile(file: File): Promise<void> {
Media.checkFile(file);
const { path } = await Media.upload(file);
const url = Media.getUrl(path);
const content = await Media.fileToContentFormat(file, url, {
description:
this.data.content[Object.keys(this.data.content)[0]]
.description || undefined,
});
await this.update({
content,
});
await mediaQueue.add(MediaJobType.CalculateMetadata, {
attachmentId: this.id,
filename: file.name,
});
}
public async updateFromUrl(uri: URL): Promise<void> {
const mimeType = await mimeLookup(uri);
const content: z.infer<typeof ContentFormatSchema> = {
[mimeType]: {
content: uri.toString(),
remote: true,
description:
this.data.content[Object.keys(this.data.content)[0]]
.description || undefined,
},
};
await this.update({
content,
});
await mediaQueue.add(MediaJobType.CalculateMetadata, {
attachmentId: this.id,
filename: "blank",
});
}
public async updateThumbnail(file: File): Promise<void> {
Media.checkFile(file);
const { path } = await Media.upload(file);
const url = Media.getUrl(path);
const content = await Media.fileToContentFormat(file, url);
await this.update({
thumbnail: content as z.infer<typeof ImageContentFormatSchema>,
});
}
public async updateMetadata(
metadata: Partial<
Omit<
z.infer<typeof ContentFormatSchema>[keyof z.infer<
typeof ContentFormatSchema
>],
"content"
>
>,
): Promise<void> {
const content = this.data.content;
for (const type of Object.keys(content)) {
content[type] = {
...content[type],
...metadata,
};
}
await this.update({
content,
});
}
public get id(): string {
return this.data.id;
}
public static getUrl(name: string): URL {
if (config.media.backend === MediaBackendType.Local) {
return new URL(`/media/${name}`, config.http.base_url);
}
if (config.media.backend === MediaBackendType.S3) {
return new URL(`/${name}`, config.s3?.public_url);
}
throw new Error("Unknown media backend");
}
public getUrl(): ProxiableUrl {
const type = this.getPreferredMimeType();
return new ProxiableUrl(this.data.content[type]?.content ?? "");
}
/**
* Gets favourite MIME type for the attachment
* Uses a hardcoded list of preferred types, for images
*
* @returns {string} Preferred MIME type
*/
public getPreferredMimeType(): string {
return Media.getPreferredMimeType(Object.keys(this.data.content));
}
/**
* Gets favourite MIME type from a list
* Uses a hardcoded list of preferred types, for images
*
* @returns {string} Preferred MIME type
*/
public static getPreferredMimeType(types: string[]): string {
const ranking = [
"image/svg+xml",
"image/avif",
"image/jxl",
"image/webp",
"image/heif",
"image/heif-sequence",
"image/heic",
"image/heic-sequence",
"image/apng",
"image/gif",
"image/png",
"image/jpeg",
"image/bmp",
];
return ranking.find((type) => types.includes(type)) ?? types[0];
}
/**
* Maps MIME type to Mastodon attachment type
*
* @returns
*/
public getMastodonType(): z.infer<typeof AttachmentSchema.shape.type> {
const type = this.getPreferredMimeType();
if (type.startsWith("image/")) {
return "image";
}
if (type.startsWith("video/")) {
return "video";
}
if (type.startsWith("audio/")) {
return "audio";
}
return "unknown";
}
/**
* Extracts metadata from a file and outputs as ContentFormat
*
* Does not calculate thumbhash (do this in a worker)
* @param file
* @param uri Uploaded file URI
* @param options Extra metadata, such as description
* @returns
*/
public static async fileToContentFormat(
file: File,
uri: URL,
options?: Partial<{
description: string;
}>,
): Promise<z.infer<typeof ContentFormatSchema>> {
const buffer = await file.arrayBuffer();
const isImage = file.type.startsWith("image/");
const { width, height } = isImage ? await sharp(buffer).metadata() : {};
const hash = new SHA256().update(file).digest("hex");
// Missing: fps, duration
// Thumbhash should be added in a worker after the file is uploaded
return {
[file.type]: {
content: uri.toString(),
remote: true,
hash: {
sha256: hash,
},
width,
height,
description: options?.description,
size: file.size,
},
};
}
public toApiMeta(): z.infer<typeof AttachmentSchema.shape.meta> {
const type = this.getPreferredMimeType();
const data = this.data.content[type];
const size =
data.width && data.height
? `${data.width}x${data.height}`
: undefined;
const aspect =
data.width && data.height ? data.width / data.height : undefined;
return {
width: data.width || undefined,
height: data.height || undefined,
fps: data.fps || undefined,
size,
// Idk whether size or length is the right value
duration: data.duration || undefined,
// Versia doesn't have a concept of length in ContentFormat
length: undefined,
aspect,
original: {
width: data.width || undefined,
height: data.height || undefined,
size,
aspect,
},
};
}
public toApi(): z.infer<typeof AttachmentSchema> {
const type = this.getPreferredMimeType();
const data = this.data.content[type];
// Thumbnail should only have a single MIME type
const thumbnailData =
this.data.thumbnail?.[Object.keys(this.data.thumbnail)[0]];
return {
id: this.data.id,
type: this.getMastodonType(),
url: this.getUrl().proxied,
remote_url: null,
preview_url: thumbnailData?.content
? new ProxiableUrl(thumbnailData.content).proxied
: null,
meta: this.toApiMeta(),
description: data.description || null,
blurhash: this.data.blurhash,
};
}
public toVersia(): VersiaEntities.ContentFormat {
return new VersiaEntities.ContentFormat(this.data.content);
}
public static fromVersia(
contentFormat: VersiaEntities.ContentFormat,
): Promise<Media> {
return Media.insert({
id: randomUUIDv7(),
content: contentFormat.data,
originalContent: contentFormat.data,
});
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,196 @@
import type { Notification as NotificationSchema } from "@versia/client/schemas";
import { db, Note, User } from "@versia/kit/db";
import { Notifications } from "@versia/kit/tables";
import {
desc,
eq,
type InferInsertModel,
type InferSelectModel,
inArray,
type SQL,
} from "drizzle-orm";
import type { z } from "zod";
import {
transformOutputToUserWithRelations,
userRelations,
} from "../../../classes/functions/user.ts";
import { BaseInterface } from "./base.ts";
export type NotificationType = InferSelectModel<typeof Notifications> & {
status: typeof Note.$type | null;
account: typeof User.$type;
};
export class Notification extends BaseInterface<
typeof Notifications,
NotificationType
> {
public async reload(): Promise<void> {
const reloaded = await Notification.fromId(this.data.id);
if (!reloaded) {
throw new Error("Failed to reload notification");
}
this.data = reloaded.data;
}
public static async fromId(
id: string | null,
userId?: string,
): Promise<Notification | null> {
if (!id) {
return null;
}
return await Notification.fromSql(
eq(Notifications.id, id),
undefined,
userId,
);
}
public static async fromIds(
ids: string[],
userId?: string,
): Promise<Notification[]> {
return await Notification.manyFromSql(
inArray(Notifications.id, ids),
undefined,
undefined,
undefined,
undefined,
userId,
);
}
public static async fromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Notifications.id),
userId?: string,
): Promise<Notification | null> {
const found = await db.query.Notifications.findFirst({
where: sql,
orderBy,
with: {
account: {
with: {
...userRelations,
},
},
},
});
if (!found) {
return null;
}
return new Notification({
...found,
account: transformOutputToUserWithRelations(found.account),
status: (await Note.fromId(found.noteId, userId))?.data ?? null,
});
}
public static async manyFromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Notifications.id),
limit?: number,
offset?: number,
extra?: Parameters<typeof db.query.Notifications.findMany>[0],
userId?: string,
): Promise<Notification[]> {
const found = await db.query.Notifications.findMany({
where: sql,
orderBy,
limit,
offset,
with: {
...extra?.with,
account: {
with: {
...userRelations,
},
},
},
extras: extra?.extras,
});
return (
await Promise.all(
found.map(async (notif) => ({
...notif,
account: transformOutputToUserWithRelations(notif.account),
status:
(await Note.fromId(notif.noteId, userId))?.data ?? null,
})),
)
).map((s) => new Notification(s));
}
public async update(
newAttachment: Partial<NotificationType>,
): Promise<NotificationType> {
await db
.update(Notifications)
.set(newAttachment)
.where(eq(Notifications.id, this.id));
const updated = await Notification.fromId(this.data.id);
if (!updated) {
throw new Error("Failed to update notification");
}
this.data = updated.data;
return updated.data;
}
public save(): Promise<NotificationType> {
return this.update(this.data);
}
public async delete(ids?: string[]): Promise<void> {
if (Array.isArray(ids)) {
await db
.delete(Notifications)
.where(inArray(Notifications.id, ids));
} else {
await db.delete(Notifications).where(eq(Notifications.id, this.id));
}
}
public static async insert(
data: InferInsertModel<typeof Notifications>,
): Promise<Notification> {
const inserted = (
await db.insert(Notifications).values(data).returning()
)[0];
const notification = await Notification.fromId(inserted.id);
if (!notification) {
throw new Error("Failed to insert notification");
}
return notification;
}
public get id(): string {
return this.data.id;
}
public async toApi(): Promise<z.infer<typeof NotificationSchema>> {
const account = new User(this.data.account);
return {
account: account.toApi(),
created_at: new Date(this.data.createdAt).toISOString(),
id: this.data.id,
type: this.data.type,
status: this.data.status
? await new Note(this.data.status).toApi(account)
: undefined,
group_key: `ungrouped-${this.data.id}`,
};
}
}

View file

@ -0,0 +1,190 @@
import type { WebPushSubscription as WebPushSubscriptionSchema } from "@versia/client/schemas";
import { db, type Token, type User } from "@versia/kit/db";
import { PushSubscriptions, Tokens } from "@versia/kit/tables";
import {
desc,
eq,
type InferInsertModel,
type InferSelectModel,
inArray,
type SQL,
} from "drizzle-orm";
import type { z } from "zod";
import { BaseInterface } from "./base.ts";
type PushSubscriptionType = InferSelectModel<typeof PushSubscriptions>;
export class PushSubscription extends BaseInterface<
typeof PushSubscriptions,
PushSubscriptionType
> {
public static $type: PushSubscriptionType;
public async reload(): Promise<void> {
const reloaded = await PushSubscription.fromId(this.data.id);
if (!reloaded) {
throw new Error("Failed to reload subscription");
}
this.data = reloaded.data;
}
public static async fromId(
id: string | null,
): Promise<PushSubscription | null> {
if (!id) {
return null;
}
return await PushSubscription.fromSql(eq(PushSubscriptions.id, id));
}
public static async fromIds(ids: string[]): Promise<PushSubscription[]> {
return await PushSubscription.manyFromSql(
inArray(PushSubscriptions.id, ids),
);
}
public static async fromToken(
token: Token,
): Promise<PushSubscription | null> {
return await PushSubscription.fromSql(
eq(PushSubscriptions.tokenId, token.id),
);
}
public static async manyFromUser(
user: User,
limit?: number,
offset?: number,
): Promise<PushSubscription[]> {
const found = await db
.select()
.from(PushSubscriptions)
.leftJoin(Tokens, eq(Tokens.id, PushSubscriptions.tokenId))
.where(eq(Tokens.userId, user.id))
.limit(limit ?? 9e10)
.offset(offset ?? 0);
return found.map((s) => new PushSubscription(s.PushSubscriptions));
}
public static async fromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(PushSubscriptions.id),
): Promise<PushSubscription | null> {
const found = await db.query.PushSubscriptions.findFirst({
where: sql,
orderBy,
});
if (!found) {
return null;
}
return new PushSubscription(found);
}
public static async manyFromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(PushSubscriptions.id),
limit?: number,
offset?: number,
extra?: Parameters<typeof db.query.PushSubscriptions.findMany>[0],
): Promise<PushSubscription[]> {
const found = await db.query.PushSubscriptions.findMany({
where: sql,
orderBy,
limit,
offset,
with: extra?.with,
});
return found.map((s) => new PushSubscription(s));
}
public async update(
newSubscription: Partial<PushSubscriptionType>,
): Promise<PushSubscriptionType> {
await db
.update(PushSubscriptions)
.set(newSubscription)
.where(eq(PushSubscriptions.id, this.id));
const updated = await PushSubscription.fromId(this.data.id);
if (!updated) {
throw new Error("Failed to update subscription");
}
this.data = updated.data;
return updated.data;
}
public save(): Promise<PushSubscriptionType> {
return this.update(this.data);
}
public static async clearAllOfToken(token: Token): Promise<void> {
await db
.delete(PushSubscriptions)
.where(eq(PushSubscriptions.tokenId, token.id));
}
public async delete(ids?: string[]): Promise<void> {
if (Array.isArray(ids)) {
await db
.delete(PushSubscriptions)
.where(inArray(PushSubscriptions.id, ids));
} else {
await db
.delete(PushSubscriptions)
.where(eq(PushSubscriptions.id, this.id));
}
}
public static async insert(
data: InferInsertModel<typeof PushSubscriptions>,
): Promise<PushSubscription> {
const inserted = (
await db.insert(PushSubscriptions).values(data).returning()
)[0];
const subscription = await PushSubscription.fromId(inserted.id);
if (!subscription) {
throw new Error("Failed to insert subscription");
}
return subscription;
}
public get id(): string {
return this.data.id;
}
public getAlerts(): z.infer<typeof WebPushSubscriptionSchema.shape.alerts> {
return {
mention: this.data.alerts.mention ?? false,
favourite: this.data.alerts.favourite ?? false,
reblog: this.data.alerts.reblog ?? false,
follow: this.data.alerts.follow ?? false,
poll: this.data.alerts.poll ?? false,
follow_request: this.data.alerts.follow_request ?? false,
status: this.data.alerts.status ?? false,
update: this.data.alerts.update ?? false,
"admin.sign_up": this.data.alerts["admin.sign_up"] ?? false,
"admin.report": this.data.alerts["admin.report"] ?? false,
};
}
public toApi(): z.infer<typeof WebPushSubscriptionSchema> {
return {
id: this.data.id,
alerts: this.getAlerts(),
endpoint: this.data.endpoint,
// FIXME: Add real key
server_key: "",
};
}
}

View file

@ -0,0 +1,285 @@
import { db, Emoji, Instance, type Note, User } from "@versia/kit/db";
import { type Notes, Reactions, type Users } from "@versia/kit/tables";
import * as VersiaEntities from "@versia/sdk/entities";
import { config } from "@versia-server/config";
import { randomUUIDv7 } from "bun";
import {
and,
desc,
eq,
type InferInsertModel,
type InferSelectModel,
inArray,
isNull,
type SQL,
} from "drizzle-orm";
import { BaseInterface } from "./base.ts";
type ReactionType = InferSelectModel<typeof Reactions> & {
emoji: typeof Emoji.$type | null;
author: InferSelectModel<typeof Users>;
note: InferSelectModel<typeof Notes>;
};
export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
public static $type: ReactionType;
public async reload(): Promise<void> {
const reloaded = await Reaction.fromId(this.data.id);
if (!reloaded) {
throw new Error("Failed to reload reaction");
}
this.data = reloaded.data;
}
public static async fromId(id: string | null): Promise<Reaction | null> {
if (!id) {
return null;
}
return await Reaction.fromSql(eq(Reactions.id, id));
}
public static async fromIds(ids: string[]): Promise<Reaction[]> {
return await Reaction.manyFromSql(inArray(Reactions.id, ids));
}
public static async fromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Reactions.id),
): Promise<Reaction | null> {
const found = await db.query.Reactions.findFirst({
where: sql,
with: {
emoji: {
with: {
instance: true,
media: true,
},
},
author: true,
note: true,
},
orderBy,
});
if (!found) {
return null;
}
return new Reaction(found);
}
public static async manyFromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Reactions.id),
limit?: number,
offset?: number,
extra?: Parameters<typeof db.query.Reactions.findMany>[0],
): Promise<Reaction[]> {
const found = await db.query.Reactions.findMany({
where: sql,
orderBy,
limit,
offset,
with: {
...extra?.with,
emoji: {
with: {
instance: true,
media: true,
},
},
author: true,
note: true,
},
});
return found.map((s) => new Reaction(s));
}
public async update(
newReaction: Partial<ReactionType>,
): Promise<ReactionType> {
await db
.update(Reactions)
.set(newReaction)
.where(eq(Reactions.id, this.id));
const updated = await Reaction.fromId(this.data.id);
if (!updated) {
throw new Error("Failed to update reaction");
}
this.data = updated.data;
return updated.data;
}
public save(): Promise<ReactionType> {
return this.update(this.data);
}
public async delete(ids?: string[]): Promise<void> {
if (Array.isArray(ids)) {
await db.delete(Reactions).where(inArray(Reactions.id, ids));
} else {
await db.delete(Reactions).where(eq(Reactions.id, this.id));
}
}
public static async insert(
data: InferInsertModel<typeof Reactions>,
): Promise<Reaction> {
// Needs one of emojiId or emojiText, but not both
if (!(data.emojiId || data.emojiText)) {
throw new Error("EmojiID or emojiText is required");
}
if (data.emojiId && data.emojiText) {
throw new Error("Cannot have both emojiId and emojiText");
}
const inserted = (
await db.insert(Reactions).values(data).returning()
)[0];
const reaction = await Reaction.fromId(inserted.id);
if (!reaction) {
throw new Error("Failed to insert reaction");
}
return reaction;
}
public get id(): string {
return this.data.id;
}
public static fromEmoji(
emoji: Emoji | string,
author: User,
note: Note,
): Promise<Reaction | null> {
if (emoji instanceof Emoji) {
return Reaction.fromSql(
and(
eq(Reactions.authorId, author.id),
eq(Reactions.noteId, note.id),
isNull(Reactions.emojiText),
eq(Reactions.emojiId, emoji.id),
),
);
}
return Reaction.fromSql(
and(
eq(Reactions.authorId, author.id),
eq(Reactions.noteId, note.id),
eq(Reactions.emojiText, emoji),
isNull(Reactions.emojiId),
),
);
}
public getUri(baseUrl: URL): URL {
return this.data.uri
? new URL(this.data.uri)
: new URL(
`/notes/${this.data.noteId}/reactions/${this.id}`,
baseUrl,
);
}
public get local(): boolean {
return this.data.author.instanceId === null;
}
public hasCustomEmoji(): boolean {
return !!this.data.emoji || !this.data.emojiText;
}
public toVersia(): VersiaEntities.Reaction {
if (!this.local) {
throw new Error("Cannot convert a non-local reaction to Versia");
}
return new VersiaEntities.Reaction({
uri: this.getUri(config.http.base_url).href,
type: "pub.versia:reactions/Reaction",
author: User.getUri(
this.data.authorId,
this.data.author.uri ? new URL(this.data.author.uri) : null,
).href,
created_at: new Date(this.data.createdAt).toISOString(),
id: this.id,
object: this.data.note.uri
? new URL(this.data.note.uri).href
: new URL(`/notes/${this.data.noteId}`, config.http.base_url)
.href,
content: this.hasCustomEmoji()
? `:${this.data.emoji?.shortcode}:`
: this.data.emojiText || "",
extensions: this.hasCustomEmoji()
? {
"pub.versia:custom_emojis": {
emojis: [
new Emoji(
this.data.emoji as typeof Emoji.$type,
).toVersia(),
],
},
}
: undefined,
});
}
public toVersiaUnreact(): VersiaEntities.Delete {
return new VersiaEntities.Delete({
type: "Delete",
id: crypto.randomUUID(),
created_at: new Date().toISOString(),
author: User.getUri(
this.data.authorId,
this.data.author.uri ? new URL(this.data.author.uri) : null,
).href,
deleted_type: "pub.versia:reactions/Reaction",
deleted: this.getUri(config.http.base_url).href,
});
}
public static async fromVersia(
reactionToConvert: VersiaEntities.Reaction,
author: User,
note: Note,
): Promise<Reaction> {
if (author.local) {
throw new Error("Cannot process a reaction from a local user");
}
const emojiEntity =
reactionToConvert.data.extensions?.["pub.versia:custom_emojis"]
?.emojis[0];
const emoji = emojiEntity
? await Emoji.fetchFromRemote(
emojiEntity,
new Instance(
author.data.instance as NonNullable<
(typeof User.$type)["instance"]
>,
),
)
: null;
return Reaction.insert({
id: randomUUIDv7(),
uri: reactionToConvert.data.uri,
authorId: author.id,
noteId: note.id,
emojiId: emoji ? emoji.id : null,
emojiText: emoji ? null : reactionToConvert.data.content,
});
}
}

View file

@ -0,0 +1,353 @@
import type { Relationship as RelationshipSchema } from "@versia/client/schemas";
import { db } from "@versia/kit/db";
import { Relationships, Users } from "@versia/kit/tables";
import { randomUUIDv7 } from "bun";
import {
and,
desc,
eq,
type InferInsertModel,
type InferSelectModel,
inArray,
type SQL,
sql,
} from "drizzle-orm";
import { z } from "zod";
import { BaseInterface } from "./base.ts";
import type { User } from "./user.ts";
type RelationshipType = InferSelectModel<typeof Relationships>;
type RelationshipWithOpposite = RelationshipType & {
followedBy: boolean;
blockedBy: boolean;
requestedBy: boolean;
};
export class Relationship extends BaseInterface<
typeof Relationships,
RelationshipWithOpposite
> {
public static schema = z.object({
id: z.string(),
blocked_by: z.boolean(),
blocking: z.boolean(),
domain_blocking: z.boolean(),
endorsed: z.boolean(),
followed_by: z.boolean(),
following: z.boolean(),
muting_notifications: z.boolean(),
muting: z.boolean(),
note: z.string().nullable(),
notifying: z.boolean(),
requested_by: z.boolean(),
requested: z.boolean(),
showing_reblogs: z.boolean(),
});
public static $type: RelationshipWithOpposite;
public async reload(): Promise<void> {
const reloaded = await Relationship.fromId(this.data.id);
if (!reloaded) {
throw new Error("Failed to reload relationship");
}
this.data = reloaded.data;
}
public static async fromId(
id: string | null,
): Promise<Relationship | null> {
if (!id) {
return null;
}
return await Relationship.fromSql(eq(Relationships.id, id));
}
public static async fromIds(ids: string[]): Promise<Relationship[]> {
return await Relationship.manyFromSql(inArray(Relationships.id, ids));
}
public static async fromOwnerAndSubject(
owner: User,
subject: User,
): Promise<Relationship> {
const found = await Relationship.fromSql(
and(
eq(Relationships.ownerId, owner.id),
eq(Relationships.subjectId, subject.id),
),
);
if (!found) {
// Create a new relationship if one doesn't exist
return await Relationship.insert({
id: randomUUIDv7(),
ownerId: owner.id,
subjectId: subject.id,
languages: [],
following: false,
showingReblogs: false,
notifying: false,
blocking: false,
muting: false,
mutingNotifications: false,
requested: false,
domainBlocking: false,
endorsed: false,
note: "",
});
}
return found;
}
public static async fromOwnerAndSubjects(
owner: User,
subjectIds: string[],
): Promise<Relationship[]> {
const found = await Relationship.manyFromSql(
and(
eq(Relationships.ownerId, owner.id),
inArray(Relationships.subjectId, subjectIds),
),
);
const missingSubjectsIds = subjectIds.filter(
(id) => !found.find((rel) => rel.data.subjectId === id),
);
for (const subjectId of missingSubjectsIds) {
await Relationship.insert({
id: randomUUIDv7(),
ownerId: owner.id,
subjectId,
languages: [],
following: false,
showingReblogs: false,
notifying: false,
blocking: false,
muting: false,
mutingNotifications: false,
requested: false,
domainBlocking: false,
endorsed: false,
note: "",
});
}
return await Relationship.manyFromSql(
and(
eq(Relationships.ownerId, owner.id),
inArray(Relationships.subjectId, subjectIds),
),
);
}
public static async fromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Relationships.id),
): Promise<Relationship | null> {
const found = await db.query.Relationships.findFirst({
where: sql,
orderBy,
});
if (!found) {
return null;
}
const opposite = await Relationship.getOpposite(found);
return new Relationship({
...found,
followedBy: opposite.following,
blockedBy: opposite.blocking,
requestedBy: opposite.requested,
});
}
public static async manyFromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Relationships.id),
limit?: number,
offset?: number,
extra?: Parameters<typeof db.query.Relationships.findMany>[0],
): Promise<Relationship[]> {
const found = await db.query.Relationships.findMany({
where: sql,
orderBy,
limit,
offset,
with: extra?.with,
});
const opposites = await Promise.all(
found.map((rel) => Relationship.getOpposite(rel)),
);
return found.map((s, i) => {
return new Relationship({
...s,
followedBy: opposites[i].following,
blockedBy: opposites[i].blocking,
requestedBy: opposites[i].requested,
});
});
}
public static async getOpposite(oppositeTo: {
subjectId: string;
ownerId: string;
}): Promise<RelationshipType> {
let output = await db.query.Relationships.findFirst({
where: (rel): SQL | undefined =>
and(
eq(rel.ownerId, oppositeTo.subjectId),
eq(rel.subjectId, oppositeTo.ownerId),
),
});
// If the opposite relationship doesn't exist, create it
if (!output) {
output = (
await db
.insert(Relationships)
.values({
id: randomUUIDv7(),
ownerId: oppositeTo.subjectId,
subjectId: oppositeTo.ownerId,
languages: [],
following: false,
showingReblogs: false,
notifying: false,
blocking: false,
domainBlocking: false,
endorsed: false,
note: "",
muting: false,
mutingNotifications: false,
requested: false,
})
.returning()
)[0];
}
return output;
}
public async update(
newRelationship: Partial<RelationshipType>,
): Promise<RelationshipWithOpposite> {
await db
.update(Relationships)
.set(newRelationship)
.where(eq(Relationships.id, this.id));
// If a user follows another user, update followerCount and followingCount
if (newRelationship.following && !this.data.following) {
await db
.update(Users)
.set({
followingCount: sql`${Users.followingCount} + 1`,
})
.where(eq(Users.id, this.data.ownerId));
await db
.update(Users)
.set({
followerCount: sql`${Users.followerCount} + 1`,
})
.where(eq(Users.id, this.data.subjectId));
}
// If a user unfollows another user, update followerCount and followingCount
if (!newRelationship.following && this.data.following) {
await db
.update(Users)
.set({
followingCount: sql`${Users.followingCount} - 1`,
})
.where(eq(Users.id, this.data.ownerId));
await db
.update(Users)
.set({
followerCount: sql`${Users.followerCount} - 1`,
})
.where(eq(Users.id, this.data.subjectId));
}
const updated = await Relationship.fromId(this.data.id);
if (!updated) {
throw new Error("Failed to update relationship");
}
this.data = updated.data;
return updated.data;
}
public save(): Promise<RelationshipWithOpposite> {
return this.update(this.data);
}
public async delete(ids?: string[]): Promise<void> {
if (Array.isArray(ids)) {
await db
.delete(Relationships)
.where(inArray(Relationships.id, ids));
} else {
await db.delete(Relationships).where(eq(Relationships.id, this.id));
}
}
public static async insert(
data: InferInsertModel<typeof Relationships>,
): Promise<Relationship> {
const inserted = (
await db.insert(Relationships).values(data).returning()
)[0];
const relationship = await Relationship.fromId(inserted.id);
if (!relationship) {
throw new Error("Failed to insert relationship");
}
// Create opposite relationship if necessary
await Relationship.getOpposite({
subjectId: relationship.data.subjectId,
ownerId: relationship.data.ownerId,
});
return relationship;
}
public get id(): string {
return this.data.id;
}
public toApi(): z.infer<typeof RelationshipSchema> {
return {
id: this.data.subjectId,
blocked_by: this.data.blockedBy,
blocking: this.data.blocking,
domain_blocking: this.data.domainBlocking,
endorsed: this.data.endorsed,
followed_by: this.data.followedBy,
following: this.data.following,
muting_notifications: this.data.mutingNotifications,
muting: this.data.muting,
note: this.data.note,
notifying: this.data.notifying,
requested_by: this.data.requestedBy,
requested: this.data.requested,
showing_reblogs: this.data.showingReblogs,
languages: this.data.languages ?? [],
};
}
}

View file

@ -0,0 +1,225 @@
import type {
RolePermission,
Role as RoleSchema,
} from "@versia/client/schemas";
import { db } from "@versia/kit/db";
import { Roles, RoleToUsers } from "@versia/kit/tables";
import { config, ProxiableUrl } from "@versia-server/config";
import {
and,
desc,
eq,
type InferInsertModel,
type InferSelectModel,
inArray,
type SQL,
} from "drizzle-orm";
import type { z } from "zod";
import { BaseInterface } from "./base.ts";
type RoleType = InferSelectModel<typeof Roles>;
export class Role extends BaseInterface<typeof Roles> {
public static $type: RoleType;
public static defaultRole = new Role({
id: "default",
name: "Default",
permissions: config.permissions.default,
priority: 0,
description: "Default role for all users",
visible: false,
icon: null,
});
public static adminRole = new Role({
id: "admin",
name: "Admin",
permissions: config.permissions.admin,
priority: 2 ** 31 - 1,
description: "Default role for all administrators",
visible: false,
icon: null,
});
public async reload(): Promise<void> {
const reloaded = await Role.fromId(this.data.id);
if (!reloaded) {
throw new Error("Failed to reload role");
}
this.data = reloaded.data;
}
public static async fromId(id: string | null): Promise<Role | null> {
if (!id) {
return null;
}
return await Role.fromSql(eq(Roles.id, id));
}
public static async fromIds(ids: string[]): Promise<Role[]> {
return await Role.manyFromSql(inArray(Roles.id, ids));
}
public static async fromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Roles.id),
): Promise<Role | null> {
const found = await db.query.Roles.findFirst({
where: sql,
orderBy,
});
if (!found) {
return null;
}
return new Role(found);
}
public static async getAll(): Promise<Role[]> {
return (await Role.manyFromSql(undefined)).concat(
Role.defaultRole,
Role.adminRole,
);
}
public static async getUserRoles(
userId: string,
isAdmin: boolean,
): Promise<Role[]> {
return (
await db.query.RoleToUsers.findMany({
where: (role): SQL | undefined => eq(role.userId, userId),
with: {
role: true,
user: {
columns: {
isAdmin: true,
},
},
},
})
)
.map((r) => new Role(r.role))
.concat(
new Role({
id: "default",
name: "Default",
permissions: config.permissions.default,
priority: 0,
description: "Default role for all users",
visible: false,
icon: null,
}),
)
.concat(
isAdmin
? [
new Role({
id: "admin",
name: "Admin",
permissions: config.permissions.admin,
priority: 2 ** 31 - 1,
description:
"Default role for all administrators",
visible: false,
icon: null,
}),
]
: [],
);
}
public static async manyFromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Roles.id),
limit?: number,
offset?: number,
extra?: Parameters<typeof db.query.Roles.findMany>[0],
): Promise<Role[]> {
const found = await db.query.Roles.findMany({
where: sql,
orderBy,
limit,
offset,
with: extra?.with,
});
return found.map((s) => new Role(s));
}
public async update(newRole: Partial<RoleType>): Promise<RoleType> {
await db.update(Roles).set(newRole).where(eq(Roles.id, this.id));
const updated = await Role.fromId(this.data.id);
if (!updated) {
throw new Error("Failed to update role");
}
return updated.data;
}
public save(): Promise<RoleType> {
return this.update(this.data);
}
public async delete(ids?: string[]): Promise<void> {
if (Array.isArray(ids)) {
await db.delete(Roles).where(inArray(Roles.id, ids));
} else {
await db.delete(Roles).where(eq(Roles.id, this.id));
}
}
public static async insert(
data: InferInsertModel<typeof Roles>,
): Promise<Role> {
const inserted = (await db.insert(Roles).values(data).returning())[0];
const role = await Role.fromId(inserted.id);
if (!role) {
throw new Error("Failed to insert role");
}
return role;
}
public async linkUser(userId: string): Promise<void> {
await db.insert(RoleToUsers).values({
userId,
roleId: this.id,
});
}
public async unlinkUser(userId: string): Promise<void> {
await db
.delete(RoleToUsers)
.where(
and(
eq(RoleToUsers.roleId, this.id),
eq(RoleToUsers.userId, userId),
),
);
}
public get id(): string {
return this.data.id;
}
public toApi(): z.infer<typeof RoleSchema> {
return {
id: this.id,
name: this.data.name,
permissions: this.data.permissions as unknown as RolePermission[],
priority: this.data.priority,
description: this.data.description ?? undefined,
visible: this.data.visible,
icon: this.data.icon
? new ProxiableUrl(this.data.icon).proxied
: undefined,
};
}
}

View file

@ -0,0 +1,241 @@
import { Notes, Notifications, Users } from "@versia/kit/tables";
import { config } from "@versia-server/config";
import { gt, type SQL } from "drizzle-orm";
import { Note } from "./note.ts";
import { Notification } from "./notification.ts";
import { User } from "./user.ts";
enum TimelineType {
Note = "Note",
User = "User",
Notification = "Notification",
}
export class Timeline<Type extends Note | User | Notification> {
public constructor(private type: TimelineType) {}
public static getNoteTimeline(
sql: SQL<unknown> | undefined,
limit: number,
url: URL,
userId?: string,
): Promise<{ link: string; objects: Note[] }> {
return new Timeline<Note>(TimelineType.Note).fetchTimeline(
sql,
limit,
url,
userId,
);
}
public static getUserTimeline(
sql: SQL<unknown> | undefined,
limit: number,
url: URL,
): Promise<{ link: string; objects: User[] }> {
return new Timeline<User>(TimelineType.User).fetchTimeline(
sql,
limit,
url,
);
}
public static getNotificationTimeline(
sql: SQL<unknown> | undefined,
limit: number,
url: URL,
userId?: string,
): Promise<{ link: string; objects: Notification[] }> {
return new Timeline<Notification>(
TimelineType.Notification,
).fetchTimeline(sql, limit, url, userId);
}
private async fetchObjects(
sql: SQL<unknown> | undefined,
limit: number,
userId?: string,
): Promise<Type[]> {
switch (this.type) {
case TimelineType.Note:
return (await Note.manyFromSql(
sql,
undefined,
limit,
undefined,
userId,
)) as Type[];
case TimelineType.User:
return (await User.manyFromSql(
sql,
undefined,
limit,
)) as Type[];
case TimelineType.Notification:
return (await Notification.manyFromSql(
sql,
undefined,
limit,
undefined,
undefined,
userId,
)) as Type[];
}
}
private async fetchLinkHeader(
objects: Type[],
url: URL,
limit: number,
): Promise<string> {
const linkHeader: string[] = [];
const urlWithoutQuery = new URL(url.pathname, config.http.base_url);
if (objects.length > 0) {
switch (this.type) {
case TimelineType.Note:
linkHeader.push(
...(await Timeline.fetchNoteLinkHeader(
objects as Note[],
urlWithoutQuery,
limit,
)),
);
break;
case TimelineType.User:
linkHeader.push(
...(await Timeline.fetchUserLinkHeader(
objects as User[],
urlWithoutQuery,
limit,
)),
);
break;
case TimelineType.Notification:
linkHeader.push(
...(await Timeline.fetchNotificationLinkHeader(
objects as Notification[],
urlWithoutQuery,
limit,
)),
);
}
}
return linkHeader.join(", ");
}
private static async fetchNoteLinkHeader(
notes: Note[],
urlWithoutQuery: URL,
limit: number,
): Promise<string[]> {
const linkHeader: string[] = [];
const objectBefore = await Note.fromSql(gt(Notes.id, notes[0].data.id));
if (objectBefore) {
linkHeader.push(
`<${urlWithoutQuery}?limit=${limit ?? 20}&min_id=${notes[0].data.id}>; rel="prev"`,
);
}
if (notes.length >= (limit ?? 20)) {
const objectAfter = await Note.fromSql(
gt(Notes.id, notes.at(-1)?.data.id ?? ""),
);
if (objectAfter) {
linkHeader.push(
`<${urlWithoutQuery}?limit=${limit ?? 20}&max_id=${notes.at(-1)?.data.id}>; rel="next"`,
);
}
}
return linkHeader;
}
private static async fetchUserLinkHeader(
users: User[],
urlWithoutQuery: URL,
limit: number,
): Promise<string[]> {
const linkHeader: string[] = [];
const objectBefore = await User.fromSql(gt(Users.id, users[0].id));
if (objectBefore) {
linkHeader.push(
`<${urlWithoutQuery}?limit=${limit ?? 20}&min_id=${users[0].id}>; rel="prev"`,
);
}
if (users.length >= (limit ?? 20)) {
const objectAfter = await User.fromSql(
gt(Users.id, users.at(-1)?.id ?? ""),
);
if (objectAfter) {
linkHeader.push(
`<${urlWithoutQuery}?limit=${limit ?? 20}&max_id=${users.at(-1)?.id}>; rel="next"`,
);
}
}
return linkHeader;
}
private static async fetchNotificationLinkHeader(
notifications: Notification[],
urlWithoutQuery: URL,
limit: number,
): Promise<string[]> {
const linkHeader: string[] = [];
const objectBefore = await Notification.fromSql(
gt(Notifications.id, notifications[0].data.id),
);
if (objectBefore) {
linkHeader.push(
`<${urlWithoutQuery}?limit=${limit ?? 20}&min_id=${notifications[0].data.id}>; rel="prev"`,
);
}
if (notifications.length >= (limit ?? 20)) {
const objectAfter = await Notification.fromSql(
gt(Notifications.id, notifications.at(-1)?.data.id ?? ""),
);
if (objectAfter) {
linkHeader.push(
`<${urlWithoutQuery}?limit=${limit ?? 20}&max_id=${notifications.at(-1)?.data.id}>; rel="next"`,
);
}
}
return linkHeader;
}
private async fetchTimeline(
sql: SQL<unknown> | undefined,
limit: number,
url: URL,
userId?: string,
): Promise<{ link: string; objects: Type[] }> {
const objects = await this.fetchObjects(sql, limit, userId);
const link = await this.fetchLinkHeader(objects, url, limit);
switch (this.type) {
case TimelineType.Note:
return {
link,
objects,
};
case TimelineType.User:
return {
link,
objects,
};
case TimelineType.Notification:
return {
link,
objects,
};
}
}
}

View file

@ -0,0 +1,166 @@
import type { Token as TokenSchema } from "@versia/client/schemas";
import { type Application, db, User } from "@versia/kit/db";
import { Tokens } from "@versia/kit/tables";
import {
desc,
eq,
type InferInsertModel,
type InferSelectModel,
inArray,
type SQL,
} from "drizzle-orm";
import type { z } from "zod";
import { BaseInterface } from "./base.ts";
type TokenType = InferSelectModel<typeof Tokens> & {
application: typeof Application.$type | null;
};
export class Token extends BaseInterface<typeof Tokens, TokenType> {
public static $type: TokenType;
public async reload(): Promise<void> {
const reloaded = await Token.fromId(this.data.id);
if (!reloaded) {
throw new Error("Failed to reload token");
}
this.data = reloaded.data;
}
public static async fromId(id: string | null): Promise<Token | null> {
if (!id) {
return null;
}
return await Token.fromSql(eq(Tokens.id, id));
}
public static async fromIds(ids: string[]): Promise<Token[]> {
return await Token.manyFromSql(inArray(Tokens.id, ids));
}
public static async fromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Tokens.id),
): Promise<Token | null> {
const found = await db.query.Tokens.findFirst({
where: sql,
orderBy,
with: {
application: true,
},
});
if (!found) {
return null;
}
return new Token(found);
}
public static async manyFromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Tokens.id),
limit?: number,
offset?: number,
extra?: Parameters<typeof db.query.Tokens.findMany>[0],
): Promise<Token[]> {
const found = await db.query.Tokens.findMany({
where: sql,
orderBy,
limit,
offset,
with: {
application: true,
...extra?.with,
},
});
return found.map((s) => new Token(s));
}
public async update(newAttachment: Partial<TokenType>): Promise<TokenType> {
await db
.update(Tokens)
.set(newAttachment)
.where(eq(Tokens.id, this.id));
const updated = await Token.fromId(this.data.id);
if (!updated) {
throw new Error("Failed to update token");
}
this.data = updated.data;
return updated.data;
}
public save(): Promise<TokenType> {
return this.update(this.data);
}
public async delete(ids?: string[]): Promise<void> {
if (Array.isArray(ids)) {
await db.delete(Tokens).where(inArray(Tokens.id, ids));
} else {
await db.delete(Tokens).where(eq(Tokens.id, this.id));
}
}
public static async insert(
data: InferInsertModel<typeof Tokens>,
): Promise<Token> {
const inserted = (await db.insert(Tokens).values(data).returning())[0];
const token = await Token.fromId(inserted.id);
if (!token) {
throw new Error("Failed to insert token");
}
return token;
}
public static async insertMany(
data: InferInsertModel<typeof Tokens>[],
): Promise<Token[]> {
const inserted = await db.insert(Tokens).values(data).returning();
return await Token.fromIds(inserted.map((i) => i.id));
}
public get id(): string {
return this.data.id;
}
public static async fromAccessToken(
accessToken: string,
): Promise<Token | null> {
return await Token.fromSql(eq(Tokens.accessToken, accessToken));
}
/**
* Retrieves the associated user from this token
*
* @returns The user associated with this token
*/
public async getUser(): Promise<User | null> {
if (!this.data.userId) {
return null;
}
return await User.fromId(this.data.userId);
}
public toApi(): z.infer<typeof TokenSchema> {
return {
access_token: this.data.accessToken,
token_type: "Bearer",
scope: this.data.scope,
created_at: Math.floor(
new Date(this.data.createdAt).getTime() / 1000,
),
};
}
}

File diff suppressed because it is too large Load diff