mirror of
https://github.com/versia-pub/server.git
synced 2026-03-13 05:49:16 +01:00
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:
parent
120ba0fb81
commit
e52e230ce3
110 changed files with 165 additions and 165 deletions
|
|
@ -1,155 +0,0 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,266 +0,0 @@
|
|||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
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>;
|
||||
}
|
||||
|
|
@ -1,234 +0,0 @@
|
|||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,345 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,311 +0,0 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,290 +0,0 @@
|
|||
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[],
|
||||
};
|
||||
}
|
||||
} */
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,10 @@
|
|||
// biome-ignore lint/performance/noBarrelFile: <explanation>
|
||||
export { User } from "~/packages/database-interface/user";
|
||||
export { Role } from "~/packages/database-interface/role";
|
||||
export { Attachment } from "~/packages/database-interface/attachment";
|
||||
export { Emoji } from "~/packages/database-interface/emoji";
|
||||
export { Instance } from "~/packages/database-interface/instance";
|
||||
export { Note } from "~/packages/database-interface/note";
|
||||
export { Timeline } from "~/packages/database-interface/timeline";
|
||||
export { Application } from "~/packages/database-interface/application";
|
||||
export { User } from "~/classes/database/user";
|
||||
export { Role } from "~/classes/database/role";
|
||||
export { Attachment } from "~/classes/database/attachment";
|
||||
export { Emoji } from "~/classes/database/emoji";
|
||||
export { Instance } from "~/classes/database/instance";
|
||||
export { Note } from "~/classes/database/note";
|
||||
export { Timeline } from "~/classes/database/timeline";
|
||||
export { Application } from "~/classes/database/application";
|
||||
export { db } from "~/drizzle/db";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue