refactor(database): 🚚 Move database ORM code to classes/database

The old directory, packages/database-interface, was confusingly named so it was better to move it here
This commit is contained in:
Jesse Wierzbinski 2024-10-24 16:28:38 +02:00
parent 120ba0fb81
commit e52e230ce3
No known key found for this signature in database
110 changed files with 165 additions and 165 deletions

View file

@ -0,0 +1,155 @@
import type { Application as APIApplication } from "@versia/client/types";
import {
type InferInsertModel,
type InferSelectModel,
type SQL,
desc,
eq,
inArray,
} from "drizzle-orm";
import { z } from "zod";
import { db } from "~/drizzle/db";
import { Applications } from "~/drizzle/schema";
import { BaseInterface } from "./base.ts";
export type ApplicationType = InferSelectModel<typeof Applications>;
export class Application extends BaseInterface<typeof Applications> {
static schema: z.ZodType<APIApplication> = z.object({
name: z.string(),
website: z.string().url().optional().nullable(),
vapid_key: z.string().optional().nullable(),
redirect_uris: z.string().optional(),
scopes: z.string().optional(),
});
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 db.query.Tokens.findFirst({
where: (tokens, { eq }) => eq(tokens.accessToken, token),
with: {
application: true,
},
});
return result?.application ? new Application(result.application) : null;
}
public static fromClientId(clientId: string): Promise<Application | null> {
return Application.fromSql(eq(Applications.clientId, clientId));
}
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;
}
save(): Promise<ApplicationType> {
return this.update(this.data);
}
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;
}
get id() {
return this.data.id;
}
public toApi(): APIApplication {
return {
name: this.data.name,
website: this.data.website,
vapid_key: this.data.vapidKey,
};
}
}

View file

@ -0,0 +1,266 @@
import { proxyUrl } from "@/response";
import type { Attachment as ApiAttachment } from "@versia/client/types";
import type { ContentFormat } from "@versia/federation/types";
import {
type InferInsertModel,
type InferSelectModel,
type SQL,
desc,
eq,
inArray,
} from "drizzle-orm";
import { z } from "zod";
import { db } from "~/drizzle/db";
import { Attachments } from "~/drizzle/schema";
import { MediaBackendType } from "~/packages/config-manager/config.type";
import { config } from "~/packages/config-manager/index.ts";
import { BaseInterface } from "./base.ts";
export type AttachmentType = InferSelectModel<typeof Attachments>;
export class Attachment extends BaseInterface<typeof Attachments> {
static schema: z.ZodType<ApiAttachment> = z.object({
id: z.string().uuid(),
type: z.enum(["unknown", "image", "gifv", "video", "audio"]),
url: z.string().url(),
remote_url: z.string().url().nullable(),
preview_url: z.string().url().nullable(),
text_url: z.string().url().nullable(),
meta: z
.object({
width: z.number().optional(),
height: z.number().optional(),
fps: z.number().optional(),
size: z.string().optional(),
duration: z.number().optional(),
length: z.string().optional(),
aspect: z.number().optional(),
original: z.object({
width: z.number().optional(),
height: z.number().optional(),
size: z.string().optional(),
aspect: z.number().optional(),
}),
})
.nullable(),
description: z.string().nullable(),
blurhash: z.string().nullable(),
});
async reload(): Promise<void> {
const reloaded = await Attachment.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<Attachment | null> {
if (!id) {
return null;
}
return await Attachment.fromSql(eq(Attachments.id, id));
}
public static async fromIds(ids: string[]): Promise<Attachment[]> {
return await Attachment.manyFromSql(inArray(Attachments.id, ids));
}
public static async fromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Attachments.id),
): Promise<Attachment | null> {
const found = await db.query.Attachments.findFirst({
where: sql,
orderBy,
});
if (!found) {
return null;
}
return new Attachment(found);
}
public static async manyFromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Attachments.id),
limit?: number,
offset?: number,
extra?: Parameters<typeof db.query.Attachments.findMany>[0],
): Promise<Attachment[]> {
const found = await db.query.Attachments.findMany({
where: sql,
orderBy,
limit,
offset,
with: extra?.with,
});
return found.map((s) => new Attachment(s));
}
async update(
newAttachment: Partial<AttachmentType>,
): Promise<AttachmentType> {
await db
.update(Attachments)
.set(newAttachment)
.where(eq(Attachments.id, this.id));
const updated = await Attachment.fromId(this.data.id);
if (!updated) {
throw new Error("Failed to update attachment");
}
this.data = updated.data;
return updated.data;
}
save(): Promise<AttachmentType> {
return this.update(this.data);
}
async delete(ids?: string[]): Promise<void> {
if (Array.isArray(ids)) {
await db.delete(Attachments).where(inArray(Attachments.id, ids));
} else {
await db.delete(Attachments).where(eq(Attachments.id, this.id));
}
}
public static async insert(
data: InferInsertModel<typeof Attachments>,
): Promise<Attachment> {
const inserted = (
await db.insert(Attachments).values(data).returning()
)[0];
const attachment = await Attachment.fromId(inserted.id);
if (!attachment) {
throw new Error("Failed to insert attachment");
}
return attachment;
}
get id() {
return this.data.id;
}
public static getUrl(name: string) {
if (config.media.backend === MediaBackendType.Local) {
return new URL(`/media/${name}`, config.http.base_url).toString();
}
if (config.media.backend === MediaBackendType.S3) {
return new URL(`/${name}`, config.s3.public_url).toString();
}
return "";
}
public getMastodonType(): ApiAttachment["type"] {
if (this.data.mimeType.startsWith("image/")) {
return "image";
}
if (this.data.mimeType.startsWith("video/")) {
return "video";
}
if (this.data.mimeType.startsWith("audio/")) {
return "audio";
}
return "unknown";
}
public toApiMeta(): ApiAttachment["meta"] {
return {
width: this.data.width || undefined,
height: this.data.height || undefined,
fps: this.data.fps || undefined,
size:
this.data.width && this.data.height
? `${this.data.width}x${this.data.height}`
: undefined,
duration: this.data.duration || undefined,
length: undefined,
aspect:
this.data.width && this.data.height
? this.data.width / this.data.height
: undefined,
original: {
width: this.data.width || undefined,
height: this.data.height || undefined,
size:
this.data.width && this.data.height
? `${this.data.width}x${this.data.height}`
: undefined,
aspect:
this.data.width && this.data.height
? this.data.width / this.data.height
: undefined,
},
// Idk whether size or length is the right value
};
}
public toApi(): ApiAttachment {
return {
id: this.data.id,
type: this.getMastodonType(),
url: proxyUrl(this.data.url) ?? "",
remote_url: proxyUrl(this.data.remoteUrl),
preview_url: proxyUrl(this.data.thumbnailUrl || this.data.url),
text_url: null,
meta: this.toApiMeta(),
description: this.data.description,
blurhash: this.data.blurhash,
};
}
public toVersia(): ContentFormat {
return {
[this.data.mimeType]: {
content: this.data.url,
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,
height: this.data.height ?? undefined,
size: this.data.size ?? undefined,
hash: this.data.sha256
? {
sha256: this.data.sha256,
}
: undefined,
width: this.data.width ?? undefined,
},
};
}
public static fromVersia(
attachmentToConvert: ContentFormat,
): Promise<Attachment> {
const key = Object.keys(attachmentToConvert)[0];
const value = attachmentToConvert[key];
return Attachment.insert({
mimeType: key,
url: value.content,
description: value.description || undefined,
duration: value.duration || undefined,
fps: value.fps || undefined,
height: value.height || undefined,
// biome-ignore lint/style/useExplicitLengthCheck: Biome thinks we're checking if size is not zero
size: value.size || undefined,
width: value.width || undefined,
sha256: value.hash?.sha256 || undefined,
// blurhash: value.blurhash || undefined,
});
}
}

54
classes/database/base.ts Normal file
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.
*/
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>;
}

234
classes/database/emoji.ts Normal file
View file

@ -0,0 +1,234 @@
import { emojiValidatorWithColons, emojiValidatorWithIdentifiers } from "@/api";
import { proxyUrl } from "@/response";
import type { Emoji as ApiEmoji } from "@versia/client/types";
import type { CustomEmojiExtension } from "@versia/federation/types";
import {
type InferInsertModel,
type InferSelectModel,
type SQL,
and,
desc,
eq,
inArray,
} from "drizzle-orm";
import { z } from "zod";
import { db } from "~/drizzle/db";
import { Emojis, Instances } from "~/drizzle/schema";
import { BaseInterface } from "./base.ts";
import { Instance } from "./instance.ts";
export type EmojiWithInstance = InferSelectModel<typeof Emojis> & {
instance: InferSelectModel<typeof Instances> | null;
};
export class Emoji extends BaseInterface<typeof Emojis, EmojiWithInstance> {
static schema = z.object({
shortcode: z.string(),
url: z.string(),
visible_in_picker: z.boolean(),
category: z.string().optional(),
static_url: z.string(),
});
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,
},
});
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 },
});
return found.map((s) => new Emoji(s));
}
async update(
newEmoji: Partial<EmojiWithInstance>,
): Promise<EmojiWithInstance> {
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;
}
save(): Promise<EmojiWithInstance> {
return this.update(this.data);
}
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: CustomEmojiExtension["emojis"][0],
host?: string,
): Promise<Emoji> {
const existingEmoji = await db
.select()
.from(Emojis)
.innerJoin(Instances, eq(Emojis.instanceId, Instances.id))
.where(
and(
eq(Emojis.shortcode, emojiToFetch.name),
host ? eq(Instances.baseUrl, host) : undefined,
),
)
.limit(1);
if (existingEmoji[0]) {
const found = await Emoji.fromId(existingEmoji[0].Emojis.id);
if (!found) {
throw new Error("Failed to fetch emoji");
}
return found;
}
const foundInstance = host ? await Instance.resolve(host) : null;
return await Emoji.fromVersia(emojiToFetch, foundInstance?.id ?? null);
}
get id() {
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(emojiValidatorWithColons);
if (!matches || matches.length === 0) {
return Promise.resolve([]);
}
return Emoji.manyFromSql(
inArray(
Emojis.shortcode,
matches.map((match) => match.replace(/:/g, "")),
),
);
}
public toApi(): ApiEmoji {
return {
// @ts-expect-error ID is not in regular Mastodon API
id: this.id,
shortcode: this.data.shortcode,
static_url: proxyUrl(this.data.url) ?? "", // TODO: Add static version
url: proxyUrl(this.data.url) ?? "",
visible_in_picker: this.data.visibleInPicker,
category: this.data.category ?? undefined,
};
}
public toVersia(): CustomEmojiExtension["emojis"][0] {
return {
name: `:${this.data.shortcode}:`,
url: {
[this.data.contentType]: {
content: this.data.url,
description: this.data.alt || undefined,
remote: true,
},
},
};
}
public static fromVersia(
emoji: CustomEmojiExtension["emojis"][0],
instanceId: string | null,
): Promise<Emoji> {
// Extracts the shortcode from the emoji name (e.g. :shortcode: -> shortcode)
const shortcode = [
...emoji.name.matchAll(emojiValidatorWithIdentifiers),
][0].groups.shortcode;
if (!shortcode) {
throw new Error("Could not extract shortcode from emoji name");
}
return Emoji.insert({
shortcode,
url: Object.entries(emoji.url)[0][1].content,
alt: Object.entries(emoji.url)[0][1].description || undefined,
contentType: Object.keys(emoji.url)[0],
visibleInPicker: true,
instanceId,
});
}
}

View file

@ -0,0 +1,345 @@
import { getLogger } from "@logtape/logtape";
import {
EntityValidator,
type ResponseError,
type ValidationError,
} from "@versia/federation";
import type { InstanceMetadata } from "@versia/federation/types";
import chalk from "chalk";
import {
type InferInsertModel,
type InferSelectModel,
type SQL,
desc,
eq,
inArray,
} from "drizzle-orm";
import { db } from "~/drizzle/db";
import { Instances } from "~/drizzle/schema";
import { config } from "~/packages/config-manager/index.ts";
import { BaseInterface } from "./base.ts";
import { User } from "./user.ts";
export type AttachmentType = InferSelectModel<typeof Instances>;
export class Instance extends BaseInterface<typeof Instances> {
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));
}
async update(
newInstance: Partial<AttachmentType>,
): Promise<AttachmentType> {
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;
}
save(): Promise<AttachmentType> {
return this.update(this.data);
}
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 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;
}
get id() {
return this.data.id;
}
static async fetchMetadata(url: string): Promise<{
metadata: InstanceMetadata;
protocol: "versia" | "activitypub";
} | null> {
const origin = new URL(url).origin;
const wellKnownUrl = new URL("/.well-known/versia", origin);
const logger = getLogger("federation");
const requester = await User.getFederationRequester();
try {
const { ok, raw, data } = await requester
.get(wellKnownUrl, {
// @ts-expect-error Bun extension
proxy: config.http.proxy.address,
})
.catch((e) => ({
...(e as ResponseError).response,
}));
if (!(ok && raw.headers.get("content-type")?.includes("json"))) {
// 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) {
return null;
}
return {
metadata: data,
protocol: "activitypub",
};
}
try {
const metadata = await new EntityValidator().InstanceMetadata(
data,
);
return { metadata, protocol: "versia" };
} catch (error) {
logger.error`Instance ${chalk.bold(
origin,
)} has invalid metadata: ${(error as ValidationError).message}`;
return null;
}
} catch (error) {
logger.error`Failed to fetch Versia metadata for instance ${chalk.bold(
origin,
)} - Error! ${error}`;
return null;
}
}
private static async fetchActivityPubMetadata(
url: string,
): Promise<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");
const requester = await User.getFederationRequester();
try {
const {
raw: response,
ok,
data: wellKnown,
} = await requester
.get<{
links: { rel: string; href: string }[];
}>(wellKnownUrl, {
// @ts-expect-error Bun extension
proxy: config.http.proxy.address,
})
.catch((e) => ({
...(
e as ResponseError<{
links: { rel: string; href: string }[];
}>
).response,
}));
if (!ok) {
logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
origin,
)} - HTTP ${response.status}`;
return null;
}
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 {
raw: metadataResponse,
ok: ok2,
data: metadata,
} = await requester
.get<{
metadata: {
nodeName?: string;
title?: string;
nodeDescription?: string;
description?: string;
};
software: { version: string };
}>(metadataUrl.href, {
// @ts-expect-error Bun extension
proxy: config.http.proxy.address,
})
.catch((e) => ({
...(
e as ResponseError<{
metadata: {
nodeName?: string;
title?: string;
nodeDescription?: string;
description?: string;
};
software: { version: string };
}>
).response,
}));
if (!ok2) {
logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
origin,
)} - HTTP ${metadataResponse.status}`;
return null;
}
return {
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;
}
}
static async resolve(url: string): Promise<Instance> {
const logger = getLogger("federation");
const host = new URL(url).host;
const existingInstance = await Instance.fromSql(
eq(Instances.baseUrl, host),
);
if (existingInstance) {
return existingInstance;
}
const output = await Instance.fetchMetadata(url);
if (!output) {
logger.error`Failed to resolve instance ${chalk.bold(host)}`;
throw new Error("Failed to resolve instance");
}
const { metadata, protocol } = output;
return Instance.insert({
baseUrl: host,
name: metadata.name,
version: metadata.software.version,
logo: metadata.logo,
protocol,
});
}
static getCount(): Promise<number> {
return db.$count(Instances);
}
}

1102
classes/database/note.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,311 @@
import type { Relationship as APIRelationship } from "@versia/client/types";
import {
type InferInsertModel,
type InferSelectModel,
type SQL,
and,
desc,
eq,
inArray,
} from "drizzle-orm";
import { z } from "zod";
import { db } from "~/drizzle/db";
import { Relationships } from "~/drizzle/schema";
import { BaseInterface } from "./base.ts";
import type { User } from "./user.ts";
export type RelationshipType = InferSelectModel<typeof Relationships>;
export type RelationshipWithOpposite = RelationshipType & {
followedBy: boolean;
blockedBy: boolean;
requestedBy: boolean;
};
export class Relationship extends BaseInterface<
typeof Relationships,
RelationshipWithOpposite
> {
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(),
});
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({
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({
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, { and, eq }) =>
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({
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;
}
async update(
newRelationship: Partial<RelationshipType>,
): Promise<RelationshipWithOpposite> {
await db
.update(Relationships)
.set(newRelationship)
.where(eq(Relationships.id, this.id));
const updated = await Relationship.fromId(this.data.id);
if (!updated) {
throw new Error("Failed to update relationship");
}
this.data = updated.data;
return updated.data;
}
save(): Promise<RelationshipWithOpposite> {
return this.update(this.data);
}
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;
}
get id() {
return this.data.id;
}
public toApi(): APIRelationship {
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,
};
}
}

201
classes/database/role.ts Normal file
View file

@ -0,0 +1,201 @@
import { proxyUrl } from "@/response";
import { RolePermission } from "@versia/client/types";
import {
type InferInsertModel,
type InferSelectModel,
type SQL,
and,
desc,
eq,
inArray,
} from "drizzle-orm";
import { z } from "zod";
import { db } from "~/drizzle/db";
import { RoleToUsers, Roles } from "~/drizzle/schema";
import { config } from "~/packages/config-manager/index.ts";
import { BaseInterface } from "./base.ts";
export type RoleType = InferSelectModel<typeof Roles>;
export class Role extends BaseInterface<typeof Roles> {
static schema = z.object({
id: z.string(),
name: z.string(),
permissions: z.array(z.nativeEnum(RolePermission)),
priority: z.number(),
description: z.string().nullable(),
visible: z.boolean(),
icon: z.string().nullable(),
});
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),
) {
const found = await db.query.Roles.findFirst({
where: sql,
orderBy,
});
if (!found) {
return null;
}
return new Role(found);
}
public static async getUserRoles(userId: string, isAdmin: boolean) {
return (
await db.query.RoleToUsers.findMany({
where: (role, { eq }) => 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],
) {
const found = await db.query.Roles.findMany({
where: sql,
orderBy,
limit,
offset,
with: extra?.with,
});
return found.map((s) => new Role(s));
}
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;
}
save(): Promise<RoleType> {
return this.update(this.data);
}
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) {
await db.insert(RoleToUsers).values({
userId,
roleId: this.id,
});
}
public async unlinkUser(userId: string) {
await db
.delete(RoleToUsers)
.where(
and(
eq(RoleToUsers.roleId, this.id),
eq(RoleToUsers.userId, userId),
),
);
}
get id() {
return this.data.id;
}
public toApi() {
return {
id: this.id,
name: this.data.name,
permissions: this.data.permissions as unknown as RolePermission[],
priority: this.data.priority,
description: this.data.description,
visible: this.data.visible,
icon: proxyUrl(this.data.icon),
};
}
}

View file

@ -0,0 +1,290 @@
import { type SQL, gt } from "drizzle-orm";
import { Notes, Users } from "~/drizzle/schema";
import { config } from "~/packages/config-manager";
import { Note } from "./note.ts";
import { User } from "./user.ts";
enum TimelineType {
Note = "Note",
User = "User",
}
export class Timeline<Type extends Note | User> {
constructor(private type: TimelineType) {}
static getNoteTimeline(
sql: SQL<unknown> | undefined,
limit: number,
url: string,
userId?: string,
) {
return new Timeline<Note>(TimelineType.Note).fetchTimeline(
sql,
limit,
url,
userId,
);
}
static getUserTimeline(
sql: SQL<unknown> | undefined,
limit: number,
url: string,
) {
return new Timeline<User>(TimelineType.User).fetchTimeline(
sql,
limit,
url,
);
}
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[];
}
}
private async fetchLinkHeader(
objects: Type[],
url: string,
limit: number,
): Promise<string> {
const linkHeader: string[] = [];
const urlWithoutQuery = new URL(
new URL(url).pathname,
config.http.base_url,
).toString();
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;
}
}
return linkHeader.join(", ");
}
private static async fetchNoteLinkHeader(
notes: Note[],
urlWithoutQuery: string,
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[notes.length - 1].data.id),
);
if (objectAfter) {
linkHeader.push(
`<${urlWithoutQuery}?limit=${limit ?? 20}&max_id=${notes[notes.length - 1].data.id}>; rel="next"`,
);
}
}
return linkHeader;
}
private static async fetchUserLinkHeader(
users: User[],
urlWithoutQuery: string,
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[users.length - 1].id),
);
if (objectAfter) {
linkHeader.push(
`<${urlWithoutQuery}?limit=${limit ?? 20}&max_id=${users[users.length - 1].id}>; rel="next"`,
);
}
}
return linkHeader;
}
private async fetchTimeline(
sql: SQL<unknown> | undefined,
limit: number,
url: string,
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,
};
}
}
/* private async fetchTimeline<T>(
sql: SQL<unknown> | undefined,
limit: number,
url: string,
userId?: string,
) {
const notes: Note[] = [];
const users: User[] = [];
switch (this.type) {
case TimelineType.Note:
notes.push(
...(await Note.manyFromSql(
sql,
undefined,
limit,
undefined,
userId,
)),
);
break;
case TimelineType.User:
users.push(...(await User.manyFromSql(sql, undefined, limit)));
break;
}
const linkHeader = [];
const urlWithoutQuery = new URL(
new URL(url).pathname,
config.http.base_url,
).toString();
if (notes.length > 0) {
switch (this.type) {
case TimelineType.Note: {
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[notes.length - 1].data.id),
);
if (objectAfter) {
linkHeader.push(
`<${urlWithoutQuery}?limit=${
limit ?? 20
}&max_id=${
notes[notes.length - 1].data.id
}>; rel="next"`,
);
}
}
break;
}
case TimelineType.User: {
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[users.length - 1].id),
);
if (objectAfter) {
linkHeader.push(
`<${urlWithoutQuery}?limit=${
limit ?? 20
}&max_id=${
users[users.length - 1].id
}>; rel="next"`,
);
}
}
break;
}
}
}
switch (this.type) {
case TimelineType.Note:
return {
link: linkHeader.join(", "),
objects: notes as T[],
};
case TimelineType.User:
return {
link: linkHeader.join(", "),
objects: users as T[],
};
}
} */
}

1049
classes/database/user.ts Normal file

File diff suppressed because it is too large Load diff