mirror of
https://github.com/versia-pub/server.git
synced 2026-03-13 05:49:16 +01:00
refactor: ♻️ Rewrite build system to fit the monorepo architecture
This commit is contained in:
parent
7de4b573e3
commit
90b6399407
217 changed files with 2143 additions and 1858 deletions
165
packages/kit/db/application.ts
Normal file
165
packages/kit/db/application.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import type {
|
||||
Application as ApplicationSchema,
|
||||
CredentialApplication,
|
||||
} from "@versia/client/schemas";
|
||||
import {
|
||||
desc,
|
||||
eq,
|
||||
type InferInsertModel,
|
||||
type InferSelectModel,
|
||||
inArray,
|
||||
type SQL,
|
||||
} from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
import { db } from "../tables/db.ts";
|
||||
import { Applications } from "../tables/schema.ts";
|
||||
import { BaseInterface } from "./base.ts";
|
||||
import { Token } from "./token.ts";
|
||||
|
||||
type ApplicationType = InferSelectModel<typeof Applications>;
|
||||
|
||||
export class Application extends BaseInterface<typeof Applications> {
|
||||
public static $type: ApplicationType;
|
||||
|
||||
public async reload(): Promise<void> {
|
||||
const reloaded = await Application.fromId(this.data.id);
|
||||
|
||||
if (!reloaded) {
|
||||
throw new Error("Failed to reload application");
|
||||
}
|
||||
|
||||
this.data = reloaded.data;
|
||||
}
|
||||
|
||||
public static async fromId(id: string | null): Promise<Application | null> {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await Application.fromSql(eq(Applications.id, id));
|
||||
}
|
||||
|
||||
public static async fromIds(ids: string[]): Promise<Application[]> {
|
||||
return await Application.manyFromSql(inArray(Applications.id, ids));
|
||||
}
|
||||
|
||||
public static async fromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(Applications.id),
|
||||
): Promise<Application | null> {
|
||||
const found = await db.query.Applications.findFirst({
|
||||
where: sql,
|
||||
orderBy,
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
return null;
|
||||
}
|
||||
return new Application(found);
|
||||
}
|
||||
|
||||
public static async manyFromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(Applications.id),
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
extra?: Parameters<typeof db.query.Applications.findMany>[0],
|
||||
): Promise<Application[]> {
|
||||
const found = await db.query.Applications.findMany({
|
||||
where: sql,
|
||||
orderBy,
|
||||
limit,
|
||||
offset,
|
||||
with: extra?.with,
|
||||
});
|
||||
|
||||
return found.map((s) => new Application(s));
|
||||
}
|
||||
|
||||
public static async getFromToken(
|
||||
token: string,
|
||||
): Promise<Application | null> {
|
||||
const result = await Token.fromAccessToken(token);
|
||||
|
||||
return result?.data.application
|
||||
? new Application(result.data.application)
|
||||
: null;
|
||||
}
|
||||
|
||||
public static fromClientId(clientId: string): Promise<Application | null> {
|
||||
return Application.fromSql(eq(Applications.clientId, clientId));
|
||||
}
|
||||
|
||||
public async update(
|
||||
newApplication: Partial<ApplicationType>,
|
||||
): Promise<ApplicationType> {
|
||||
await db
|
||||
.update(Applications)
|
||||
.set(newApplication)
|
||||
.where(eq(Applications.id, this.id));
|
||||
|
||||
const updated = await Application.fromId(this.data.id);
|
||||
|
||||
if (!updated) {
|
||||
throw new Error("Failed to update application");
|
||||
}
|
||||
|
||||
this.data = updated.data;
|
||||
return updated.data;
|
||||
}
|
||||
|
||||
public save(): Promise<ApplicationType> {
|
||||
return this.update(this.data);
|
||||
}
|
||||
|
||||
public async delete(ids?: string[]): Promise<void> {
|
||||
if (Array.isArray(ids)) {
|
||||
await db.delete(Applications).where(inArray(Applications.id, ids));
|
||||
} else {
|
||||
await db.delete(Applications).where(eq(Applications.id, this.id));
|
||||
}
|
||||
}
|
||||
|
||||
public static async insert(
|
||||
data: InferInsertModel<typeof Applications>,
|
||||
): Promise<Application> {
|
||||
const inserted = (
|
||||
await db.insert(Applications).values(data).returning()
|
||||
)[0];
|
||||
|
||||
const application = await Application.fromId(inserted.id);
|
||||
|
||||
if (!application) {
|
||||
throw new Error("Failed to insert application");
|
||||
}
|
||||
|
||||
return application;
|
||||
}
|
||||
|
||||
public get id(): string {
|
||||
return this.data.id;
|
||||
}
|
||||
|
||||
public toApi(): z.infer<typeof ApplicationSchema> {
|
||||
return {
|
||||
name: this.data.name,
|
||||
website: this.data.website,
|
||||
scopes: this.data.scopes.split(" "),
|
||||
redirect_uri: this.data.redirectUri,
|
||||
redirect_uris: this.data.redirectUri.split("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
public toApiCredential(): z.infer<typeof CredentialApplication> {
|
||||
return {
|
||||
name: this.data.name,
|
||||
website: this.data.website,
|
||||
client_id: this.data.clientId,
|
||||
client_secret: this.data.secret,
|
||||
client_secret_expires_at: "0",
|
||||
scopes: this.data.scopes.split(" "),
|
||||
redirect_uri: this.data.redirectUri,
|
||||
redirect_uris: this.data.redirectUri.split("\n"),
|
||||
};
|
||||
}
|
||||
}
|
||||
54
packages/kit/db/base.ts
Normal file
54
packages/kit/db/base.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import type { InferModelFromColumns, InferSelectModel } from "drizzle-orm";
|
||||
import type { PgTableWithColumns } from "drizzle-orm/pg-core";
|
||||
|
||||
/**
|
||||
* BaseInterface is an abstract class that provides a common interface for all models.
|
||||
* It includes methods for saving, deleting, updating, and reloading data.
|
||||
*
|
||||
* @template Table - The type of the table with columns.
|
||||
* @template Columns - The type of the columns inferred from the table.
|
||||
*/
|
||||
export abstract class BaseInterface<
|
||||
// biome-ignore lint/suspicious/noExplicitAny: This is just an extended interface
|
||||
Table extends PgTableWithColumns<any>,
|
||||
Columns = InferModelFromColumns<Table["_"]["columns"]>,
|
||||
> {
|
||||
/**
|
||||
* Constructs a new instance of the BaseInterface.
|
||||
*
|
||||
* @param data - The data for the model.
|
||||
*/
|
||||
public constructor(public data: Columns) {}
|
||||
|
||||
/**
|
||||
* Saves the current state of the model to the database.
|
||||
*
|
||||
* @returns A promise that resolves with the saved model.
|
||||
*/
|
||||
public abstract save(): Promise<Columns>;
|
||||
|
||||
/**
|
||||
* Deletes the model from the database.
|
||||
*
|
||||
* @param ids - The ids of the models to delete. If not provided, the current model will be deleted.
|
||||
* @returns A promise that resolves when the deletion is complete.
|
||||
*/
|
||||
public abstract delete(ids?: string[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Updates the model with new data.
|
||||
*
|
||||
* @param newData - The new data for the model.
|
||||
* @returns A promise that resolves with the updated model.
|
||||
*/
|
||||
public abstract update(
|
||||
newData: Partial<InferSelectModel<Table>>,
|
||||
): Promise<Columns>;
|
||||
|
||||
/**
|
||||
* Reloads the model from the database.
|
||||
*
|
||||
* @returns A promise that resolves when the reloading is complete.
|
||||
*/
|
||||
public abstract reload(): Promise<void>;
|
||||
}
|
||||
240
packages/kit/db/emoji.ts
Normal file
240
packages/kit/db/emoji.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import {
|
||||
type CustomEmoji,
|
||||
emojiWithColonsRegex,
|
||||
emojiWithIdentifiersRegex,
|
||||
} from "@versia/client/schemas";
|
||||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import type { ImageContentFormatSchema } from "@versia/sdk/schemas";
|
||||
import { randomUUIDv7 } from "bun";
|
||||
import {
|
||||
and,
|
||||
desc,
|
||||
eq,
|
||||
type InferInsertModel,
|
||||
type InferSelectModel,
|
||||
inArray,
|
||||
isNull,
|
||||
type SQL,
|
||||
} from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
import { db } from "../tables/db.ts";
|
||||
import { Emojis, type Instances, type Medias } from "../tables/schema.ts";
|
||||
import { BaseInterface } from "./base.ts";
|
||||
import type { Instance } from "./instance.ts";
|
||||
import { Media } from "./media.ts";
|
||||
|
||||
type EmojiType = InferSelectModel<typeof Emojis> & {
|
||||
media: InferSelectModel<typeof Medias>;
|
||||
instance: InferSelectModel<typeof Instances> | null;
|
||||
};
|
||||
|
||||
export class Emoji extends BaseInterface<typeof Emojis, EmojiType> {
|
||||
public static $type: EmojiType;
|
||||
public media: Media;
|
||||
|
||||
public constructor(data: EmojiType) {
|
||||
super(data);
|
||||
this.media = new Media(data.media);
|
||||
}
|
||||
|
||||
public async reload(): Promise<void> {
|
||||
const reloaded = await Emoji.fromId(this.data.id);
|
||||
|
||||
if (!reloaded) {
|
||||
throw new Error("Failed to reload emoji");
|
||||
}
|
||||
|
||||
this.data = reloaded.data;
|
||||
}
|
||||
|
||||
public static async fromId(id: string | null): Promise<Emoji | null> {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await Emoji.fromSql(eq(Emojis.id, id));
|
||||
}
|
||||
|
||||
public static async fromIds(ids: string[]): Promise<Emoji[]> {
|
||||
return await Emoji.manyFromSql(inArray(Emojis.id, ids));
|
||||
}
|
||||
|
||||
public static async fromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(Emojis.id),
|
||||
): Promise<Emoji | null> {
|
||||
const found = await db.query.Emojis.findFirst({
|
||||
where: sql,
|
||||
orderBy,
|
||||
with: {
|
||||
instance: true,
|
||||
media: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
return null;
|
||||
}
|
||||
return new Emoji(found);
|
||||
}
|
||||
|
||||
public static async manyFromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(Emojis.id),
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
extra?: Parameters<typeof db.query.Emojis.findMany>[0],
|
||||
): Promise<Emoji[]> {
|
||||
const found = await db.query.Emojis.findMany({
|
||||
where: sql,
|
||||
orderBy,
|
||||
limit,
|
||||
offset,
|
||||
with: { ...extra?.with, instance: true, media: true },
|
||||
});
|
||||
|
||||
return found.map((s) => new Emoji(s));
|
||||
}
|
||||
|
||||
public async update(newEmoji: Partial<EmojiType>): Promise<EmojiType> {
|
||||
await db.update(Emojis).set(newEmoji).where(eq(Emojis.id, this.id));
|
||||
|
||||
const updated = await Emoji.fromId(this.data.id);
|
||||
|
||||
if (!updated) {
|
||||
throw new Error("Failed to update emoji");
|
||||
}
|
||||
|
||||
this.data = updated.data;
|
||||
return updated.data;
|
||||
}
|
||||
|
||||
public save(): Promise<EmojiType> {
|
||||
return this.update(this.data);
|
||||
}
|
||||
|
||||
public async delete(ids?: string[]): Promise<void> {
|
||||
if (Array.isArray(ids)) {
|
||||
await db.delete(Emojis).where(inArray(Emojis.id, ids));
|
||||
} else {
|
||||
await db.delete(Emojis).where(eq(Emojis.id, this.id));
|
||||
}
|
||||
}
|
||||
|
||||
public static async insert(
|
||||
data: InferInsertModel<typeof Emojis>,
|
||||
): Promise<Emoji> {
|
||||
const inserted = (await db.insert(Emojis).values(data).returning())[0];
|
||||
|
||||
const emoji = await Emoji.fromId(inserted.id);
|
||||
|
||||
if (!emoji) {
|
||||
throw new Error("Failed to insert emoji");
|
||||
}
|
||||
|
||||
return emoji;
|
||||
}
|
||||
|
||||
public static async fetchFromRemote(
|
||||
emojiToFetch: {
|
||||
name: string;
|
||||
url: z.infer<typeof ImageContentFormatSchema>;
|
||||
},
|
||||
instance: Instance,
|
||||
): Promise<Emoji> {
|
||||
const existingEmoji = await Emoji.fromSql(
|
||||
and(
|
||||
eq(Emojis.shortcode, emojiToFetch.name),
|
||||
eq(Emojis.instanceId, instance.id),
|
||||
),
|
||||
);
|
||||
|
||||
if (existingEmoji) {
|
||||
return existingEmoji;
|
||||
}
|
||||
|
||||
return await Emoji.fromVersia(emojiToFetch, instance);
|
||||
}
|
||||
|
||||
public get id(): string {
|
||||
return this.data.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse emojis from text
|
||||
*
|
||||
* @param text The text to parse
|
||||
* @returns An array of emojis
|
||||
*/
|
||||
public static parseFromText(text: string): Promise<Emoji[]> {
|
||||
const matches = text.match(emojiWithColonsRegex);
|
||||
if (!matches || matches.length === 0) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
return Emoji.manyFromSql(
|
||||
and(
|
||||
inArray(
|
||||
Emojis.shortcode,
|
||||
matches.map((match) => match.replace(/:/g, "")),
|
||||
),
|
||||
isNull(Emojis.instanceId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public toApi(): z.infer<typeof CustomEmoji> {
|
||||
return {
|
||||
id: this.id,
|
||||
shortcode: this.data.shortcode,
|
||||
static_url: this.media.getUrl().proxied,
|
||||
url: this.media.getUrl().proxied,
|
||||
visible_in_picker: this.data.visibleInPicker,
|
||||
category: this.data.category,
|
||||
global: this.data.ownerId === null,
|
||||
description:
|
||||
this.media.data.content[this.media.getPreferredMimeType()]
|
||||
.description ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
public toVersia(): {
|
||||
name: string;
|
||||
url: z.infer<typeof ImageContentFormatSchema>;
|
||||
} {
|
||||
return {
|
||||
name: `:${this.data.shortcode}:`,
|
||||
url: this.media.toVersia().data as z.infer<
|
||||
typeof ImageContentFormatSchema
|
||||
>,
|
||||
};
|
||||
}
|
||||
|
||||
public static async fromVersia(
|
||||
emoji: {
|
||||
name: string;
|
||||
url: z.infer<typeof ImageContentFormatSchema>;
|
||||
},
|
||||
instance: Instance,
|
||||
): Promise<Emoji> {
|
||||
// Extracts the shortcode from the emoji name (e.g. :shortcode: -> shortcode)
|
||||
const shortcode = [...emoji.name.matchAll(emojiWithIdentifiersRegex)][0]
|
||||
.groups.shortcode;
|
||||
|
||||
if (!shortcode) {
|
||||
throw new Error("Could not extract shortcode from emoji name");
|
||||
}
|
||||
|
||||
const media = await Media.fromVersia(
|
||||
new VersiaEntities.ImageContentFormat(emoji.url),
|
||||
);
|
||||
|
||||
return Emoji.insert({
|
||||
id: randomUUIDv7(),
|
||||
shortcode,
|
||||
mediaId: media.id,
|
||||
visibleInPicker: true,
|
||||
instanceId: instance.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
15
packages/kit/db/index.ts
Normal file
15
packages/kit/db/index.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
export { db, setupDatabase } from "../tables/db.ts";
|
||||
export { Application } from "./application.ts";
|
||||
export { Emoji } from "./emoji.ts";
|
||||
export { Instance } from "./instance.ts";
|
||||
export { Like } from "./like.ts";
|
||||
export { Media } from "./media.ts";
|
||||
export { Note } from "./note.ts";
|
||||
export { Notification } from "./notification.ts";
|
||||
export { PushSubscription } from "./pushsubscription.ts";
|
||||
export { Reaction } from "./reaction.ts";
|
||||
export { Relationship } from "./relationship.ts";
|
||||
export { Role } from "./role.ts";
|
||||
export { Timeline } from "./timeline.ts";
|
||||
export { Token } from "./token.ts";
|
||||
export { User } from "./user.ts";
|
||||
369
packages/kit/db/instance.ts
Normal file
369
packages/kit/db/instance.ts
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import { FederationRequester } from "@versia/sdk/http";
|
||||
import { config } from "@versia-server/config";
|
||||
import {
|
||||
federationMessagingLogger,
|
||||
federationResolversLogger,
|
||||
} from "@versia-server/logging";
|
||||
import { randomUUIDv7 } from "bun";
|
||||
import chalk from "chalk";
|
||||
import {
|
||||
desc,
|
||||
eq,
|
||||
type InferInsertModel,
|
||||
type InferSelectModel,
|
||||
inArray,
|
||||
type SQL,
|
||||
} from "drizzle-orm";
|
||||
import { ApiError } from "../api-error.ts";
|
||||
import { db } from "../tables/db.ts";
|
||||
import { Instances } from "../tables/schema.ts";
|
||||
import { BaseInterface } from "./base.ts";
|
||||
import type { User } from "./user.ts";
|
||||
|
||||
type InstanceType = InferSelectModel<typeof Instances>;
|
||||
|
||||
export class Instance extends BaseInterface<typeof Instances> {
|
||||
public static $type: InstanceType;
|
||||
|
||||
public async reload(): Promise<void> {
|
||||
const reloaded = await Instance.fromId(this.data.id);
|
||||
|
||||
if (!reloaded) {
|
||||
throw new Error("Failed to reload instance");
|
||||
}
|
||||
|
||||
this.data = reloaded.data;
|
||||
}
|
||||
|
||||
public static async fromId(id: string | null): Promise<Instance | null> {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await Instance.fromSql(eq(Instances.id, id));
|
||||
}
|
||||
|
||||
public static async fromIds(ids: string[]): Promise<Instance[]> {
|
||||
return await Instance.manyFromSql(inArray(Instances.id, ids));
|
||||
}
|
||||
|
||||
public static async fromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(Instances.id),
|
||||
): Promise<Instance | null> {
|
||||
const found = await db.query.Instances.findFirst({
|
||||
where: sql,
|
||||
orderBy,
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
return null;
|
||||
}
|
||||
return new Instance(found);
|
||||
}
|
||||
|
||||
public static async manyFromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(Instances.id),
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
extra?: Parameters<typeof db.query.Instances.findMany>[0],
|
||||
): Promise<Instance[]> {
|
||||
const found = await db.query.Instances.findMany({
|
||||
where: sql,
|
||||
orderBy,
|
||||
limit,
|
||||
offset,
|
||||
with: extra?.with,
|
||||
});
|
||||
|
||||
return found.map((s) => new Instance(s));
|
||||
}
|
||||
|
||||
public async update(
|
||||
newInstance: Partial<InstanceType>,
|
||||
): Promise<InstanceType> {
|
||||
await db
|
||||
.update(Instances)
|
||||
.set(newInstance)
|
||||
.where(eq(Instances.id, this.id));
|
||||
|
||||
const updated = await Instance.fromId(this.data.id);
|
||||
|
||||
if (!updated) {
|
||||
throw new Error("Failed to update instance");
|
||||
}
|
||||
|
||||
this.data = updated.data;
|
||||
return updated.data;
|
||||
}
|
||||
|
||||
public save(): Promise<InstanceType> {
|
||||
return this.update(this.data);
|
||||
}
|
||||
|
||||
public async delete(ids?: string[]): Promise<void> {
|
||||
if (Array.isArray(ids)) {
|
||||
await db.delete(Instances).where(inArray(Instances.id, ids));
|
||||
} else {
|
||||
await db.delete(Instances).where(eq(Instances.id, this.id));
|
||||
}
|
||||
}
|
||||
|
||||
public static async fromUser(user: User): Promise<Instance | null> {
|
||||
if (!user.data.instanceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await Instance.fromId(user.data.instanceId);
|
||||
}
|
||||
|
||||
public static async insert(
|
||||
data: InferInsertModel<typeof Instances>,
|
||||
): Promise<Instance> {
|
||||
const inserted = (
|
||||
await db.insert(Instances).values(data).returning()
|
||||
)[0];
|
||||
|
||||
const instance = await Instance.fromId(inserted.id);
|
||||
|
||||
if (!instance) {
|
||||
throw new Error("Failed to insert instance");
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
public get id(): string {
|
||||
return this.data.id;
|
||||
}
|
||||
|
||||
public static async fetchMetadata(url: URL): Promise<{
|
||||
metadata: VersiaEntities.InstanceMetadata;
|
||||
protocol: "versia" | "activitypub";
|
||||
}> {
|
||||
const origin = new URL(url).origin;
|
||||
const wellKnownUrl = new URL("/.well-known/versia", origin);
|
||||
|
||||
try {
|
||||
const metadata = await new FederationRequester(
|
||||
config.instance.keys.private,
|
||||
config.http.base_url,
|
||||
).fetchEntity(wellKnownUrl, VersiaEntities.InstanceMetadata);
|
||||
|
||||
return { metadata, protocol: "versia" };
|
||||
} catch {
|
||||
// If the server doesn't have a Versia well-known endpoint, it's not a Versia instance
|
||||
// Try to resolve ActivityPub metadata instead
|
||||
const data = await Instance.fetchActivityPubMetadata(url);
|
||||
|
||||
if (!data) {
|
||||
throw new ApiError(
|
||||
404,
|
||||
`Instance at ${origin} is not reachable or does not exist`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
metadata: data,
|
||||
protocol: "activitypub",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static async fetchActivityPubMetadata(
|
||||
url: URL,
|
||||
): Promise<VersiaEntities.InstanceMetadata | null> {
|
||||
const origin = new URL(url).origin;
|
||||
const wellKnownUrl = new URL("/.well-known/nodeinfo", origin);
|
||||
|
||||
// Go to endpoint, then follow the links to the actual metadata
|
||||
try {
|
||||
const { json, ok, status } = await fetch(wellKnownUrl, {
|
||||
// @ts-expect-error Bun extension
|
||||
proxy: config.http.proxy_address,
|
||||
});
|
||||
|
||||
if (!ok) {
|
||||
federationResolversLogger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
|
||||
origin,
|
||||
)} - HTTP ${status}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
const wellKnown = (await json()) as {
|
||||
links: { rel: string; href: string }[];
|
||||
};
|
||||
|
||||
if (!wellKnown.links) {
|
||||
federationResolversLogger.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) {
|
||||
federationResolversLogger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
|
||||
origin,
|
||||
)} - No metadata URL found`;
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
json: json2,
|
||||
ok: ok2,
|
||||
status: status2,
|
||||
} = await fetch(metadataUrl.href, {
|
||||
// @ts-expect-error Bun extension
|
||||
proxy: config.http.proxy_address,
|
||||
});
|
||||
|
||||
if (!ok2) {
|
||||
federationResolversLogger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
|
||||
origin,
|
||||
)} - HTTP ${status2}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadata = (await json2()) as {
|
||||
metadata: {
|
||||
nodeName?: string;
|
||||
title?: string;
|
||||
nodeDescription?: string;
|
||||
description?: string;
|
||||
};
|
||||
software: { version: string };
|
||||
};
|
||||
|
||||
return new VersiaEntities.InstanceMetadata({
|
||||
name:
|
||||
metadata.metadata.nodeName || metadata.metadata.title || "",
|
||||
description:
|
||||
metadata.metadata.nodeDescription ||
|
||||
metadata.metadata.description,
|
||||
type: "InstanceMetadata",
|
||||
software: {
|
||||
name: "Unknown ActivityPub software",
|
||||
version: metadata.software.version,
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
public_key: {
|
||||
key: "",
|
||||
algorithm: "ed25519",
|
||||
},
|
||||
host: new URL(url).host,
|
||||
compatibility: {
|
||||
extensions: [],
|
||||
versions: [],
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
federationResolversLogger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
|
||||
origin,
|
||||
)} - Error! ${error}`;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static resolveFromHost(host: string): Promise<Instance> {
|
||||
if (host.startsWith("http")) {
|
||||
const url = new URL(host);
|
||||
|
||||
return Instance.resolve(url);
|
||||
}
|
||||
|
||||
const url = new URL(`https://${host}`);
|
||||
|
||||
return Instance.resolve(url);
|
||||
}
|
||||
|
||||
public static async resolve(url: URL): Promise<Instance> {
|
||||
const host = url.host;
|
||||
|
||||
const existingInstance = await Instance.fromSql(
|
||||
eq(Instances.baseUrl, host),
|
||||
);
|
||||
|
||||
if (existingInstance) {
|
||||
return existingInstance;
|
||||
}
|
||||
|
||||
const output = await Instance.fetchMetadata(url);
|
||||
|
||||
const { metadata, protocol } = output;
|
||||
|
||||
return Instance.insert({
|
||||
id: randomUUIDv7(),
|
||||
baseUrl: host,
|
||||
name: metadata.data.name,
|
||||
version: metadata.data.software.version,
|
||||
logo: metadata.data.logo,
|
||||
protocol,
|
||||
publicKey: metadata.data.public_key,
|
||||
inbox: metadata.data.shared_inbox ?? null,
|
||||
extensions: metadata.data.extensions ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
public async updateFromRemote(): Promise<Instance> {
|
||||
const output = await Instance.fetchMetadata(
|
||||
new URL(`https://${this.data.baseUrl}`),
|
||||
);
|
||||
|
||||
if (!output) {
|
||||
federationResolversLogger.error`Failed to update instance ${chalk.bold(
|
||||
this.data.baseUrl,
|
||||
)}`;
|
||||
throw new Error("Failed to update instance");
|
||||
}
|
||||
|
||||
const { metadata, protocol } = output;
|
||||
|
||||
await this.update({
|
||||
name: metadata.data.name,
|
||||
version: metadata.data.software.version,
|
||||
logo: metadata.data.logo,
|
||||
protocol,
|
||||
publicKey: metadata.data.public_key,
|
||||
inbox: metadata.data.shared_inbox ?? null,
|
||||
extensions: metadata.data.extensions ?? null,
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public async sendMessage(content: string): Promise<void> {
|
||||
if (
|
||||
!this.data.extensions?.["pub.versia:instance_messaging"]?.endpoint
|
||||
) {
|
||||
federationMessagingLogger.info`Instance ${chalk.gray(
|
||||
this.data.baseUrl,
|
||||
)} does not support Instance Messaging, skipping message`;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const endpoint = new URL(
|
||||
this.data.extensions["pub.versia:instance_messaging"].endpoint,
|
||||
);
|
||||
|
||||
await fetch(endpoint.href, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
body: content,
|
||||
});
|
||||
}
|
||||
|
||||
public static getCount(): Promise<number> {
|
||||
return db.$count(Instances);
|
||||
}
|
||||
}
|
||||
182
packages/kit/db/like.ts
Normal file
182
packages/kit/db/like.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import { config } from "@versia-server/config";
|
||||
import {
|
||||
and,
|
||||
desc,
|
||||
eq,
|
||||
type InferInsertModel,
|
||||
type InferSelectModel,
|
||||
inArray,
|
||||
type SQL,
|
||||
} from "drizzle-orm";
|
||||
import { db } from "../tables/db.ts";
|
||||
import {
|
||||
Likes,
|
||||
type Notes,
|
||||
Notifications,
|
||||
type Users,
|
||||
} from "../tables/schema.ts";
|
||||
import { BaseInterface } from "./base.ts";
|
||||
import { User } from "./user.ts";
|
||||
|
||||
type LikeType = InferSelectModel<typeof Likes> & {
|
||||
liker: InferSelectModel<typeof Users>;
|
||||
liked: InferSelectModel<typeof Notes>;
|
||||
};
|
||||
|
||||
export class Like extends BaseInterface<typeof Likes, LikeType> {
|
||||
public static $type: LikeType;
|
||||
|
||||
public async reload(): Promise<void> {
|
||||
const reloaded = await Like.fromId(this.data.id);
|
||||
|
||||
if (!reloaded) {
|
||||
throw new Error("Failed to reload like");
|
||||
}
|
||||
|
||||
this.data = reloaded.data;
|
||||
}
|
||||
|
||||
public static async fromId(id: string | null): Promise<Like | null> {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await Like.fromSql(eq(Likes.id, id));
|
||||
}
|
||||
|
||||
public static async fromIds(ids: string[]): Promise<Like[]> {
|
||||
return await Like.manyFromSql(inArray(Likes.id, ids));
|
||||
}
|
||||
|
||||
public static async fromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(Likes.id),
|
||||
): Promise<Like | null> {
|
||||
const found = await db.query.Likes.findFirst({
|
||||
where: sql,
|
||||
orderBy,
|
||||
with: {
|
||||
liked: true,
|
||||
liker: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
return null;
|
||||
}
|
||||
return new Like(found);
|
||||
}
|
||||
|
||||
public static async manyFromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(Likes.id),
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
extra?: Parameters<typeof db.query.Likes.findMany>[0],
|
||||
): Promise<Like[]> {
|
||||
const found = await db.query.Likes.findMany({
|
||||
where: sql,
|
||||
orderBy,
|
||||
limit,
|
||||
offset,
|
||||
with: {
|
||||
liked: true,
|
||||
liker: true,
|
||||
...extra?.with,
|
||||
},
|
||||
});
|
||||
|
||||
return found.map((s) => new Like(s));
|
||||
}
|
||||
|
||||
public async update(newRole: Partial<LikeType>): Promise<LikeType> {
|
||||
await db.update(Likes).set(newRole).where(eq(Likes.id, this.id));
|
||||
|
||||
const updated = await Like.fromId(this.data.id);
|
||||
|
||||
if (!updated) {
|
||||
throw new Error("Failed to update like");
|
||||
}
|
||||
|
||||
return updated.data;
|
||||
}
|
||||
|
||||
public save(): Promise<LikeType> {
|
||||
return this.update(this.data);
|
||||
}
|
||||
|
||||
public async delete(): Promise<void> {
|
||||
await db.delete(Likes).where(eq(Likes.id, this.id));
|
||||
}
|
||||
|
||||
public static async insert(
|
||||
data: InferInsertModel<typeof Likes>,
|
||||
): Promise<Like> {
|
||||
const inserted = (await db.insert(Likes).values(data).returning())[0];
|
||||
|
||||
const like = await Like.fromId(inserted.id);
|
||||
|
||||
if (!like) {
|
||||
throw new Error("Failed to insert like");
|
||||
}
|
||||
|
||||
return like;
|
||||
}
|
||||
|
||||
public get id(): string {
|
||||
return this.data.id;
|
||||
}
|
||||
|
||||
public async clearRelatedNotifications(): Promise<void> {
|
||||
await db
|
||||
.delete(Notifications)
|
||||
.where(
|
||||
and(
|
||||
eq(Notifications.accountId, this.id),
|
||||
eq(Notifications.type, "favourite"),
|
||||
eq(Notifications.notifiedId, this.data.liked.authorId),
|
||||
eq(Notifications.noteId, this.data.liked.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public getUri(): URL {
|
||||
return new URL(`/likes/${this.data.id}`, config.http.base_url);
|
||||
}
|
||||
|
||||
public toVersia(): VersiaEntities.Like {
|
||||
return new VersiaEntities.Like({
|
||||
id: this.data.id,
|
||||
author: User.getUri(
|
||||
this.data.liker.id,
|
||||
this.data.liker.uri ? new URL(this.data.liker.uri) : null,
|
||||
).href,
|
||||
type: "pub.versia:likes/Like",
|
||||
created_at: new Date(this.data.createdAt).toISOString(),
|
||||
liked: this.data.liked.uri
|
||||
? new URL(this.data.liked.uri).href
|
||||
: new URL(`/notes/${this.data.liked.id}`, config.http.base_url)
|
||||
.href,
|
||||
uri: this.getUri().href,
|
||||
});
|
||||
}
|
||||
|
||||
public unlikeToVersia(unliker?: User): VersiaEntities.Delete {
|
||||
return new VersiaEntities.Delete({
|
||||
type: "Delete",
|
||||
id: crypto.randomUUID(),
|
||||
created_at: new Date().toISOString(),
|
||||
author: User.getUri(
|
||||
unliker?.id ?? this.data.liker.id,
|
||||
unliker?.data.uri
|
||||
? new URL(unliker.data.uri)
|
||||
: this.data.liker.uri
|
||||
? new URL(this.data.liker.uri)
|
||||
: null,
|
||||
).href,
|
||||
deleted_type: "pub.versia:likes/Like",
|
||||
deleted: this.getUri().href,
|
||||
});
|
||||
}
|
||||
}
|
||||
554
packages/kit/db/media.ts
Normal file
554
packages/kit/db/media.ts
Normal file
|
|
@ -0,0 +1,554 @@
|
|||
import { join } from "node:path";
|
||||
import type { Attachment as AttachmentSchema } from "@versia/client/schemas";
|
||||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import type {
|
||||
ContentFormatSchema,
|
||||
ImageContentFormatSchema,
|
||||
} from "@versia/sdk/schemas";
|
||||
import { config, MediaBackendType, ProxiableUrl } from "@versia-server/config";
|
||||
import { randomUUIDv7, S3Client, SHA256, write } from "bun";
|
||||
import {
|
||||
desc,
|
||||
eq,
|
||||
type InferInsertModel,
|
||||
type InferSelectModel,
|
||||
inArray,
|
||||
type SQL,
|
||||
} from "drizzle-orm";
|
||||
import sharp from "sharp";
|
||||
import type { z } from "zod";
|
||||
import { mimeLookup } from "@/content_types.ts";
|
||||
import { getMediaHash } from "../../../classes/media/media-hasher.ts";
|
||||
import { ApiError } from "../api-error.ts";
|
||||
import { MediaJobType, mediaQueue } from "../queues/media/queue.ts";
|
||||
import { db } from "../tables/db.ts";
|
||||
import { Medias } from "../tables/schema.ts";
|
||||
import { BaseInterface } from "./base.ts";
|
||||
|
||||
type MediaType = InferSelectModel<typeof Medias>;
|
||||
|
||||
export class Media extends BaseInterface<typeof Medias> {
|
||||
public static $type: MediaType;
|
||||
|
||||
public async reload(): Promise<void> {
|
||||
const reloaded = await Media.fromId(this.data.id);
|
||||
|
||||
if (!reloaded) {
|
||||
throw new Error("Failed to reload attachment");
|
||||
}
|
||||
|
||||
this.data = reloaded.data;
|
||||
}
|
||||
|
||||
public static async fromId(id: string | null): Promise<Media | null> {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await Media.fromSql(eq(Medias.id, id));
|
||||
}
|
||||
|
||||
public static async fromIds(ids: string[]): Promise<Media[]> {
|
||||
return await Media.manyFromSql(inArray(Medias.id, ids));
|
||||
}
|
||||
|
||||
public static async fromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(Medias.id),
|
||||
): Promise<Media | null> {
|
||||
const found = await db.query.Medias.findFirst({
|
||||
where: sql,
|
||||
orderBy,
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
return null;
|
||||
}
|
||||
return new Media(found);
|
||||
}
|
||||
|
||||
public static async manyFromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(Medias.id),
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
extra?: Parameters<typeof db.query.Medias.findMany>[0],
|
||||
): Promise<Media[]> {
|
||||
const found = await db.query.Medias.findMany({
|
||||
where: sql,
|
||||
orderBy,
|
||||
limit,
|
||||
offset,
|
||||
with: extra?.with,
|
||||
});
|
||||
|
||||
return found.map((s) => new Media(s));
|
||||
}
|
||||
|
||||
public async update(newAttachment: Partial<MediaType>): Promise<MediaType> {
|
||||
await db
|
||||
.update(Medias)
|
||||
.set(newAttachment)
|
||||
.where(eq(Medias.id, this.id));
|
||||
|
||||
const updated = await Media.fromId(this.data.id);
|
||||
|
||||
if (!updated) {
|
||||
throw new Error("Failed to update attachment");
|
||||
}
|
||||
|
||||
this.data = updated.data;
|
||||
return updated.data;
|
||||
}
|
||||
|
||||
public save(): Promise<MediaType> {
|
||||
return this.update(this.data);
|
||||
}
|
||||
|
||||
public async delete(ids?: string[]): Promise<void> {
|
||||
if (Array.isArray(ids)) {
|
||||
await db.delete(Medias).where(inArray(Medias.id, ids));
|
||||
} else {
|
||||
await db.delete(Medias).where(eq(Medias.id, this.id));
|
||||
}
|
||||
|
||||
// TODO: Also delete the file from the media manager
|
||||
}
|
||||
|
||||
public static async insert(
|
||||
data: InferInsertModel<typeof Medias>,
|
||||
): Promise<Media> {
|
||||
const inserted = (await db.insert(Medias).values(data).returning())[0];
|
||||
|
||||
const attachment = await Media.fromId(inserted.id);
|
||||
|
||||
if (!attachment) {
|
||||
throw new Error("Failed to insert attachment");
|
||||
}
|
||||
|
||||
return attachment;
|
||||
}
|
||||
|
||||
private static async upload(file: File): Promise<{
|
||||
path: string;
|
||||
}> {
|
||||
const fileName = file.name ?? randomUUIDv7();
|
||||
const hash = await getMediaHash(file);
|
||||
|
||||
switch (config.media.backend) {
|
||||
case MediaBackendType.Local: {
|
||||
const path = join(config.media.uploads_path, hash, fileName);
|
||||
|
||||
await write(path, file);
|
||||
|
||||
return { path: join(hash, fileName) };
|
||||
}
|
||||
|
||||
case MediaBackendType.S3: {
|
||||
const path = join(hash, fileName);
|
||||
|
||||
if (!config.s3) {
|
||||
throw new ApiError(500, "S3 configuration missing");
|
||||
}
|
||||
|
||||
const client = new S3Client({
|
||||
endpoint: config.s3.endpoint.origin,
|
||||
region: config.s3.region,
|
||||
bucket: config.s3.bucket_name,
|
||||
accessKeyId: config.s3.access_key,
|
||||
secretAccessKey: config.s3.secret_access_key,
|
||||
virtualHostedStyle: !config.s3.path_style,
|
||||
});
|
||||
|
||||
await client.write(path, file);
|
||||
const finalPath = config.s3.path
|
||||
? join(config.s3.path, path)
|
||||
: path;
|
||||
|
||||
return { path: finalPath };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static async fromFile(
|
||||
file: File,
|
||||
options?: {
|
||||
description?: string;
|
||||
thumbnail?: File;
|
||||
},
|
||||
): Promise<Media> {
|
||||
Media.checkFile(file);
|
||||
|
||||
const { path } = await Media.upload(file);
|
||||
|
||||
const url = Media.getUrl(path);
|
||||
|
||||
let thumbnailUrl: URL | null = null;
|
||||
|
||||
if (options?.thumbnail) {
|
||||
const { path } = await Media.upload(options.thumbnail);
|
||||
|
||||
thumbnailUrl = Media.getUrl(path);
|
||||
}
|
||||
|
||||
const content = await Media.fileToContentFormat(file, url, {
|
||||
description: options?.description,
|
||||
});
|
||||
const thumbnailContent =
|
||||
thumbnailUrl && options?.thumbnail
|
||||
? await Media.fileToContentFormat(
|
||||
options.thumbnail,
|
||||
thumbnailUrl,
|
||||
{
|
||||
description: options?.description,
|
||||
},
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const newAttachment = await Media.insert({
|
||||
id: randomUUIDv7(),
|
||||
content,
|
||||
thumbnail: thumbnailContent as z.infer<
|
||||
typeof ImageContentFormatSchema
|
||||
>,
|
||||
});
|
||||
|
||||
if (config.media.conversion.convert_images) {
|
||||
await mediaQueue.add(MediaJobType.ConvertMedia, {
|
||||
attachmentId: newAttachment.id,
|
||||
filename: file.name,
|
||||
});
|
||||
}
|
||||
|
||||
await mediaQueue.add(MediaJobType.CalculateMetadata, {
|
||||
attachmentId: newAttachment.id,
|
||||
filename: file.name,
|
||||
});
|
||||
|
||||
return newAttachment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and adds a new media attachment from a URL
|
||||
* @param uri
|
||||
* @param options
|
||||
* @returns
|
||||
*/
|
||||
public static async fromUrl(
|
||||
uri: URL,
|
||||
options?: {
|
||||
description?: string;
|
||||
},
|
||||
): Promise<Media> {
|
||||
const mimeType = await mimeLookup(uri);
|
||||
|
||||
const content: z.infer<typeof ContentFormatSchema> = {
|
||||
[mimeType]: {
|
||||
content: uri.toString(),
|
||||
remote: true,
|
||||
description: options?.description,
|
||||
},
|
||||
};
|
||||
|
||||
const newAttachment = await Media.insert({
|
||||
id: randomUUIDv7(),
|
||||
content,
|
||||
});
|
||||
|
||||
await mediaQueue.add(MediaJobType.CalculateMetadata, {
|
||||
attachmentId: newAttachment.id,
|
||||
// CalculateMetadata doesn't use the filename, but the type is annoying
|
||||
// and requires it anyway
|
||||
filename: "blank",
|
||||
});
|
||||
|
||||
return newAttachment;
|
||||
}
|
||||
|
||||
private static checkFile(file: File): void {
|
||||
if (file.size > config.validation.media.max_bytes) {
|
||||
throw new ApiError(
|
||||
413,
|
||||
`File too large, max size is ${config.validation.media.max_bytes} bytes`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
config.validation.media.allowed_mime_types.length > 0 &&
|
||||
!config.validation.media.allowed_mime_types.includes(file.type)
|
||||
) {
|
||||
throw new ApiError(
|
||||
415,
|
||||
`File type ${file.type} is not allowed`,
|
||||
`Allowed types: ${config.validation.media.allowed_mime_types.join(
|
||||
", ",
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async updateFromFile(file: File): Promise<void> {
|
||||
Media.checkFile(file);
|
||||
|
||||
const { path } = await Media.upload(file);
|
||||
|
||||
const url = Media.getUrl(path);
|
||||
|
||||
const content = await Media.fileToContentFormat(file, url, {
|
||||
description:
|
||||
this.data.content[Object.keys(this.data.content)[0]]
|
||||
.description || undefined,
|
||||
});
|
||||
|
||||
await this.update({
|
||||
content,
|
||||
});
|
||||
|
||||
await mediaQueue.add(MediaJobType.CalculateMetadata, {
|
||||
attachmentId: this.id,
|
||||
filename: file.name,
|
||||
});
|
||||
}
|
||||
|
||||
public async updateFromUrl(uri: URL): Promise<void> {
|
||||
const mimeType = await mimeLookup(uri);
|
||||
|
||||
const content: z.infer<typeof ContentFormatSchema> = {
|
||||
[mimeType]: {
|
||||
content: uri.toString(),
|
||||
remote: true,
|
||||
description:
|
||||
this.data.content[Object.keys(this.data.content)[0]]
|
||||
.description || undefined,
|
||||
},
|
||||
};
|
||||
|
||||
await this.update({
|
||||
content,
|
||||
});
|
||||
|
||||
await mediaQueue.add(MediaJobType.CalculateMetadata, {
|
||||
attachmentId: this.id,
|
||||
filename: "blank",
|
||||
});
|
||||
}
|
||||
|
||||
public async updateThumbnail(file: File): Promise<void> {
|
||||
Media.checkFile(file);
|
||||
|
||||
const { path } = await Media.upload(file);
|
||||
|
||||
const url = Media.getUrl(path);
|
||||
|
||||
const content = await Media.fileToContentFormat(file, url);
|
||||
|
||||
await this.update({
|
||||
thumbnail: content as z.infer<typeof ImageContentFormatSchema>,
|
||||
});
|
||||
}
|
||||
|
||||
public async updateMetadata(
|
||||
metadata: Partial<
|
||||
Omit<
|
||||
z.infer<typeof ContentFormatSchema>[keyof z.infer<
|
||||
typeof ContentFormatSchema
|
||||
>],
|
||||
"content"
|
||||
>
|
||||
>,
|
||||
): Promise<void> {
|
||||
const content = this.data.content;
|
||||
|
||||
for (const type of Object.keys(content)) {
|
||||
content[type] = {
|
||||
...content[type],
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
await this.update({
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
public get id(): string {
|
||||
return this.data.id;
|
||||
}
|
||||
|
||||
public static getUrl(name: string): URL {
|
||||
if (config.media.backend === MediaBackendType.Local) {
|
||||
return new URL(`/media/${name}`, config.http.base_url);
|
||||
}
|
||||
if (config.media.backend === MediaBackendType.S3) {
|
||||
return new URL(`/${name}`, config.s3?.public_url);
|
||||
}
|
||||
|
||||
throw new Error("Unknown media backend");
|
||||
}
|
||||
|
||||
public getUrl(): ProxiableUrl {
|
||||
const type = this.getPreferredMimeType();
|
||||
|
||||
return new ProxiableUrl(this.data.content[type]?.content ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets favourite MIME type for the attachment
|
||||
* Uses a hardcoded list of preferred types, for images
|
||||
*
|
||||
* @returns {string} Preferred MIME type
|
||||
*/
|
||||
public getPreferredMimeType(): string {
|
||||
return Media.getPreferredMimeType(Object.keys(this.data.content));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets favourite MIME type from a list
|
||||
* Uses a hardcoded list of preferred types, for images
|
||||
*
|
||||
* @returns {string} Preferred MIME type
|
||||
*/
|
||||
public static getPreferredMimeType(types: string[]): string {
|
||||
const ranking = [
|
||||
"image/svg+xml",
|
||||
"image/avif",
|
||||
"image/jxl",
|
||||
"image/webp",
|
||||
"image/heif",
|
||||
"image/heif-sequence",
|
||||
"image/heic",
|
||||
"image/heic-sequence",
|
||||
"image/apng",
|
||||
"image/gif",
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/bmp",
|
||||
];
|
||||
|
||||
return ranking.find((type) => types.includes(type)) ?? types[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps MIME type to Mastodon attachment type
|
||||
*
|
||||
* @returns
|
||||
*/
|
||||
public getMastodonType(): z.infer<typeof AttachmentSchema.shape.type> {
|
||||
const type = this.getPreferredMimeType();
|
||||
|
||||
if (type.startsWith("image/")) {
|
||||
return "image";
|
||||
}
|
||||
if (type.startsWith("video/")) {
|
||||
return "video";
|
||||
}
|
||||
if (type.startsWith("audio/")) {
|
||||
return "audio";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts metadata from a file and outputs as ContentFormat
|
||||
*
|
||||
* Does not calculate thumbhash (do this in a worker)
|
||||
* @param file
|
||||
* @param uri Uploaded file URI
|
||||
* @param options Extra metadata, such as description
|
||||
* @returns
|
||||
*/
|
||||
public static async fileToContentFormat(
|
||||
file: File,
|
||||
uri: URL,
|
||||
options?: Partial<{
|
||||
description: string;
|
||||
}>,
|
||||
): Promise<z.infer<typeof ContentFormatSchema>> {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const isImage = file.type.startsWith("image/");
|
||||
const { width, height } = isImage ? await sharp(buffer).metadata() : {};
|
||||
const hash = new SHA256().update(file).digest("hex");
|
||||
|
||||
// Missing: fps, duration
|
||||
// Thumbhash should be added in a worker after the file is uploaded
|
||||
return {
|
||||
[file.type]: {
|
||||
content: uri.toString(),
|
||||
remote: true,
|
||||
hash: {
|
||||
sha256: hash,
|
||||
},
|
||||
width,
|
||||
height,
|
||||
description: options?.description,
|
||||
size: file.size,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public toApiMeta(): z.infer<typeof AttachmentSchema.shape.meta> {
|
||||
const type = this.getPreferredMimeType();
|
||||
const data = this.data.content[type];
|
||||
const size =
|
||||
data.width && data.height
|
||||
? `${data.width}x${data.height}`
|
||||
: undefined;
|
||||
const aspect =
|
||||
data.width && data.height ? data.width / data.height : undefined;
|
||||
|
||||
return {
|
||||
width: data.width || undefined,
|
||||
height: data.height || undefined,
|
||||
fps: data.fps || undefined,
|
||||
size,
|
||||
// Idk whether size or length is the right value
|
||||
duration: data.duration || undefined,
|
||||
// Versia doesn't have a concept of length in ContentFormat
|
||||
length: undefined,
|
||||
aspect,
|
||||
original: {
|
||||
width: data.width || undefined,
|
||||
height: data.height || undefined,
|
||||
size,
|
||||
aspect,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public toApi(): z.infer<typeof AttachmentSchema> {
|
||||
const type = this.getPreferredMimeType();
|
||||
const data = this.data.content[type];
|
||||
|
||||
// Thumbnail should only have a single MIME type
|
||||
const thumbnailData =
|
||||
this.data.thumbnail?.[Object.keys(this.data.thumbnail)[0]];
|
||||
|
||||
return {
|
||||
id: this.data.id,
|
||||
type: this.getMastodonType(),
|
||||
url: this.getUrl().proxied,
|
||||
remote_url: null,
|
||||
preview_url: thumbnailData?.content
|
||||
? new ProxiableUrl(thumbnailData.content).proxied
|
||||
: null,
|
||||
meta: this.toApiMeta(),
|
||||
description: data.description || null,
|
||||
blurhash: this.data.blurhash,
|
||||
};
|
||||
}
|
||||
|
||||
public toVersia(): VersiaEntities.ContentFormat {
|
||||
return new VersiaEntities.ContentFormat(this.data.content);
|
||||
}
|
||||
|
||||
public static fromVersia(
|
||||
contentFormat: VersiaEntities.ContentFormat,
|
||||
): Promise<Media> {
|
||||
return Media.insert({
|
||||
id: randomUUIDv7(),
|
||||
content: contentFormat.data,
|
||||
originalContent: contentFormat.data,
|
||||
});
|
||||
}
|
||||
}
|
||||
1502
packages/kit/db/note.ts
Normal file
1502
packages/kit/db/note.ts
Normal file
File diff suppressed because it is too large
Load diff
198
packages/kit/db/notification.ts
Normal file
198
packages/kit/db/notification.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import type { Notification as NotificationSchema } from "@versia/client/schemas";
|
||||
import {
|
||||
desc,
|
||||
eq,
|
||||
type InferInsertModel,
|
||||
type InferSelectModel,
|
||||
inArray,
|
||||
type SQL,
|
||||
} from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
import { db } from "../tables/db.ts";
|
||||
import { Notifications } from "../tables/schema.ts";
|
||||
import { BaseInterface } from "./base.ts";
|
||||
import { Note } from "./note.ts";
|
||||
import {
|
||||
transformOutputToUserWithRelations,
|
||||
User,
|
||||
userRelations,
|
||||
} from "./user.ts";
|
||||
|
||||
export type NotificationType = InferSelectModel<typeof Notifications> & {
|
||||
status: typeof Note.$type | null;
|
||||
account: typeof User.$type;
|
||||
};
|
||||
|
||||
export class Notification extends BaseInterface<
|
||||
typeof Notifications,
|
||||
NotificationType
|
||||
> {
|
||||
public async reload(): Promise<void> {
|
||||
const reloaded = await Notification.fromId(this.data.id);
|
||||
|
||||
if (!reloaded) {
|
||||
throw new Error("Failed to reload notification");
|
||||
}
|
||||
|
||||
this.data = reloaded.data;
|
||||
}
|
||||
|
||||
public static async fromId(
|
||||
id: string | null,
|
||||
userId?: string,
|
||||
): Promise<Notification | null> {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await Notification.fromSql(
|
||||
eq(Notifications.id, id),
|
||||
undefined,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
public static async fromIds(
|
||||
ids: string[],
|
||||
userId?: string,
|
||||
): Promise<Notification[]> {
|
||||
return await Notification.manyFromSql(
|
||||
inArray(Notifications.id, ids),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
public static async fromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(Notifications.id),
|
||||
userId?: string,
|
||||
): Promise<Notification | null> {
|
||||
const found = await db.query.Notifications.findFirst({
|
||||
where: sql,
|
||||
orderBy,
|
||||
with: {
|
||||
account: {
|
||||
with: {
|
||||
...userRelations,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
return null;
|
||||
}
|
||||
return new Notification({
|
||||
...found,
|
||||
account: transformOutputToUserWithRelations(found.account),
|
||||
status: (await Note.fromId(found.noteId, userId))?.data ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
public static async manyFromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(Notifications.id),
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
extra?: Parameters<typeof db.query.Notifications.findMany>[0],
|
||||
userId?: string,
|
||||
): Promise<Notification[]> {
|
||||
const found = await db.query.Notifications.findMany({
|
||||
where: sql,
|
||||
orderBy,
|
||||
limit,
|
||||
offset,
|
||||
with: {
|
||||
...extra?.with,
|
||||
account: {
|
||||
with: {
|
||||
...userRelations,
|
||||
},
|
||||
},
|
||||
},
|
||||
extras: extra?.extras,
|
||||
});
|
||||
|
||||
return (
|
||||
await Promise.all(
|
||||
found.map(async (notif) => ({
|
||||
...notif,
|
||||
account: transformOutputToUserWithRelations(notif.account),
|
||||
status:
|
||||
(await Note.fromId(notif.noteId, userId))?.data ?? null,
|
||||
})),
|
||||
)
|
||||
).map((s) => new Notification(s));
|
||||
}
|
||||
|
||||
public async update(
|
||||
newAttachment: Partial<NotificationType>,
|
||||
): Promise<NotificationType> {
|
||||
await db
|
||||
.update(Notifications)
|
||||
.set(newAttachment)
|
||||
.where(eq(Notifications.id, this.id));
|
||||
|
||||
const updated = await Notification.fromId(this.data.id);
|
||||
|
||||
if (!updated) {
|
||||
throw new Error("Failed to update notification");
|
||||
}
|
||||
|
||||
this.data = updated.data;
|
||||
return updated.data;
|
||||
}
|
||||
|
||||
public save(): Promise<NotificationType> {
|
||||
return this.update(this.data);
|
||||
}
|
||||
|
||||
public async delete(ids?: string[]): Promise<void> {
|
||||
if (Array.isArray(ids)) {
|
||||
await db
|
||||
.delete(Notifications)
|
||||
.where(inArray(Notifications.id, ids));
|
||||
} else {
|
||||
await db.delete(Notifications).where(eq(Notifications.id, this.id));
|
||||
}
|
||||
}
|
||||
|
||||
public static async insert(
|
||||
data: InferInsertModel<typeof Notifications>,
|
||||
): Promise<Notification> {
|
||||
const inserted = (
|
||||
await db.insert(Notifications).values(data).returning()
|
||||
)[0];
|
||||
|
||||
const notification = await Notification.fromId(inserted.id);
|
||||
|
||||
if (!notification) {
|
||||
throw new Error("Failed to insert notification");
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
public get id(): string {
|
||||
return this.data.id;
|
||||
}
|
||||
|
||||
public async toApi(): Promise<z.infer<typeof NotificationSchema>> {
|
||||
const account = new User(this.data.account);
|
||||
|
||||
return {
|
||||
account: account.toApi(),
|
||||
created_at: new Date(this.data.createdAt).toISOString(),
|
||||
id: this.data.id,
|
||||
type: this.data.type,
|
||||
status: this.data.status
|
||||
? await new Note(this.data.status).toApi(account)
|
||||
: undefined,
|
||||
group_key: `ungrouped-${this.data.id}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
192
packages/kit/db/pushsubscription.ts
Normal file
192
packages/kit/db/pushsubscription.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import type { WebPushSubscription as WebPushSubscriptionSchema } from "@versia/client/schemas";
|
||||
import {
|
||||
desc,
|
||||
eq,
|
||||
type InferInsertModel,
|
||||
type InferSelectModel,
|
||||
inArray,
|
||||
type SQL,
|
||||
} from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
import { db } from "../tables/db.ts";
|
||||
import { PushSubscriptions, Tokens } from "../tables/schema.ts";
|
||||
import { BaseInterface } from "./base.ts";
|
||||
import type { Token } from "./token.ts";
|
||||
import type { User } from "./user.ts";
|
||||
|
||||
type PushSubscriptionType = InferSelectModel<typeof PushSubscriptions>;
|
||||
|
||||
export class PushSubscription extends BaseInterface<
|
||||
typeof PushSubscriptions,
|
||||
PushSubscriptionType
|
||||
> {
|
||||
public static $type: PushSubscriptionType;
|
||||
|
||||
public async reload(): Promise<void> {
|
||||
const reloaded = await PushSubscription.fromId(this.data.id);
|
||||
|
||||
if (!reloaded) {
|
||||
throw new Error("Failed to reload subscription");
|
||||
}
|
||||
|
||||
this.data = reloaded.data;
|
||||
}
|
||||
|
||||
public static async fromId(
|
||||
id: string | null,
|
||||
): Promise<PushSubscription | null> {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await PushSubscription.fromSql(eq(PushSubscriptions.id, id));
|
||||
}
|
||||
|
||||
public static async fromIds(ids: string[]): Promise<PushSubscription[]> {
|
||||
return await PushSubscription.manyFromSql(
|
||||
inArray(PushSubscriptions.id, ids),
|
||||
);
|
||||
}
|
||||
|
||||
public static async fromToken(
|
||||
token: Token,
|
||||
): Promise<PushSubscription | null> {
|
||||
return await PushSubscription.fromSql(
|
||||
eq(PushSubscriptions.tokenId, token.id),
|
||||
);
|
||||
}
|
||||
|
||||
public static async manyFromUser(
|
||||
user: User,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
): Promise<PushSubscription[]> {
|
||||
const found = await db
|
||||
.select()
|
||||
.from(PushSubscriptions)
|
||||
.leftJoin(Tokens, eq(Tokens.id, PushSubscriptions.tokenId))
|
||||
.where(eq(Tokens.userId, user.id))
|
||||
.limit(limit ?? 9e10)
|
||||
.offset(offset ?? 0);
|
||||
|
||||
return found.map((s) => new PushSubscription(s.PushSubscriptions));
|
||||
}
|
||||
|
||||
public static async fromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(PushSubscriptions.id),
|
||||
): Promise<PushSubscription | null> {
|
||||
const found = await db.query.PushSubscriptions.findFirst({
|
||||
where: sql,
|
||||
orderBy,
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
return null;
|
||||
}
|
||||
return new PushSubscription(found);
|
||||
}
|
||||
|
||||
public static async manyFromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(PushSubscriptions.id),
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
extra?: Parameters<typeof db.query.PushSubscriptions.findMany>[0],
|
||||
): Promise<PushSubscription[]> {
|
||||
const found = await db.query.PushSubscriptions.findMany({
|
||||
where: sql,
|
||||
orderBy,
|
||||
limit,
|
||||
offset,
|
||||
with: extra?.with,
|
||||
});
|
||||
|
||||
return found.map((s) => new PushSubscription(s));
|
||||
}
|
||||
|
||||
public async update(
|
||||
newSubscription: Partial<PushSubscriptionType>,
|
||||
): Promise<PushSubscriptionType> {
|
||||
await db
|
||||
.update(PushSubscriptions)
|
||||
.set(newSubscription)
|
||||
.where(eq(PushSubscriptions.id, this.id));
|
||||
|
||||
const updated = await PushSubscription.fromId(this.data.id);
|
||||
|
||||
if (!updated) {
|
||||
throw new Error("Failed to update subscription");
|
||||
}
|
||||
|
||||
this.data = updated.data;
|
||||
return updated.data;
|
||||
}
|
||||
|
||||
public save(): Promise<PushSubscriptionType> {
|
||||
return this.update(this.data);
|
||||
}
|
||||
|
||||
public static async clearAllOfToken(token: Token): Promise<void> {
|
||||
await db
|
||||
.delete(PushSubscriptions)
|
||||
.where(eq(PushSubscriptions.tokenId, token.id));
|
||||
}
|
||||
|
||||
public async delete(ids?: string[]): Promise<void> {
|
||||
if (Array.isArray(ids)) {
|
||||
await db
|
||||
.delete(PushSubscriptions)
|
||||
.where(inArray(PushSubscriptions.id, ids));
|
||||
} else {
|
||||
await db
|
||||
.delete(PushSubscriptions)
|
||||
.where(eq(PushSubscriptions.id, this.id));
|
||||
}
|
||||
}
|
||||
|
||||
public static async insert(
|
||||
data: InferInsertModel<typeof PushSubscriptions>,
|
||||
): Promise<PushSubscription> {
|
||||
const inserted = (
|
||||
await db.insert(PushSubscriptions).values(data).returning()
|
||||
)[0];
|
||||
|
||||
const subscription = await PushSubscription.fromId(inserted.id);
|
||||
|
||||
if (!subscription) {
|
||||
throw new Error("Failed to insert subscription");
|
||||
}
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
public get id(): string {
|
||||
return this.data.id;
|
||||
}
|
||||
|
||||
public getAlerts(): z.infer<typeof WebPushSubscriptionSchema.shape.alerts> {
|
||||
return {
|
||||
mention: this.data.alerts.mention ?? false,
|
||||
favourite: this.data.alerts.favourite ?? false,
|
||||
reblog: this.data.alerts.reblog ?? false,
|
||||
follow: this.data.alerts.follow ?? false,
|
||||
poll: this.data.alerts.poll ?? false,
|
||||
follow_request: this.data.alerts.follow_request ?? false,
|
||||
status: this.data.alerts.status ?? false,
|
||||
update: this.data.alerts.update ?? false,
|
||||
"admin.sign_up": this.data.alerts["admin.sign_up"] ?? false,
|
||||
"admin.report": this.data.alerts["admin.report"] ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
public toApi(): z.infer<typeof WebPushSubscriptionSchema> {
|
||||
return {
|
||||
id: this.data.id,
|
||||
alerts: this.getAlerts(),
|
||||
endpoint: this.data.endpoint,
|
||||
// FIXME: Add real key
|
||||
server_key: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
289
packages/kit/db/reaction.ts
Normal file
289
packages/kit/db/reaction.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import * as VersiaEntities from "@versia/sdk/entities";
|
||||
import { config } from "@versia-server/config";
|
||||
import { randomUUIDv7 } from "bun";
|
||||
import {
|
||||
and,
|
||||
desc,
|
||||
eq,
|
||||
type InferInsertModel,
|
||||
type InferSelectModel,
|
||||
inArray,
|
||||
isNull,
|
||||
type SQL,
|
||||
} from "drizzle-orm";
|
||||
import { db } from "../tables/db.ts";
|
||||
import { type Notes, Reactions, type Users } from "../tables/schema.ts";
|
||||
import { BaseInterface } from "./base.ts";
|
||||
import { Emoji } from "./emoji.ts";
|
||||
import { Instance } from "./instance.ts";
|
||||
import type { Note } from "./note.ts";
|
||||
import { User } from "./user.ts";
|
||||
|
||||
type ReactionType = InferSelectModel<typeof Reactions> & {
|
||||
emoji: typeof Emoji.$type | null;
|
||||
author: InferSelectModel<typeof Users>;
|
||||
note: InferSelectModel<typeof Notes>;
|
||||
};
|
||||
|
||||
export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
|
||||
public static $type: ReactionType;
|
||||
|
||||
public async reload(): Promise<void> {
|
||||
const reloaded = await Reaction.fromId(this.data.id);
|
||||
|
||||
if (!reloaded) {
|
||||
throw new Error("Failed to reload reaction");
|
||||
}
|
||||
|
||||
this.data = reloaded.data;
|
||||
}
|
||||
|
||||
public static async fromId(id: string | null): Promise<Reaction | null> {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await Reaction.fromSql(eq(Reactions.id, id));
|
||||
}
|
||||
|
||||
public static async fromIds(ids: string[]): Promise<Reaction[]> {
|
||||
return await Reaction.manyFromSql(inArray(Reactions.id, ids));
|
||||
}
|
||||
|
||||
public static async fromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(Reactions.id),
|
||||
): Promise<Reaction | null> {
|
||||
const found = await db.query.Reactions.findFirst({
|
||||
where: sql,
|
||||
with: {
|
||||
emoji: {
|
||||
with: {
|
||||
instance: true,
|
||||
media: true,
|
||||
},
|
||||
},
|
||||
author: true,
|
||||
note: true,
|
||||
},
|
||||
orderBy,
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
return null;
|
||||
}
|
||||
return new Reaction(found);
|
||||
}
|
||||
|
||||
public static async manyFromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(Reactions.id),
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
extra?: Parameters<typeof db.query.Reactions.findMany>[0],
|
||||
): Promise<Reaction[]> {
|
||||
const found = await db.query.Reactions.findMany({
|
||||
where: sql,
|
||||
orderBy,
|
||||
limit,
|
||||
offset,
|
||||
with: {
|
||||
...extra?.with,
|
||||
emoji: {
|
||||
with: {
|
||||
instance: true,
|
||||
media: true,
|
||||
},
|
||||
},
|
||||
author: true,
|
||||
note: true,
|
||||
},
|
||||
});
|
||||
|
||||
return found.map((s) => new Reaction(s));
|
||||
}
|
||||
|
||||
public async update(
|
||||
newReaction: Partial<ReactionType>,
|
||||
): Promise<ReactionType> {
|
||||
await db
|
||||
.update(Reactions)
|
||||
.set(newReaction)
|
||||
.where(eq(Reactions.id, this.id));
|
||||
|
||||
const updated = await Reaction.fromId(this.data.id);
|
||||
|
||||
if (!updated) {
|
||||
throw new Error("Failed to update reaction");
|
||||
}
|
||||
|
||||
this.data = updated.data;
|
||||
return updated.data;
|
||||
}
|
||||
|
||||
public save(): Promise<ReactionType> {
|
||||
return this.update(this.data);
|
||||
}
|
||||
|
||||
public async delete(ids?: string[]): Promise<void> {
|
||||
if (Array.isArray(ids)) {
|
||||
await db.delete(Reactions).where(inArray(Reactions.id, ids));
|
||||
} else {
|
||||
await db.delete(Reactions).where(eq(Reactions.id, this.id));
|
||||
}
|
||||
}
|
||||
|
||||
public static async insert(
|
||||
data: InferInsertModel<typeof Reactions>,
|
||||
): Promise<Reaction> {
|
||||
// Needs one of emojiId or emojiText, but not both
|
||||
if (!(data.emojiId || data.emojiText)) {
|
||||
throw new Error("EmojiID or emojiText is required");
|
||||
}
|
||||
|
||||
if (data.emojiId && data.emojiText) {
|
||||
throw new Error("Cannot have both emojiId and emojiText");
|
||||
}
|
||||
|
||||
const inserted = (
|
||||
await db.insert(Reactions).values(data).returning()
|
||||
)[0];
|
||||
|
||||
const reaction = await Reaction.fromId(inserted.id);
|
||||
|
||||
if (!reaction) {
|
||||
throw new Error("Failed to insert reaction");
|
||||
}
|
||||
|
||||
return reaction;
|
||||
}
|
||||
|
||||
public get id(): string {
|
||||
return this.data.id;
|
||||
}
|
||||
|
||||
public static fromEmoji(
|
||||
emoji: Emoji | string,
|
||||
author: User,
|
||||
note: Note,
|
||||
): Promise<Reaction | null> {
|
||||
if (emoji instanceof Emoji) {
|
||||
return Reaction.fromSql(
|
||||
and(
|
||||
eq(Reactions.authorId, author.id),
|
||||
eq(Reactions.noteId, note.id),
|
||||
isNull(Reactions.emojiText),
|
||||
eq(Reactions.emojiId, emoji.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Reaction.fromSql(
|
||||
and(
|
||||
eq(Reactions.authorId, author.id),
|
||||
eq(Reactions.noteId, note.id),
|
||||
eq(Reactions.emojiText, emoji),
|
||||
isNull(Reactions.emojiId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public getUri(baseUrl: URL): URL {
|
||||
return this.data.uri
|
||||
? new URL(this.data.uri)
|
||||
: new URL(
|
||||
`/notes/${this.data.noteId}/reactions/${this.id}`,
|
||||
baseUrl,
|
||||
);
|
||||
}
|
||||
|
||||
public get local(): boolean {
|
||||
return this.data.author.instanceId === null;
|
||||
}
|
||||
|
||||
public hasCustomEmoji(): boolean {
|
||||
return !!this.data.emoji || !this.data.emojiText;
|
||||
}
|
||||
|
||||
public toVersia(): VersiaEntities.Reaction {
|
||||
if (!this.local) {
|
||||
throw new Error("Cannot convert a non-local reaction to Versia");
|
||||
}
|
||||
|
||||
return new VersiaEntities.Reaction({
|
||||
uri: this.getUri(config.http.base_url).href,
|
||||
type: "pub.versia:reactions/Reaction",
|
||||
author: User.getUri(
|
||||
this.data.authorId,
|
||||
this.data.author.uri ? new URL(this.data.author.uri) : null,
|
||||
).href,
|
||||
created_at: new Date(this.data.createdAt).toISOString(),
|
||||
id: this.id,
|
||||
object: this.data.note.uri
|
||||
? new URL(this.data.note.uri).href
|
||||
: new URL(`/notes/${this.data.noteId}`, config.http.base_url)
|
||||
.href,
|
||||
content: this.hasCustomEmoji()
|
||||
? `:${this.data.emoji?.shortcode}:`
|
||||
: this.data.emojiText || "",
|
||||
extensions: this.hasCustomEmoji()
|
||||
? {
|
||||
"pub.versia:custom_emojis": {
|
||||
emojis: [
|
||||
new Emoji(
|
||||
this.data.emoji as typeof Emoji.$type,
|
||||
).toVersia(),
|
||||
],
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
public toVersiaUnreact(): VersiaEntities.Delete {
|
||||
return new VersiaEntities.Delete({
|
||||
type: "Delete",
|
||||
id: crypto.randomUUID(),
|
||||
created_at: new Date().toISOString(),
|
||||
author: User.getUri(
|
||||
this.data.authorId,
|
||||
this.data.author.uri ? new URL(this.data.author.uri) : null,
|
||||
).href,
|
||||
deleted_type: "pub.versia:reactions/Reaction",
|
||||
deleted: this.getUri(config.http.base_url).href,
|
||||
});
|
||||
}
|
||||
|
||||
public static async fromVersia(
|
||||
reactionToConvert: VersiaEntities.Reaction,
|
||||
author: User,
|
||||
note: Note,
|
||||
): Promise<Reaction> {
|
||||
if (author.local) {
|
||||
throw new Error("Cannot process a reaction from a local user");
|
||||
}
|
||||
|
||||
const emojiEntity =
|
||||
reactionToConvert.data.extensions?.["pub.versia:custom_emojis"]
|
||||
?.emojis[0];
|
||||
const emoji = emojiEntity
|
||||
? await Emoji.fetchFromRemote(
|
||||
emojiEntity,
|
||||
new Instance(
|
||||
author.data.instance as NonNullable<
|
||||
(typeof User.$type)["instance"]
|
||||
>,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
return Reaction.insert({
|
||||
id: randomUUIDv7(),
|
||||
uri: reactionToConvert.data.uri,
|
||||
authorId: author.id,
|
||||
noteId: note.id,
|
||||
emojiId: emoji ? emoji.id : null,
|
||||
emojiText: emoji ? null : reactionToConvert.data.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
353
packages/kit/db/relationship.ts
Normal file
353
packages/kit/db/relationship.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
import type { Relationship as RelationshipSchema } from "@versia/client/schemas";
|
||||
import { randomUUIDv7 } from "bun";
|
||||
import {
|
||||
and,
|
||||
desc,
|
||||
eq,
|
||||
type InferInsertModel,
|
||||
type InferSelectModel,
|
||||
inArray,
|
||||
type SQL,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "../tables/db.ts";
|
||||
import { Relationships, Users } from "../tables/schema.ts";
|
||||
import { BaseInterface } from "./base.ts";
|
||||
import type { User } from "./user.ts";
|
||||
|
||||
type RelationshipType = InferSelectModel<typeof Relationships>;
|
||||
|
||||
type RelationshipWithOpposite = RelationshipType & {
|
||||
followedBy: boolean;
|
||||
blockedBy: boolean;
|
||||
requestedBy: boolean;
|
||||
};
|
||||
|
||||
export class Relationship extends BaseInterface<
|
||||
typeof Relationships,
|
||||
RelationshipWithOpposite
|
||||
> {
|
||||
public static schema = z.object({
|
||||
id: z.string(),
|
||||
blocked_by: z.boolean(),
|
||||
blocking: z.boolean(),
|
||||
domain_blocking: z.boolean(),
|
||||
endorsed: z.boolean(),
|
||||
followed_by: z.boolean(),
|
||||
following: z.boolean(),
|
||||
muting_notifications: z.boolean(),
|
||||
muting: z.boolean(),
|
||||
note: z.string().nullable(),
|
||||
notifying: z.boolean(),
|
||||
requested_by: z.boolean(),
|
||||
requested: z.boolean(),
|
||||
showing_reblogs: z.boolean(),
|
||||
});
|
||||
|
||||
public static $type: RelationshipWithOpposite;
|
||||
|
||||
public async reload(): Promise<void> {
|
||||
const reloaded = await Relationship.fromId(this.data.id);
|
||||
|
||||
if (!reloaded) {
|
||||
throw new Error("Failed to reload relationship");
|
||||
}
|
||||
|
||||
this.data = reloaded.data;
|
||||
}
|
||||
|
||||
public static async fromId(
|
||||
id: string | null,
|
||||
): Promise<Relationship | null> {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await Relationship.fromSql(eq(Relationships.id, id));
|
||||
}
|
||||
|
||||
public static async fromIds(ids: string[]): Promise<Relationship[]> {
|
||||
return await Relationship.manyFromSql(inArray(Relationships.id, ids));
|
||||
}
|
||||
|
||||
public static async fromOwnerAndSubject(
|
||||
owner: User,
|
||||
subject: User,
|
||||
): Promise<Relationship> {
|
||||
const found = await Relationship.fromSql(
|
||||
and(
|
||||
eq(Relationships.ownerId, owner.id),
|
||||
eq(Relationships.subjectId, subject.id),
|
||||
),
|
||||
);
|
||||
|
||||
if (!found) {
|
||||
// Create a new relationship if one doesn't exist
|
||||
return await Relationship.insert({
|
||||
id: randomUUIDv7(),
|
||||
ownerId: owner.id,
|
||||
subjectId: subject.id,
|
||||
languages: [],
|
||||
following: false,
|
||||
showingReblogs: false,
|
||||
notifying: false,
|
||||
blocking: false,
|
||||
muting: false,
|
||||
mutingNotifications: false,
|
||||
requested: false,
|
||||
domainBlocking: false,
|
||||
endorsed: false,
|
||||
note: "",
|
||||
});
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
public static async fromOwnerAndSubjects(
|
||||
owner: User,
|
||||
subjectIds: string[],
|
||||
): Promise<Relationship[]> {
|
||||
const found = await Relationship.manyFromSql(
|
||||
and(
|
||||
eq(Relationships.ownerId, owner.id),
|
||||
inArray(Relationships.subjectId, subjectIds),
|
||||
),
|
||||
);
|
||||
|
||||
const missingSubjectsIds = subjectIds.filter(
|
||||
(id) => !found.find((rel) => rel.data.subjectId === id),
|
||||
);
|
||||
|
||||
for (const subjectId of missingSubjectsIds) {
|
||||
await Relationship.insert({
|
||||
id: randomUUIDv7(),
|
||||
ownerId: owner.id,
|
||||
subjectId,
|
||||
languages: [],
|
||||
following: false,
|
||||
showingReblogs: false,
|
||||
notifying: false,
|
||||
blocking: false,
|
||||
muting: false,
|
||||
mutingNotifications: false,
|
||||
requested: false,
|
||||
domainBlocking: false,
|
||||
endorsed: false,
|
||||
note: "",
|
||||
});
|
||||
}
|
||||
|
||||
return await Relationship.manyFromSql(
|
||||
and(
|
||||
eq(Relationships.ownerId, owner.id),
|
||||
inArray(Relationships.subjectId, subjectIds),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public static async fromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(Relationships.id),
|
||||
): Promise<Relationship | null> {
|
||||
const found = await db.query.Relationships.findFirst({
|
||||
where: sql,
|
||||
orderBy,
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const opposite = await Relationship.getOpposite(found);
|
||||
|
||||
return new Relationship({
|
||||
...found,
|
||||
followedBy: opposite.following,
|
||||
blockedBy: opposite.blocking,
|
||||
requestedBy: opposite.requested,
|
||||
});
|
||||
}
|
||||
|
||||
public static async manyFromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(Relationships.id),
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
extra?: Parameters<typeof db.query.Relationships.findMany>[0],
|
||||
): Promise<Relationship[]> {
|
||||
const found = await db.query.Relationships.findMany({
|
||||
where: sql,
|
||||
orderBy,
|
||||
limit,
|
||||
offset,
|
||||
with: extra?.with,
|
||||
});
|
||||
|
||||
const opposites = await Promise.all(
|
||||
found.map((rel) => Relationship.getOpposite(rel)),
|
||||
);
|
||||
|
||||
return found.map((s, i) => {
|
||||
return new Relationship({
|
||||
...s,
|
||||
followedBy: opposites[i].following,
|
||||
blockedBy: opposites[i].blocking,
|
||||
requestedBy: opposites[i].requested,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static async getOpposite(oppositeTo: {
|
||||
subjectId: string;
|
||||
ownerId: string;
|
||||
}): Promise<RelationshipType> {
|
||||
let output = await db.query.Relationships.findFirst({
|
||||
where: (rel): SQL | undefined =>
|
||||
and(
|
||||
eq(rel.ownerId, oppositeTo.subjectId),
|
||||
eq(rel.subjectId, oppositeTo.ownerId),
|
||||
),
|
||||
});
|
||||
|
||||
// If the opposite relationship doesn't exist, create it
|
||||
if (!output) {
|
||||
output = (
|
||||
await db
|
||||
.insert(Relationships)
|
||||
.values({
|
||||
id: randomUUIDv7(),
|
||||
ownerId: oppositeTo.subjectId,
|
||||
subjectId: oppositeTo.ownerId,
|
||||
languages: [],
|
||||
following: false,
|
||||
showingReblogs: false,
|
||||
notifying: false,
|
||||
blocking: false,
|
||||
domainBlocking: false,
|
||||
endorsed: false,
|
||||
note: "",
|
||||
muting: false,
|
||||
mutingNotifications: false,
|
||||
requested: false,
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
public async update(
|
||||
newRelationship: Partial<RelationshipType>,
|
||||
): Promise<RelationshipWithOpposite> {
|
||||
await db
|
||||
.update(Relationships)
|
||||
.set(newRelationship)
|
||||
.where(eq(Relationships.id, this.id));
|
||||
|
||||
// If a user follows another user, update followerCount and followingCount
|
||||
if (newRelationship.following && !this.data.following) {
|
||||
await db
|
||||
.update(Users)
|
||||
.set({
|
||||
followingCount: sql`${Users.followingCount} + 1`,
|
||||
})
|
||||
.where(eq(Users.id, this.data.ownerId));
|
||||
|
||||
await db
|
||||
.update(Users)
|
||||
.set({
|
||||
followerCount: sql`${Users.followerCount} + 1`,
|
||||
})
|
||||
.where(eq(Users.id, this.data.subjectId));
|
||||
}
|
||||
|
||||
// If a user unfollows another user, update followerCount and followingCount
|
||||
if (!newRelationship.following && this.data.following) {
|
||||
await db
|
||||
.update(Users)
|
||||
.set({
|
||||
followingCount: sql`${Users.followingCount} - 1`,
|
||||
})
|
||||
.where(eq(Users.id, this.data.ownerId));
|
||||
|
||||
await db
|
||||
.update(Users)
|
||||
.set({
|
||||
followerCount: sql`${Users.followerCount} - 1`,
|
||||
})
|
||||
.where(eq(Users.id, this.data.subjectId));
|
||||
}
|
||||
|
||||
const updated = await Relationship.fromId(this.data.id);
|
||||
|
||||
if (!updated) {
|
||||
throw new Error("Failed to update relationship");
|
||||
}
|
||||
|
||||
this.data = updated.data;
|
||||
return updated.data;
|
||||
}
|
||||
|
||||
public save(): Promise<RelationshipWithOpposite> {
|
||||
return this.update(this.data);
|
||||
}
|
||||
|
||||
public async delete(ids?: string[]): Promise<void> {
|
||||
if (Array.isArray(ids)) {
|
||||
await db
|
||||
.delete(Relationships)
|
||||
.where(inArray(Relationships.id, ids));
|
||||
} else {
|
||||
await db.delete(Relationships).where(eq(Relationships.id, this.id));
|
||||
}
|
||||
}
|
||||
|
||||
public static async insert(
|
||||
data: InferInsertModel<typeof Relationships>,
|
||||
): Promise<Relationship> {
|
||||
const inserted = (
|
||||
await db.insert(Relationships).values(data).returning()
|
||||
)[0];
|
||||
|
||||
const relationship = await Relationship.fromId(inserted.id);
|
||||
|
||||
if (!relationship) {
|
||||
throw new Error("Failed to insert relationship");
|
||||
}
|
||||
|
||||
// Create opposite relationship if necessary
|
||||
await Relationship.getOpposite({
|
||||
subjectId: relationship.data.subjectId,
|
||||
ownerId: relationship.data.ownerId,
|
||||
});
|
||||
|
||||
return relationship;
|
||||
}
|
||||
|
||||
public get id(): string {
|
||||
return this.data.id;
|
||||
}
|
||||
|
||||
public toApi(): z.infer<typeof RelationshipSchema> {
|
||||
return {
|
||||
id: this.data.subjectId,
|
||||
blocked_by: this.data.blockedBy,
|
||||
blocking: this.data.blocking,
|
||||
domain_blocking: this.data.domainBlocking,
|
||||
endorsed: this.data.endorsed,
|
||||
followed_by: this.data.followedBy,
|
||||
following: this.data.following,
|
||||
muting_notifications: this.data.mutingNotifications,
|
||||
muting: this.data.muting,
|
||||
note: this.data.note,
|
||||
notifying: this.data.notifying,
|
||||
requested_by: this.data.requestedBy,
|
||||
requested: this.data.requested,
|
||||
showing_reblogs: this.data.showingReblogs,
|
||||
languages: this.data.languages ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
225
packages/kit/db/role.ts
Normal file
225
packages/kit/db/role.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import type {
|
||||
RolePermission,
|
||||
Role as RoleSchema,
|
||||
} from "@versia/client/schemas";
|
||||
import { config, ProxiableUrl } from "@versia-server/config";
|
||||
import {
|
||||
and,
|
||||
desc,
|
||||
eq,
|
||||
type InferInsertModel,
|
||||
type InferSelectModel,
|
||||
inArray,
|
||||
type SQL,
|
||||
} from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
import { db } from "../tables/db.ts";
|
||||
import { Roles, RoleToUsers } from "../tables/schema.ts";
|
||||
import { BaseInterface } from "./base.ts";
|
||||
|
||||
type RoleType = InferSelectModel<typeof Roles>;
|
||||
|
||||
export class Role extends BaseInterface<typeof Roles> {
|
||||
public static $type: RoleType;
|
||||
public static defaultRole = new Role({
|
||||
id: "default",
|
||||
name: "Default",
|
||||
permissions: config.permissions.default,
|
||||
priority: 0,
|
||||
description: "Default role for all users",
|
||||
visible: false,
|
||||
icon: null,
|
||||
});
|
||||
public static adminRole = new Role({
|
||||
id: "admin",
|
||||
name: "Admin",
|
||||
permissions: config.permissions.admin,
|
||||
priority: 2 ** 31 - 1,
|
||||
description: "Default role for all administrators",
|
||||
visible: false,
|
||||
icon: null,
|
||||
});
|
||||
|
||||
public async reload(): Promise<void> {
|
||||
const reloaded = await Role.fromId(this.data.id);
|
||||
|
||||
if (!reloaded) {
|
||||
throw new Error("Failed to reload role");
|
||||
}
|
||||
|
||||
this.data = reloaded.data;
|
||||
}
|
||||
|
||||
public static async fromId(id: string | null): Promise<Role | null> {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await Role.fromSql(eq(Roles.id, id));
|
||||
}
|
||||
|
||||
public static async fromIds(ids: string[]): Promise<Role[]> {
|
||||
return await Role.manyFromSql(inArray(Roles.id, ids));
|
||||
}
|
||||
|
||||
public static async fromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(Roles.id),
|
||||
): Promise<Role | null> {
|
||||
const found = await db.query.Roles.findFirst({
|
||||
where: sql,
|
||||
orderBy,
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
return null;
|
||||
}
|
||||
return new Role(found);
|
||||
}
|
||||
|
||||
public static async getAll(): Promise<Role[]> {
|
||||
return (await Role.manyFromSql(undefined)).concat(
|
||||
Role.defaultRole,
|
||||
Role.adminRole,
|
||||
);
|
||||
}
|
||||
|
||||
public static async getUserRoles(
|
||||
userId: string,
|
||||
isAdmin: boolean,
|
||||
): Promise<Role[]> {
|
||||
return (
|
||||
await db.query.RoleToUsers.findMany({
|
||||
where: (role): SQL | undefined => eq(role.userId, userId),
|
||||
with: {
|
||||
role: true,
|
||||
user: {
|
||||
columns: {
|
||||
isAdmin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
.map((r) => new Role(r.role))
|
||||
.concat(
|
||||
new Role({
|
||||
id: "default",
|
||||
name: "Default",
|
||||
permissions: config.permissions.default,
|
||||
priority: 0,
|
||||
description: "Default role for all users",
|
||||
visible: false,
|
||||
icon: null,
|
||||
}),
|
||||
)
|
||||
.concat(
|
||||
isAdmin
|
||||
? [
|
||||
new Role({
|
||||
id: "admin",
|
||||
name: "Admin",
|
||||
permissions: config.permissions.admin,
|
||||
priority: 2 ** 31 - 1,
|
||||
description:
|
||||
"Default role for all administrators",
|
||||
visible: false,
|
||||
icon: null,
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
public static async manyFromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(Roles.id),
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
extra?: Parameters<typeof db.query.Roles.findMany>[0],
|
||||
): Promise<Role[]> {
|
||||
const found = await db.query.Roles.findMany({
|
||||
where: sql,
|
||||
orderBy,
|
||||
limit,
|
||||
offset,
|
||||
with: extra?.with,
|
||||
});
|
||||
|
||||
return found.map((s) => new Role(s));
|
||||
}
|
||||
|
||||
public async update(newRole: Partial<RoleType>): Promise<RoleType> {
|
||||
await db.update(Roles).set(newRole).where(eq(Roles.id, this.id));
|
||||
|
||||
const updated = await Role.fromId(this.data.id);
|
||||
|
||||
if (!updated) {
|
||||
throw new Error("Failed to update role");
|
||||
}
|
||||
|
||||
return updated.data;
|
||||
}
|
||||
|
||||
public save(): Promise<RoleType> {
|
||||
return this.update(this.data);
|
||||
}
|
||||
|
||||
public async delete(ids?: string[]): Promise<void> {
|
||||
if (Array.isArray(ids)) {
|
||||
await db.delete(Roles).where(inArray(Roles.id, ids));
|
||||
} else {
|
||||
await db.delete(Roles).where(eq(Roles.id, this.id));
|
||||
}
|
||||
}
|
||||
|
||||
public static async insert(
|
||||
data: InferInsertModel<typeof Roles>,
|
||||
): Promise<Role> {
|
||||
const inserted = (await db.insert(Roles).values(data).returning())[0];
|
||||
|
||||
const role = await Role.fromId(inserted.id);
|
||||
|
||||
if (!role) {
|
||||
throw new Error("Failed to insert role");
|
||||
}
|
||||
|
||||
return role;
|
||||
}
|
||||
|
||||
public async linkUser(userId: string): Promise<void> {
|
||||
await db.insert(RoleToUsers).values({
|
||||
userId,
|
||||
roleId: this.id,
|
||||
});
|
||||
}
|
||||
|
||||
public async unlinkUser(userId: string): Promise<void> {
|
||||
await db
|
||||
.delete(RoleToUsers)
|
||||
.where(
|
||||
and(
|
||||
eq(RoleToUsers.roleId, this.id),
|
||||
eq(RoleToUsers.userId, userId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public get id(): string {
|
||||
return this.data.id;
|
||||
}
|
||||
|
||||
public toApi(): z.infer<typeof RoleSchema> {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.data.name,
|
||||
permissions: this.data.permissions as unknown as RolePermission[],
|
||||
priority: this.data.priority,
|
||||
description: this.data.description ?? undefined,
|
||||
visible: this.data.visible,
|
||||
icon: this.data.icon
|
||||
? new ProxiableUrl(this.data.icon).proxied
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
241
packages/kit/db/timeline.ts
Normal file
241
packages/kit/db/timeline.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import { config } from "@versia-server/config";
|
||||
import { gt, type SQL } from "drizzle-orm";
|
||||
import { Notes, Notifications, Users } from "../tables/schema.ts";
|
||||
import { Note } from "./note.ts";
|
||||
import { Notification } from "./notification.ts";
|
||||
import { User } from "./user.ts";
|
||||
|
||||
enum TimelineType {
|
||||
Note = "Note",
|
||||
User = "User",
|
||||
Notification = "Notification",
|
||||
}
|
||||
|
||||
export class Timeline<Type extends Note | User | Notification> {
|
||||
public constructor(private type: TimelineType) {}
|
||||
|
||||
public static getNoteTimeline(
|
||||
sql: SQL<unknown> | undefined,
|
||||
limit: number,
|
||||
url: URL,
|
||||
userId?: string,
|
||||
): Promise<{ link: string; objects: Note[] }> {
|
||||
return new Timeline<Note>(TimelineType.Note).fetchTimeline(
|
||||
sql,
|
||||
limit,
|
||||
url,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
public static getUserTimeline(
|
||||
sql: SQL<unknown> | undefined,
|
||||
limit: number,
|
||||
url: URL,
|
||||
): Promise<{ link: string; objects: User[] }> {
|
||||
return new Timeline<User>(TimelineType.User).fetchTimeline(
|
||||
sql,
|
||||
limit,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
public static getNotificationTimeline(
|
||||
sql: SQL<unknown> | undefined,
|
||||
limit: number,
|
||||
url: URL,
|
||||
userId?: string,
|
||||
): Promise<{ link: string; objects: Notification[] }> {
|
||||
return new Timeline<Notification>(
|
||||
TimelineType.Notification,
|
||||
).fetchTimeline(sql, limit, url, userId);
|
||||
}
|
||||
|
||||
private async fetchObjects(
|
||||
sql: SQL<unknown> | undefined,
|
||||
limit: number,
|
||||
userId?: string,
|
||||
): Promise<Type[]> {
|
||||
switch (this.type) {
|
||||
case TimelineType.Note:
|
||||
return (await Note.manyFromSql(
|
||||
sql,
|
||||
undefined,
|
||||
limit,
|
||||
undefined,
|
||||
userId,
|
||||
)) as Type[];
|
||||
case TimelineType.User:
|
||||
return (await User.manyFromSql(
|
||||
sql,
|
||||
undefined,
|
||||
limit,
|
||||
)) as Type[];
|
||||
case TimelineType.Notification:
|
||||
return (await Notification.manyFromSql(
|
||||
sql,
|
||||
undefined,
|
||||
limit,
|
||||
undefined,
|
||||
undefined,
|
||||
userId,
|
||||
)) as Type[];
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchLinkHeader(
|
||||
objects: Type[],
|
||||
url: URL,
|
||||
limit: number,
|
||||
): Promise<string> {
|
||||
const linkHeader: string[] = [];
|
||||
const urlWithoutQuery = new URL(url.pathname, config.http.base_url);
|
||||
|
||||
if (objects.length > 0) {
|
||||
switch (this.type) {
|
||||
case TimelineType.Note:
|
||||
linkHeader.push(
|
||||
...(await Timeline.fetchNoteLinkHeader(
|
||||
objects as Note[],
|
||||
urlWithoutQuery,
|
||||
limit,
|
||||
)),
|
||||
);
|
||||
break;
|
||||
case TimelineType.User:
|
||||
linkHeader.push(
|
||||
...(await Timeline.fetchUserLinkHeader(
|
||||
objects as User[],
|
||||
urlWithoutQuery,
|
||||
limit,
|
||||
)),
|
||||
);
|
||||
break;
|
||||
case TimelineType.Notification:
|
||||
linkHeader.push(
|
||||
...(await Timeline.fetchNotificationLinkHeader(
|
||||
objects as Notification[],
|
||||
urlWithoutQuery,
|
||||
limit,
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return linkHeader.join(", ");
|
||||
}
|
||||
|
||||
private static async fetchNoteLinkHeader(
|
||||
notes: Note[],
|
||||
urlWithoutQuery: URL,
|
||||
limit: number,
|
||||
): Promise<string[]> {
|
||||
const linkHeader: string[] = [];
|
||||
|
||||
const objectBefore = await Note.fromSql(gt(Notes.id, notes[0].data.id));
|
||||
if (objectBefore) {
|
||||
linkHeader.push(
|
||||
`<${urlWithoutQuery}?limit=${limit ?? 20}&min_id=${notes[0].data.id}>; rel="prev"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (notes.length >= (limit ?? 20)) {
|
||||
const objectAfter = await Note.fromSql(
|
||||
gt(Notes.id, notes.at(-1)?.data.id ?? ""),
|
||||
);
|
||||
if (objectAfter) {
|
||||
linkHeader.push(
|
||||
`<${urlWithoutQuery}?limit=${limit ?? 20}&max_id=${notes.at(-1)?.data.id}>; rel="next"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return linkHeader;
|
||||
}
|
||||
|
||||
private static async fetchUserLinkHeader(
|
||||
users: User[],
|
||||
urlWithoutQuery: URL,
|
||||
limit: number,
|
||||
): Promise<string[]> {
|
||||
const linkHeader: string[] = [];
|
||||
|
||||
const objectBefore = await User.fromSql(gt(Users.id, users[0].id));
|
||||
if (objectBefore) {
|
||||
linkHeader.push(
|
||||
`<${urlWithoutQuery}?limit=${limit ?? 20}&min_id=${users[0].id}>; rel="prev"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (users.length >= (limit ?? 20)) {
|
||||
const objectAfter = await User.fromSql(
|
||||
gt(Users.id, users.at(-1)?.id ?? ""),
|
||||
);
|
||||
if (objectAfter) {
|
||||
linkHeader.push(
|
||||
`<${urlWithoutQuery}?limit=${limit ?? 20}&max_id=${users.at(-1)?.id}>; rel="next"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return linkHeader;
|
||||
}
|
||||
|
||||
private static async fetchNotificationLinkHeader(
|
||||
notifications: Notification[],
|
||||
urlWithoutQuery: URL,
|
||||
limit: number,
|
||||
): Promise<string[]> {
|
||||
const linkHeader: string[] = [];
|
||||
|
||||
const objectBefore = await Notification.fromSql(
|
||||
gt(Notifications.id, notifications[0].data.id),
|
||||
);
|
||||
if (objectBefore) {
|
||||
linkHeader.push(
|
||||
`<${urlWithoutQuery}?limit=${limit ?? 20}&min_id=${notifications[0].data.id}>; rel="prev"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (notifications.length >= (limit ?? 20)) {
|
||||
const objectAfter = await Notification.fromSql(
|
||||
gt(Notifications.id, notifications.at(-1)?.data.id ?? ""),
|
||||
);
|
||||
if (objectAfter) {
|
||||
linkHeader.push(
|
||||
`<${urlWithoutQuery}?limit=${limit ?? 20}&max_id=${notifications.at(-1)?.data.id}>; rel="next"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return linkHeader;
|
||||
}
|
||||
|
||||
private async fetchTimeline(
|
||||
sql: SQL<unknown> | undefined,
|
||||
limit: number,
|
||||
url: URL,
|
||||
userId?: string,
|
||||
): Promise<{ link: string; objects: Type[] }> {
|
||||
const objects = await this.fetchObjects(sql, limit, userId);
|
||||
const link = await this.fetchLinkHeader(objects, url, limit);
|
||||
|
||||
switch (this.type) {
|
||||
case TimelineType.Note:
|
||||
return {
|
||||
link,
|
||||
objects,
|
||||
};
|
||||
case TimelineType.User:
|
||||
return {
|
||||
link,
|
||||
objects,
|
||||
};
|
||||
case TimelineType.Notification:
|
||||
return {
|
||||
link,
|
||||
objects,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
168
packages/kit/db/token.ts
Normal file
168
packages/kit/db/token.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import type { Token as TokenSchema } from "@versia/client/schemas";
|
||||
import {
|
||||
desc,
|
||||
eq,
|
||||
type InferInsertModel,
|
||||
type InferSelectModel,
|
||||
inArray,
|
||||
type SQL,
|
||||
} from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
import { db } from "../tables/db.ts";
|
||||
import { Tokens } from "../tables/schema.ts";
|
||||
import type { Application } from "./application.ts";
|
||||
import { BaseInterface } from "./base.ts";
|
||||
import { User } from "./user.ts";
|
||||
|
||||
type TokenType = InferSelectModel<typeof Tokens> & {
|
||||
application: typeof Application.$type | null;
|
||||
};
|
||||
|
||||
export class Token extends BaseInterface<typeof Tokens, TokenType> {
|
||||
public static $type: TokenType;
|
||||
|
||||
public async reload(): Promise<void> {
|
||||
const reloaded = await Token.fromId(this.data.id);
|
||||
|
||||
if (!reloaded) {
|
||||
throw new Error("Failed to reload token");
|
||||
}
|
||||
|
||||
this.data = reloaded.data;
|
||||
}
|
||||
|
||||
public static async fromId(id: string | null): Promise<Token | null> {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await Token.fromSql(eq(Tokens.id, id));
|
||||
}
|
||||
|
||||
public static async fromIds(ids: string[]): Promise<Token[]> {
|
||||
return await Token.manyFromSql(inArray(Tokens.id, ids));
|
||||
}
|
||||
|
||||
public static async fromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(Tokens.id),
|
||||
): Promise<Token | null> {
|
||||
const found = await db.query.Tokens.findFirst({
|
||||
where: sql,
|
||||
orderBy,
|
||||
with: {
|
||||
application: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
return null;
|
||||
}
|
||||
return new Token(found);
|
||||
}
|
||||
|
||||
public static async manyFromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(Tokens.id),
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
extra?: Parameters<typeof db.query.Tokens.findMany>[0],
|
||||
): Promise<Token[]> {
|
||||
const found = await db.query.Tokens.findMany({
|
||||
where: sql,
|
||||
orderBy,
|
||||
limit,
|
||||
offset,
|
||||
with: {
|
||||
application: true,
|
||||
...extra?.with,
|
||||
},
|
||||
});
|
||||
|
||||
return found.map((s) => new Token(s));
|
||||
}
|
||||
|
||||
public async update(newAttachment: Partial<TokenType>): Promise<TokenType> {
|
||||
await db
|
||||
.update(Tokens)
|
||||
.set(newAttachment)
|
||||
.where(eq(Tokens.id, this.id));
|
||||
|
||||
const updated = await Token.fromId(this.data.id);
|
||||
|
||||
if (!updated) {
|
||||
throw new Error("Failed to update token");
|
||||
}
|
||||
|
||||
this.data = updated.data;
|
||||
return updated.data;
|
||||
}
|
||||
|
||||
public save(): Promise<TokenType> {
|
||||
return this.update(this.data);
|
||||
}
|
||||
|
||||
public async delete(ids?: string[]): Promise<void> {
|
||||
if (Array.isArray(ids)) {
|
||||
await db.delete(Tokens).where(inArray(Tokens.id, ids));
|
||||
} else {
|
||||
await db.delete(Tokens).where(eq(Tokens.id, this.id));
|
||||
}
|
||||
}
|
||||
|
||||
public static async insert(
|
||||
data: InferInsertModel<typeof Tokens>,
|
||||
): Promise<Token> {
|
||||
const inserted = (await db.insert(Tokens).values(data).returning())[0];
|
||||
|
||||
const token = await Token.fromId(inserted.id);
|
||||
|
||||
if (!token) {
|
||||
throw new Error("Failed to insert token");
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
public static async insertMany(
|
||||
data: InferInsertModel<typeof Tokens>[],
|
||||
): Promise<Token[]> {
|
||||
const inserted = await db.insert(Tokens).values(data).returning();
|
||||
|
||||
return await Token.fromIds(inserted.map((i) => i.id));
|
||||
}
|
||||
|
||||
public get id(): string {
|
||||
return this.data.id;
|
||||
}
|
||||
|
||||
public static async fromAccessToken(
|
||||
accessToken: string,
|
||||
): Promise<Token | null> {
|
||||
return await Token.fromSql(eq(Tokens.accessToken, accessToken));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the associated user from this token
|
||||
*
|
||||
* @returns The user associated with this token
|
||||
*/
|
||||
public async getUser(): Promise<User | null> {
|
||||
if (!this.data.userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await User.fromId(this.data.userId);
|
||||
}
|
||||
|
||||
public toApi(): z.infer<typeof TokenSchema> {
|
||||
return {
|
||||
access_token: this.data.accessToken,
|
||||
token_type: "Bearer",
|
||||
scope: this.data.scope,
|
||||
created_at: Math.floor(
|
||||
new Date(this.data.createdAt).getTime() / 1000,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
1253
packages/kit/db/user.ts
Normal file
1253
packages/kit/db/user.ts
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue