mirror of
https://github.com/versia-pub/server.git
synced 2025-12-07 16:58:20 +01:00
Add new Instance methods, add tests for it as well
This commit is contained in:
parent
8a8d15810b
commit
9646e33099
|
|
@ -1,5 +1,12 @@
|
||||||
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm";
|
import {
|
||||||
|
BaseEntity,
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from "typeorm";
|
||||||
import { APIEmoji } from "~types/entities/emoji";
|
import { APIEmoji } from "~types/entities/emoji";
|
||||||
|
import { Instance } from "./Instance";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents an emoji entity in the database.
|
* Represents an emoji entity in the database.
|
||||||
|
|
@ -20,6 +27,15 @@ export class Emoji extends BaseEntity {
|
||||||
@Column("varchar")
|
@Column("varchar")
|
||||||
shortcode!: string;
|
shortcode!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The instance that the emoji is from.
|
||||||
|
* If is null, the emoji is from the server's instance
|
||||||
|
*/
|
||||||
|
@ManyToOne(() => Instance, {
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
instance!: Instance;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The URL for the emoji.
|
* The URL for the emoji.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,62 @@
|
||||||
import {
|
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm";
|
||||||
BaseEntity,
|
|
||||||
Column,
|
|
||||||
Entity,
|
|
||||||
ManyToOne,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
} from "typeorm";
|
|
||||||
import { APIInstance } from "~types/entities/instance";
|
import { APIInstance } from "~types/entities/instance";
|
||||||
import { User } from "./User";
|
import { APIAccount } from "~types/entities/account";
|
||||||
|
|
||||||
|
export interface NodeInfo {
|
||||||
|
software: {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
protocols: string[];
|
||||||
|
version: string;
|
||||||
|
services: {
|
||||||
|
inbound: string[];
|
||||||
|
outbound: string[];
|
||||||
|
};
|
||||||
|
openRegistrations: boolean;
|
||||||
|
usage: {
|
||||||
|
users: {
|
||||||
|
total: number;
|
||||||
|
activeHalfyear: number;
|
||||||
|
activeMonth: number;
|
||||||
|
};
|
||||||
|
localPosts: number;
|
||||||
|
localComments?: number;
|
||||||
|
remotePosts?: number;
|
||||||
|
remoteComments?: number;
|
||||||
|
};
|
||||||
|
metadata: Partial<{
|
||||||
|
nodeName: string;
|
||||||
|
nodeDescription: string;
|
||||||
|
maintainer: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
langs: string[];
|
||||||
|
tosUrl: string;
|
||||||
|
repositoryUrl: string;
|
||||||
|
feedbackUrl: string;
|
||||||
|
disableRegistration: boolean;
|
||||||
|
disableLocalTimeline: boolean;
|
||||||
|
disableRecommendedTimeline: boolean;
|
||||||
|
disableGlobalTimeline: boolean;
|
||||||
|
emailRequiredForSignup: boolean;
|
||||||
|
searchFilters: boolean;
|
||||||
|
postEditing: boolean;
|
||||||
|
postImports: boolean;
|
||||||
|
enableHcaptcha: boolean;
|
||||||
|
enableRecaptcha: boolean;
|
||||||
|
maxNoteTextLength: number;
|
||||||
|
maxCaptionTextLength: number;
|
||||||
|
enableTwitterIntegration: boolean;
|
||||||
|
enableGithubIntegration: boolean;
|
||||||
|
enableDiscordIntegration: boolean;
|
||||||
|
enableEmail: boolean;
|
||||||
|
enableServiceWorker: boolean;
|
||||||
|
proxyAccountName: string | null;
|
||||||
|
themeColor: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents an instance in the database.
|
* Represents an instance in the database.
|
||||||
|
|
@ -22,67 +72,122 @@ export class Instance extends BaseEntity {
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The contact account associated with the instance.
|
* The base URL of the instance.
|
||||||
|
* Must not have the https:// or http:// prefix.
|
||||||
*/
|
*/
|
||||||
@ManyToOne(() => User, user => user.id)
|
@Column("varchar")
|
||||||
contact_account!: User;
|
base_url!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The configuration of the instance.
|
* The configuration of the instance.
|
||||||
*/
|
*/
|
||||||
@Column("jsonb", {
|
@Column("jsonb", {
|
||||||
default: {
|
nullable: true,
|
||||||
media_attachments: {
|
|
||||||
image_matrix_limit: 0,
|
|
||||||
image_size_limit: 0,
|
|
||||||
supported_mime_types: [],
|
|
||||||
video_frame_limit: 0,
|
|
||||||
video_matrix_limit: 0,
|
|
||||||
video_size_limit: 0,
|
|
||||||
},
|
|
||||||
polls: {
|
|
||||||
max_options: 0,
|
|
||||||
max_characters_per_option: 0,
|
|
||||||
max_expiration: 0,
|
|
||||||
min_expiration: 0,
|
|
||||||
},
|
|
||||||
statuses: {
|
|
||||||
characters_reserved_per_url: 0,
|
|
||||||
max_characters: 0,
|
|
||||||
max_media_attachments: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
configuration!: APIInstance["configuration"];
|
instance_data?: APIInstance;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instance nodeinfo data
|
||||||
|
*/
|
||||||
|
@Column("jsonb")
|
||||||
|
nodeinfo!: NodeInfo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an instance to the database if it doesn't already exist.
|
||||||
|
* @param url
|
||||||
|
* @returns Either the database instance if it already exists, or a newly created instance.
|
||||||
|
*/
|
||||||
|
static async addIfNotExists(url: string): Promise<Instance> {
|
||||||
|
const origin = new URL(url).origin;
|
||||||
|
const hostname = new URL(url).hostname;
|
||||||
|
|
||||||
|
const found = await Instance.findOne({
|
||||||
|
where: {
|
||||||
|
base_url: hostname,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (found) return found;
|
||||||
|
|
||||||
|
const instance = new Instance();
|
||||||
|
|
||||||
|
instance.base_url = hostname;
|
||||||
|
|
||||||
|
// Fetch the instance configuration
|
||||||
|
const nodeinfo: NodeInfo = await fetch(`${origin}/nodeinfo/2.0`).then(
|
||||||
|
res => res.json()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to fetch configuration from Mastodon-compatible instances
|
||||||
|
if (
|
||||||
|
["firefish", "iceshrimp", "mastodon", "akkoma", "pleroma"].includes(
|
||||||
|
nodeinfo.software.name
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const instanceData: APIInstance = await fetch(
|
||||||
|
`${origin}/api/v1/instance`
|
||||||
|
).then(res => res.json());
|
||||||
|
|
||||||
|
instance.instance_data = instanceData;
|
||||||
|
}
|
||||||
|
|
||||||
|
instance.nodeinfo = nodeinfo;
|
||||||
|
|
||||||
|
await instance.save();
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts the instance to an API instance.
|
* Converts the instance to an API instance.
|
||||||
* @returns The API instance.
|
* @returns The API instance.
|
||||||
*/
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/require-await
|
||||||
async toAPI(): Promise<APIInstance> {
|
async toAPI(): Promise<APIInstance> {
|
||||||
return {
|
return {
|
||||||
uri: "",
|
uri: this.instance_data?.uri || this.base_url,
|
||||||
approval_required: false,
|
approval_required: this.instance_data?.approval_required || false,
|
||||||
email: "",
|
email: this.instance_data?.email || "",
|
||||||
thumbnail: "",
|
thumbnail: this.instance_data?.thumbnail || "",
|
||||||
title: "",
|
title: this.instance_data?.title || "",
|
||||||
version: "",
|
version: this.instance_data?.version || "",
|
||||||
configuration: this.configuration,
|
configuration: this.instance_data?.configuration || {
|
||||||
contact_account: await this.contact_account.toAPI(),
|
media_attachments: {
|
||||||
description: "",
|
image_matrix_limit: 0,
|
||||||
invites_enabled: false,
|
image_size_limit: 0,
|
||||||
languages: [],
|
supported_mime_types: [],
|
||||||
registrations: false,
|
video_frame_limit: 0,
|
||||||
rules: [],
|
video_matrix_limit: 0,
|
||||||
|
video_size_limit: 0,
|
||||||
|
},
|
||||||
|
polls: {
|
||||||
|
max_characters_per_option: 0,
|
||||||
|
max_expiration: 0,
|
||||||
|
max_options: 0,
|
||||||
|
min_expiration: 0,
|
||||||
|
},
|
||||||
|
statuses: {
|
||||||
|
characters_reserved_per_url: 0,
|
||||||
|
max_characters: 0,
|
||||||
|
max_media_attachments: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
contact_account:
|
||||||
|
this.instance_data?.contact_account || ({} as APIAccount),
|
||||||
|
description: this.instance_data?.description || "",
|
||||||
|
invites_enabled: this.instance_data?.invites_enabled || false,
|
||||||
|
languages: this.instance_data?.languages || [],
|
||||||
|
registrations: this.instance_data?.registrations || false,
|
||||||
|
rules: this.instance_data?.rules || [],
|
||||||
stats: {
|
stats: {
|
||||||
domain_count: 0,
|
domain_count: this.instance_data?.stats.domain_count || 0,
|
||||||
status_count: 0,
|
status_count: this.instance_data?.stats.status_count || 0,
|
||||||
user_count: 0,
|
user_count: this.instance_data?.stats.user_count || 0,
|
||||||
},
|
},
|
||||||
urls: {
|
urls: {
|
||||||
streaming_api: "",
|
streaming_api: this.instance_data?.urls.streaming_api || "",
|
||||||
},
|
},
|
||||||
max_toot_chars: 0,
|
max_toot_chars: this.instance_data?.max_toot_chars || 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { getConfig, getHost } from "@config";
|
||||||
import { appendFile } from "fs/promises";
|
import { appendFile } from "fs/promises";
|
||||||
import { errorResponse } from "@response";
|
import { errorResponse } from "@response";
|
||||||
import { APIAccount } from "~types/entities/account";
|
import { APIAccount } from "~types/entities/account";
|
||||||
|
import { RawActivity } from "./RawActivity";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a raw actor entity in the database.
|
* Represents a raw actor entity in the database.
|
||||||
|
|
@ -126,6 +127,15 @@ export class RawActor extends BaseEntity {
|
||||||
const { preferredUsername, name, summary, published, icon, image } =
|
const { preferredUsername, name, summary, published, icon, image } =
|
||||||
this.data;
|
this.data;
|
||||||
|
|
||||||
|
const statusCount = await RawActivity.createQueryBuilder("activity")
|
||||||
|
.leftJoinAndSelect("activity.actor", "actor")
|
||||||
|
.where("actor.data @> :data", {
|
||||||
|
data: JSON.stringify({
|
||||||
|
id: this.data.id,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.getCount();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
username: preferredUsername ?? "",
|
username: preferredUsername ?? "",
|
||||||
|
|
@ -142,9 +152,9 @@ export class RawActor extends BaseEntity {
|
||||||
config.defaults.header,
|
config.defaults.header,
|
||||||
locked: false,
|
locked: false,
|
||||||
created_at: new Date(published ?? 0).toISOString(),
|
created_at: new Date(published ?? 0).toISOString(),
|
||||||
followers_count: 0,
|
followers_count: this.followers.length,
|
||||||
following_count: 0,
|
following_count: 0,
|
||||||
statuses_count: 0,
|
statuses_count: statusCount,
|
||||||
emojis: [],
|
emojis: [],
|
||||||
fields: [],
|
fields: [],
|
||||||
bot: false,
|
bot: false,
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,26 @@ export class Status extends BaseEntity {
|
||||||
return await super.remove(options);
|
return await super.remove(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async parseEmojis(string: string) {
|
||||||
|
const emojis = [...string.matchAll(/:([a-zA-Z0-9_]+):/g)].map(
|
||||||
|
match => match[1]
|
||||||
|
);
|
||||||
|
|
||||||
|
const emojiObjects = await Promise.all(
|
||||||
|
emojis.map(async emoji => {
|
||||||
|
const emojiObject = await Emoji.findOne({
|
||||||
|
where: {
|
||||||
|
shortcode: emoji,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return emojiObject;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return emojiObjects.filter(emoji => emoji !== null) as Emoji[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new status and saves it to the database.
|
* Creates a new status and saves it to the database.
|
||||||
* @param data The data for the new status.
|
* @param data The data for the new status.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getConfig, getHost } from "@config";
|
import { getConfig } from "@config";
|
||||||
import {
|
import {
|
||||||
BaseEntity,
|
BaseEntity,
|
||||||
Column,
|
Column,
|
||||||
|
|
@ -21,8 +21,6 @@ import { Status } from "./Status";
|
||||||
import { APISource } from "~types/entities/source";
|
import { APISource } from "~types/entities/source";
|
||||||
import { Relationship } from "./Relationship";
|
import { Relationship } from "./Relationship";
|
||||||
|
|
||||||
const config = getConfig();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a user in the database.
|
* Represents a user in the database.
|
||||||
* Stores local and remote users
|
* Stores local and remote users
|
||||||
|
|
@ -382,6 +380,7 @@ export class User extends BaseEntity {
|
||||||
const privateKey = btoa(
|
const privateKey = btoa(
|
||||||
String.fromCharCode.apply(null, [
|
String.fromCharCode.apply(null, [
|
||||||
...new Uint8Array(
|
...new Uint8Array(
|
||||||
|
// jesus help me what do these letters mean
|
||||||
await crypto.subtle.exportKey("pkcs8", keys.privateKey)
|
await crypto.subtle.exportKey("pkcs8", keys.privateKey)
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
|
|
@ -389,6 +388,7 @@ export class User extends BaseEntity {
|
||||||
const publicKey = btoa(
|
const publicKey = btoa(
|
||||||
String.fromCharCode(
|
String.fromCharCode(
|
||||||
...new Uint8Array(
|
...new Uint8Array(
|
||||||
|
// why is exporting a key so hard
|
||||||
await crypto.subtle.exportKey("spki", keys.publicKey)
|
await crypto.subtle.exportKey("spki", keys.publicKey)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -402,33 +402,6 @@ export class User extends BaseEntity {
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/require-await
|
// eslint-disable-next-line @typescript-eslint/require-await
|
||||||
async toAPI(): Promise<APIAccount> {
|
async toAPI(): Promise<APIAccount> {
|
||||||
return {
|
return await this.actor.toAPIAccount();
|
||||||
acct: `@${this.username}@${getHost()}`,
|
|
||||||
avatar: "",
|
|
||||||
avatar_static: "",
|
|
||||||
bot: false,
|
|
||||||
created_at: this.created_at.toISOString(),
|
|
||||||
display_name: this.display_name,
|
|
||||||
followers_count: 0,
|
|
||||||
following_count: 0,
|
|
||||||
group: false,
|
|
||||||
header: "",
|
|
||||||
header_static: "",
|
|
||||||
id: this.id,
|
|
||||||
locked: false,
|
|
||||||
moved: null,
|
|
||||||
noindex: false,
|
|
||||||
note: this.note,
|
|
||||||
suspended: false,
|
|
||||||
url: `${config.http.base_url}/@${this.username}`,
|
|
||||||
username: this.username,
|
|
||||||
emojis: [],
|
|
||||||
fields: [],
|
|
||||||
limited: false,
|
|
||||||
statuses_count: 0,
|
|
||||||
discoverable: undefined,
|
|
||||||
role: undefined,
|
|
||||||
mute_expires_at: undefined,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
40
tests/entities/Instance.test.ts
Normal file
40
tests/entities/Instance.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||||
|
import { AppDataSource } from "~database/datasource";
|
||||||
|
import { Instance } from "~database/entities/Instance";
|
||||||
|
|
||||||
|
let instance: Instance;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
if (!AppDataSource.isInitialized) await AppDataSource.initialize();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Instance", () => {
|
||||||
|
it("should add an instance to the database if it doesn't already exist", async () => {
|
||||||
|
const url = "https://mastodon.social";
|
||||||
|
instance = await Instance.addIfNotExists(url);
|
||||||
|
expect(instance.base_url).toBe("mastodon.social");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should convert the instance to an API instance", async () => {
|
||||||
|
const apiInstance = await instance.toAPI();
|
||||||
|
expect(apiInstance.uri).toBe("mastodon.social");
|
||||||
|
expect(apiInstance.approval_required).toBe(false);
|
||||||
|
expect(apiInstance.email).toBe("staff@mastodon.social");
|
||||||
|
expect(apiInstance.thumbnail).toBeDefined();
|
||||||
|
expect(apiInstance.title).toBeDefined();
|
||||||
|
expect(apiInstance.configuration).toBeDefined();
|
||||||
|
expect(apiInstance.contact_account).toBeDefined();
|
||||||
|
expect(apiInstance.description).toBeDefined();
|
||||||
|
expect(apiInstance.invites_enabled).toBeDefined();
|
||||||
|
expect(apiInstance.languages).toBeDefined();
|
||||||
|
expect(apiInstance.registrations).toBeDefined();
|
||||||
|
expect(apiInstance.rules).toBeDefined();
|
||||||
|
expect(apiInstance.stats).toBeDefined();
|
||||||
|
expect(apiInstance.urls).toBeDefined();
|
||||||
|
expect(apiInstance.max_toot_chars).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await instance.remove();
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue