Move entities to database

This commit is contained in:
Rory&
2026-06-12 17:58:18 +02:00
parent 6821fe0513
commit c77d00e243
250 changed files with 695 additions and 538 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ import path from "node:path";
import { green, red, yellow } from "picocolors";
import { DataSource } from "typeorm";
// noinspection ES6PreferShortImport
import { ConfigEntity } from "../util/entities/Config";
import { ConfigEntity } from "@spacebar/database/entities/Config";
import fs from "node:fs";
import { ProcessLifecycle } from "../util/util/ProcessLifecycle";
+138
View File
@@ -0,0 +1,138 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, JoinColumn, ManyToOne, OneToOne, RelationId } from "typeorm";
import { BaseClass } from "./BaseClass";
import { Team } from "./Team";
import { User } from "./User";
import { Guild } from "./Guild";
@Entity({
name: "applications",
})
export class Application extends BaseClass {
@Column()
name: string;
@Column({ nullable: true })
icon?: string;
@Column({ nullable: true })
description: string;
@Column({ nullable: true })
summary: string = "";
@Column({ type: "jsonb", nullable: true })
type?: object; // TODO: this type is bad
@Column()
hook: boolean = true;
@Column()
bot_public?: boolean = true;
@Column()
bot_require_code_grant?: boolean = false;
@Column()
verify_key: string;
@JoinColumn({ name: "owner_id" })
@ManyToOne(() => User, { onDelete: "CASCADE" })
owner: User;
// TODO: enum this? https://discord.com/developers/docs/resources/application#application-object-application-flags
@Column()
flags: number = 0;
@Column({ type: "varchar", nullable: true })
redirect_uris: string[] = [];
@Column({ nullable: true })
rpc_application_state: number = 0;
@Column({ nullable: true })
store_application_state: number = 1;
@Column({ nullable: true })
verification_state: number = 1;
@Column({ nullable: true })
interactions_endpoint_url?: string;
@Column({ nullable: true })
integration_public: boolean = true;
@Column({ nullable: true })
integration_require_code_grant: boolean = false;
@Column({ nullable: true })
discoverability_state: number = 1;
@Column({ nullable: true })
discovery_eligibility_flags: number = 2240;
@JoinColumn({ name: "bot_user_id" })
@OneToOne(() => User, { onDelete: "CASCADE" })
bot?: User;
@Column({ type: "varchar", array: true, nullable: true })
tags?: string[];
@Column({ nullable: true })
cover_image?: string; // the application's default rich presence invite cover image hash
@Column({ type: "jsonb", nullable: true })
install_params?: { scopes: string[]; permissions: string };
@Column({ nullable: true })
terms_of_service_url?: string;
@Column({ nullable: true })
privacy_policy_url?: string;
@Column({ nullable: true })
@RelationId((application: Application) => application.guild)
guild_id?: string;
@JoinColumn({ name: "guild_id" })
@ManyToOne(() => Guild)
guild?: Guild; // guild to which the app is linked, e.g. a developer support server
@Column({ nullable: true })
custom_install_url?: string;
//just for us
//@Column({ type: "varchar", array: true, nullable: true })
//rpc_origins?: string[];
//@Column({ nullable: true })
//primary_sku_id?: string; // if this application is a game sold, this field will be the id of the "Game SKU" that is created,
//@Column({ nullable: true })
//slug?: string; // if this application is a game sold, this field will be the URL slug that links to the store page
@JoinColumn({ name: "team_id" })
@ManyToOne(() => Team, {
onDelete: "CASCADE",
nullable: true,
})
team?: Team;
}
@@ -0,0 +1,88 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity } from "typeorm";
import { BaseClass } from "./BaseClass";
import {
ApplicationCommandHandlerType,
ApplicationCommandOption,
ApplicationCommandIndexPermissions,
ApplicationCommandType,
Snowflake,
ApplicationIntegrationType,
InteractionContextType,
} from "@spacebar/schemas";
@Entity({
name: "application_commands",
})
export class ApplicationCommand extends BaseClass {
@Column({ default: ApplicationCommandType.CHAT_INPUT })
type?: ApplicationCommandType;
@Column()
application_id: Snowflake;
@Column({ nullable: true })
guild_id?: Snowflake;
@Column()
name: string;
@Column({ nullable: true, type: "jsonb" })
name_localizations?: Record<string, string>;
@Column()
description: string;
@Column({ nullable: true, type: "jsonb" })
description_localizations?: Record<string, string>;
@Column({ type: "jsonb", default: [] })
options: ApplicationCommandOption[];
@Column({ nullable: true, type: String })
default_member_permissions: string | null;
/*
* @deprecated
*/
@Column({ default: true })
dm_permission?: boolean;
@Column({ nullable: true, type: "jsonb" })
permissions?: ApplicationCommandIndexPermissions;
@Column({ default: false })
nsfw?: boolean;
@Column({ nullable: true, type: "jsonb" })
integration_types?: ApplicationIntegrationType[];
@Column({ default: 0 })
global_popularity_rank?: number;
@Column({ nullable: true, type: "jsonb" })
contexts?: InteractionContextType[];
@Column({ default: 0 })
version: Snowflake;
@Column({ default: 0 })
handler?: ApplicationCommandHandlerType;
}
+92
View File
@@ -0,0 +1,92 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { BeforeRemove, Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { Config, deleteFile } from "../../util/util";
import { BaseClass } from "./BaseClass";
import { getUrlSignature, NewUrlUserSignatureData, NewUrlSignatureData } from "../../util/Signing";
import { PublicAttachment } from "../../schemas/api/messages/Attachments";
@Entity({
name: "attachments",
})
export class Attachment extends BaseClass {
@Column()
filename: string; // name of file attached
@Column()
size: number; // size of file in bytes
@Column({ nullable: true })
height?: number; // height of file (if image)
@Column({ nullable: true })
width?: number; // width of file (if image)
@Column({ nullable: true })
content_type?: string;
@Column({ nullable: true })
@RelationId((attachment: Attachment) => attachment.message)
message_id: string;
@Column({ nullable: true })
@RelationId((attachment: Attachment) => attachment.channel)
channel_id: string;
@JoinColumn({ name: "message_id" })
@ManyToOne(() => require("./Message").Message, (message: import("./Message").Message) => message.attachments, {
onDelete: "CASCADE",
})
message: import("./Message").Message;
@JoinColumn({ name: "channel_id" })
@ManyToOne(() => require("./Channel").Channel, {
onDelete: "CASCADE",
})
channel: import("./Channel").Channel;
@BeforeRemove()
onDelete() {
return deleteFile(new URL(this.toJSON().url).pathname);
}
toJSON() {
const channelId = this.channel_id ?? this.channel?.id ?? this.message?.channel_id;
const messageId = this.message_id ?? this.message?.id;
return {
...this,
url: `${Config.get().cdn.endpointPublic}/attachments/${channelId}/${messageId}/${this.filename}`,
proxy_url: `${Config.get().cdn.endpointPublic}/attachments/${channelId}/${messageId}/${this.filename}`,
};
}
signUrls(data: NewUrlUserSignatureData): PublicAttachment {
const att = Attachment.prototype.toJSON.apply(this);
return {
...att,
url: getUrlSignature(new NewUrlSignatureData({ ...data, url: att.url }))
.applyToUrl(att.url)
.toString(),
proxy_url: att.proxy_url
? getUrlSignature(new NewUrlSignatureData({ ...data, url: att.proxy_url }))
.applyToUrl(att.proxy_url)
.toString()
: att.proxy_url,
};
}
}
+61
View File
@@ -0,0 +1,61 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { BaseClass } from "./BaseClass";
import { User } from "./User";
import { AuditLogChange, AuditLogEvents } from "@spacebar/schemas";
@Entity({
name: "audit_logs",
})
export class AuditLog extends BaseClass {
@JoinColumn({ name: "target_id" })
@ManyToOne(() => User)
target?: User;
@Column({ nullable: true })
@RelationId((auditlog: AuditLog) => auditlog.user)
user_id: string;
@JoinColumn({ name: "user_id" })
@ManyToOne(() => User, (user: User) => user.id)
user: User;
@Column({ type: "int" })
action_type: AuditLogEvents;
@Column({ type: "jsonb", nullable: true })
options?: {
delete_member_days?: string;
members_removed?: string;
channel_id?: string;
messaged_id?: string;
count?: string;
id?: string;
type?: string;
role_name?: string;
};
@Column()
@Column({ type: "jsonb" })
changes: AuditLogChange[];
@Column({ nullable: true })
reason?: string;
}
+67
View File
@@ -0,0 +1,67 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2024 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { BaseClass } from "./BaseClass";
import { Entity, JoinColumn, ManyToOne, Column } from "typeorm";
import { User } from "./User";
import { AutomodAction, AutomodRuleEventType, AutomodRuleTriggerMetadata, AutomodRuleTriggerType } from "@spacebar/schemas";
@Entity({
name: "automod_rules",
})
export class AutomodRule extends BaseClass {
@JoinColumn({ name: "creator_id" })
@ManyToOne(() => User, { onDelete: "CASCADE" })
creator: User;
@Column()
enabled: boolean;
@Column()
event_type: AutomodRuleEventType;
@Column({ type: "int8", array: true })
exempt_channels: string[];
@Column({ type: "int8", array: true })
exempt_roles: string[];
@Column()
guild_id: string;
@Column()
name: string;
@Column()
position: number;
@Column()
trigger_type: AutomodRuleTriggerType;
@Column({
type: "jsonb",
nullable: true,
})
trigger_metadata?: // this is null for "Block suspected spam content"
AutomodRuleTriggerMetadata;
@Column({
type: "jsonb",
})
actions: AutomodAction[];
}
+56
View File
@@ -0,0 +1,56 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
import { BaseClass } from "./BaseClass";
import { User } from "./User";
import crypto from "node:crypto";
import { Config } from "../../util/util";
@Entity({
name: "backup_codes",
})
export class BackupCode extends BaseClass {
@JoinColumn({ name: "user_id" })
@ManyToOne(() => User, { onDelete: "CASCADE" })
user: User;
@Column()
code: string;
@Column()
consumed: boolean;
@Column()
expired: boolean;
}
export function generateMfaBackupCodes(user_id: string) {
const backup_codes: BackupCode[] = [];
for (let i = 0; i < Config.get().security.mfaBackupCodeCount; i++) {
const code = BackupCode.create({
user: { id: user_id },
code: crypto.randomBytes(4).toString("hex"), // 8 characters
consumed: false,
expired: false,
});
backup_codes.push(code);
}
return backup_codes;
}
+37
View File
@@ -0,0 +1,37 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity } from "typeorm";
import { BaseClassWithoutId } from "./BaseClass";
@Entity({
name: "badges",
})
export class Badge extends BaseClassWithoutId {
@Column({ primary: true })
id: string;
@Column()
description: string;
@Column()
icon: string;
@Column({ nullable: true })
link?: string;
}
+61
View File
@@ -0,0 +1,61 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { BaseClass } from "./BaseClass";
import { Guild } from "./Guild";
import { User } from "./User";
@Entity({
name: "bans",
})
export class Ban extends BaseClass {
@Column({ nullable: true })
@RelationId((ban: Ban) => ban.user)
user_id: string;
@JoinColumn({ name: "user_id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
user: User;
@Column({ nullable: true })
@RelationId((ban: Ban) => ban.guild)
guild_id: string;
@JoinColumn({ name: "guild_id" })
@ManyToOne(() => Guild, {
onDelete: "CASCADE",
})
guild: Guild;
@Column({ nullable: true })
@RelationId((ban: Ban) => ban.executor)
executor_id: string;
@JoinColumn({ name: "executor_id" })
@ManyToOne(() => User)
executor: User;
@Column({ nullable: true })
ip?: string;
@Column({ nullable: true })
reason?: string;
}
+125
View File
@@ -0,0 +1,125 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { BaseEntity, BeforeInsert, BeforeUpdate, FindOptionsWhere, PrimaryColumn } from "typeorm";
import { getDatabase } from "../Database";
import { Snowflake } from "../../util/util/Snowflake";
import { OrmUtils } from "../../util/imports/OrmUtils";
import { annotationsKey } from "../../util/util/Decorators";
export class BaseClassWithoutId extends BaseEntity {
private get construct() {
return this.constructor;
}
// stores custom annotations we may stick on the properties
[annotationsKey]: { [p: string]: string[] };
// retrieves the custom annotations as its not super straight forward
get_annotations() {
return Object.getPrototypeOf(this)[annotationsKey];
}
// Loops through all the keys and compares it to annotations. If the RemoveEmpty is there it sets the value to undefined if null
clean_data() {
const annotations = this.get_annotations();
if (annotations == undefined || annotations.length > 0)
//prevent errors if there are no annotations on an object
return;
for (const key in this) {
if (
key in this && // This object has this property, should never fail but better to be safe
key in annotations && // If this property has an annotation
annotations[key].indexOf("JsonRemoveEmpty") > -1 && // if one of the annotations is JsonRemoveEmpty
(this[key] == null || // If this property is null
(typeof this[key] == "object" && Object.keys(this[key]).length == 0))
) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
this[key] = undefined; // set to undefined to remove
}
if (
key in this && // This object has this property, should never fail but better to be safe
key in annotations && // If this property has an annotation
annotations[key].indexOf("JsonNumber") > -1 && // if one of the annotations is JsonRemoveEmpty
typeof this[key] == "string" // and its a String
) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
this[key] = Number(this[key]); // convert string back to number
}
}
return this;
}
private get metadata() {
return getDatabase()?.getMetadata(this.construct);
}
assign(props: object) {
OrmUtils.mergeDeep(this, props);
return this;
}
// TODO: fix eslint
// eslint-disable-next-line @typescript-eslint/no-explicit-any
toJSON(): any {
this.clean_data();
return Object.fromEntries(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
this.metadata!.columns // @ts-ignore
.map((x) => [x.propertyName, this[x.propertyName]])
.concat(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.metadata.relations.map((x) => [
x.propertyName,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this[x.propertyName],
]),
),
);
}
static increment<T extends BaseClass>(conditions: FindOptionsWhere<T>, propertyPath: string, value: number | string) {
const repository = this.getRepository();
return repository.increment(conditions, propertyPath, value);
}
static decrement<T extends BaseClass>(conditions: FindOptionsWhere<T>, propertyPath: string, value: number | string) {
const repository = this.getRepository();
return repository.decrement(conditions, propertyPath, value);
}
public async insert(): Promise<this> {
await getDatabase()!.getRepository(this.construct).insert(this);
return this;
}
}
export class BaseClass extends BaseClassWithoutId {
@PrimaryColumn({ type: "int8" })
id: string = Snowflake.generate();
@BeforeUpdate()
@BeforeInsert()
_do_validate() {
if (!this.id) this.id = Snowflake.generate();
}
}
+57
View File
@@ -0,0 +1,57 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, PrimaryColumn } from "typeorm";
import { BaseClassWithoutId } from "./BaseClass";
// TODO: categories:
// [{
// "id": 16,
// "default": "Anime & Manga",
// "localizations": {
// "de": "Anime & Manga",
// "fr": "Anim\u00e9s et mangas",
// "ru": "\u0410\u043d\u0438\u043c\u0435 \u0438 \u043c\u0430\u043d\u0433\u0430"
// }
// },
// "is_primary": false/true
// }]
// Also populate discord default categories
@Entity({
name: "categories",
})
export class Categories extends BaseClassWithoutId {
// Not using snowflake
@PrimaryColumn()
id: number;
@Column({ nullable: true })
name: string;
@Column({ type: "jsonb" })
localizations: string;
// Whether to show the category prominently (e.g. in a sidebar) instead of only secondary (e.g. in search results)
@Column({ nullable: true })
is_primary: boolean;
@Column({ nullable: true })
icon?: string;
}
+736
View File
@@ -0,0 +1,736 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { HTTPError } from "lambert-server/HTTPError";
import { Column, Entity, JoinColumn, ManyToOne, OneToMany, RelationId } from "typeorm";
import { DmChannelDTO } from "../../util/dtos";
import { ChannelCreateEvent, ChannelRecipientRemoveEvent, ThreadCreateEvent, ThreadMembersUpdateEvent } from "../../util/interfaces";
import { InvisibleCharacters, Snowflake, emitEvent, getPermission, trimSpecial, Permissions, Config, DiscordApiErrors } from "../../util/util";
import { BaseClass } from "./BaseClass";
import { Guild } from "./Guild";
import { Invite } from "./Invite";
import { Message } from "./Message";
import { Tag } from "./Tag";
import { ReadState } from "./ReadState";
import { Recipient } from "./Recipient";
import { User } from "./User";
import { VoiceState } from "./VoiceState";
import { Webhook } from "./Webhook";
import { Member } from "./Member";
import { ChannelPermissionOverwrite, ChannelType, PublicChannel, PublicUserProjection, ThreadMetadata } from "@spacebar/schemas";
import { OrmUtils } from "../../util/imports";
import { ThreadMember } from "./ThreadMember";
@Entity({
name: "channels",
})
export class Channel extends BaseClass {
@Column()
created_at: Date;
@Column({ nullable: true })
name?: string;
@Column({ type: "text", nullable: true })
icon?: string | null;
@Column({ type: "int" })
type: ChannelType;
@OneToMany(() => Recipient, (recipient: Recipient) => recipient.channel, {
cascade: true,
orphanedRowAction: "delete",
})
recipients?: Recipient[];
@OneToMany(() => ThreadMember, (member: ThreadMember) => member.channel, {
cascade: true,
orphanedRowAction: "delete",
})
thread_members?: ThreadMember[];
@Column({ nullable: true })
last_message_id?: string;
@Column({ nullable: true })
@RelationId((channel: Channel) => channel.guild)
guild_id?: string;
@JoinColumn({ name: "guild_id" })
@ManyToOne(() => Guild, (guild) => guild.channels, {
onDelete: "CASCADE",
nullable: true,
})
guild?: Guild;
@Column({ nullable: true })
@RelationId((channel: Channel) => channel.parent)
parent_id: string | null;
@JoinColumn({ name: "parent_id" })
@ManyToOne(() => Channel)
parent?: Channel;
// for group DMs and owned custom channel types
@Column({ nullable: true })
@RelationId((channel: Channel) => channel.owner)
owner_id?: string;
@JoinColumn({ name: "owner_id" })
@ManyToOne(() => User)
owner: User;
@Column({ nullable: true, type: "timestamp with time zone" })
last_pin_timestamp?: Date | null; // ISO8601
@Column({ nullable: true })
default_auto_archive_duration?: number;
@Column({ type: "jsonb", nullable: true })
permission_overwrites?: ChannelPermissionOverwrite[];
@Column({ nullable: true })
video_quality_mode?: number;
@Column({ nullable: true })
bitrate?: number;
@Column({ nullable: true })
user_limit?: number;
@Column()
nsfw: boolean = false;
@Column({ nullable: true })
rate_limit_per_user?: number;
@Column({ nullable: true })
topic?: string;
@OneToMany(() => Invite, (invite: Invite) => invite.channel, {
cascade: true,
orphanedRowAction: "delete",
})
invites?: Invite[];
@Column({ nullable: true })
retention_policy_id?: string;
@OneToMany(() => Message, (message: Message) => message.channel, {
cascade: true,
orphanedRowAction: "delete",
})
messages?: Message[];
@OneToMany(() => VoiceState, (voice_state: VoiceState) => voice_state.channel, {
cascade: true,
orphanedRowAction: "delete",
})
voice_states?: VoiceState[];
@OneToMany(() => ReadState, (read_state: ReadState) => read_state.channel, {
cascade: true,
orphanedRowAction: "delete",
})
read_states?: ReadState[];
@OneToMany(() => Webhook, (webhook: Webhook) => webhook.channel, {
cascade: true,
orphanedRowAction: "delete",
})
webhooks?: Webhook[];
@Column()
flags: number = 0;
@Column({ nullable: true })
default_thread_rate_limit_per_user?: number = 0;
@Column({ type: "jsonb", nullable: true })
thread_metadata?: ThreadMetadata;
@Column({ nullable: true })
member_count?: number;
@Column({ nullable: true })
message_count?: number;
@Column({ nullable: true })
total_message_sent?: number;
@JoinColumn({ name: "available_tags_ids" })
@OneToMany(() => Tag, (tag: Tag) => tag.channel, {
cascade: true,
orphanedRowAction: "delete",
})
available_tags?: Tag[];
@Column("text", { array: true, nullable: true })
applied_tags?: string[];
@Column("text", { nullable: true })
status?: string | null;
/** Must be calculated Channel.calculatePosition */
position: number;
// TODO: DM channel
static async createChannel(
channel: Partial<Channel>,
user_id: string = "0",
opts?: {
keepId?: boolean;
skipExistsCheck?: boolean;
skipPermissionCheck?: boolean;
skipEventEmit?: boolean;
skipNameChecks?: boolean;
},
): Promise<Channel> {
if (!opts?.skipPermissionCheck) {
// Always check if user has permission first
const permissions = await getPermission(user_id, channel.guild_id);
permissions.hasThrow("MANAGE_CHANNELS");
}
const guild = await Guild.findOneOrFail({
where: { id: channel.guild_id },
select: {
features: !opts?.skipNameChecks,
channel_ordering: true,
id: true,
},
});
if (!opts?.skipNameChecks) {
if (!guild.features.includes("ALLOW_INVALID_CHANNEL_NAMES") && channel.name) {
for (const character of InvisibleCharacters) if (channel.name.includes(character)) throw new HTTPError("Channel name cannot include invalid characters", 403);
// Categories skip these checks on discord.com
if (
(channel.type !== ChannelType.GUILD_CATEGORY && channel.type !== ChannelType.GUILD_STAGE_VOICE && channel.type !== ChannelType.GUILD_VOICE) ||
guild.features.includes("IRC_LIKE_CATEGORY_NAMES")
) {
if (channel.name.includes(" ")) throw new HTTPError("Channel name cannot include invalid characters", 403);
if (channel.name.match(/--+/g)) throw new HTTPError("Channel name cannot include multiple adjacent dashes.", 403);
if (channel.name.charAt(0) === "-" || channel.name.charAt(channel.name.length - 1) === "-")
throw new HTTPError("Channel name cannot start/end with dash.", 403);
} else channel.name = channel.name.trim(); //category names are trimmed client side on discord.com
}
if (!guild.features.includes("ALLOW_UNNAMED_CHANNELS")) {
if (!channel.name) throw new HTTPError("Channel name cannot be empty.", 403);
}
}
switch (channel.type) {
// TODO: should threads even be routed through this function instead of createThreadChannel?
case ChannelType.GUILD_PUBLIC_THREAD:
case ChannelType.GUILD_PRIVATE_THREAD:
case ChannelType.GUILD_NEWS_THREAD:
case ChannelType.GUILD_TEXT:
case ChannelType.GUILD_FORUM:
case ChannelType.GUILD_MEDIA:
case ChannelType.GUILD_NEWS:
case ChannelType.GUILD_VOICE:
if (channel.parent_id && !opts?.skipExistsCheck) {
const exists = await Channel.findOneOrFail({
where: { id: channel.parent_id },
});
if (!exists) throw new HTTPError("Parent id channel doesn't exist", 400);
if (exists.guild_id !== channel.guild_id) throw new HTTPError("The category channel needs to be in the guild");
}
break;
case ChannelType.GUILD_CATEGORY:
case ChannelType.UNHANDLED:
break;
case ChannelType.DM:
case ChannelType.GROUP_DM:
throw new HTTPError("You can't create a dm channel in a guild");
case ChannelType.GUILD_STORE:
default:
throw new HTTPError("Not yet supported");
}
if (!channel.permission_overwrites) channel.permission_overwrites = [];
// TODO: eagerly auto generate position of all guild channels
const position = (channel.type === ChannelType.UNHANDLED ? 0 : channel.position) || 0;
channel = {
...channel,
...(!opts?.keepId && { id: Snowflake.generate() }),
created_at: new Date(),
position,
// from #876 (threads): shouldnt these be undefined?
// message_count: 0,
// member_count: 0,
// total_message_sent: 0,
};
// TODO: figure out why the generic is required here
const ret = Channel.create<Channel>(channel);
await Promise.all([
ret.save(),
!opts?.skipEventEmit
? emitEvent({
event: "CHANNEL_CREATE",
data: ret.toJSON(),
guild_id: channel.guild_id,
} satisfies ChannelCreateEvent)
: Promise.resolve(),
Guild.insertChannelInOrder(guild.id, ret.id, position, guild),
]);
return ret;
}
threadOnly() {
return this.type === ChannelType.GUILD_FORUM || this.type === ChannelType.GUILD_MEDIA;
}
static async createThreadChannel(
channel: Partial<Channel>,
metadata: Partial<ThreadMetadata>,
user_id: string = "0",
opts?: {
keepId?: boolean;
skipExistsCheck?: boolean;
skipParentExistsCheck?: boolean;
skipPermissionCheck?: boolean;
skipEventEmit?: boolean;
skipNameChecks?: boolean;
},
): Promise<Channel> {
channel = {
// set the default type to private
type: ChannelType.GUILD_PRIVATE_THREAD,
...channel,
...(!opts?.keepId && { id: Snowflake.generate() }),
created_at: new Date(),
position: 0, // TODO:
message_count: 0,
member_count: 1,
total_message_sent: 0,
};
const exists = await Channel.findOne({
where: {
id: channel.id,
},
});
const guild = await Guild.findOneOrFail({ where: { id: channel.guild_id } });
if (!opts?.skipExistsCheck && !guild.features.includes("ALLOW_EXISTING_THREAD_FOR_MESSAGE") && exists) throw DiscordApiErrors.THREAD_ALREADY_CREATED_FOR_THIS_MESSAGE;
if (!channel.parent_id) throw new HTTPError("Parent id not set", 400);
const parent = await Channel.findOneOrFail({ where: { id: channel.parent_id } });
if (!opts?.skipPermissionCheck) {
// Always check if user has permission first
const permissions = await getPermission(user_id, parent.guild_id);
permissions.hasThrow(channel.type === ChannelType.GUILD_PRIVATE_THREAD ? "CREATE_PRIVATE_THREADS" : "CREATE_PUBLIC_THREADS");
}
channel = {
...channel,
permission_overwrites: parent.permission_overwrites,
nsfw: parent.nsfw,
owner_id: user_id,
guild_id: parent.guild_id,
thread_metadata: {
create_timestamp: new Date().toISOString(),
archive_timestamp: new Date().toISOString(),
archived: false,
auto_archive_duration: 0,
invitable: channel.type === ChannelType.GUILD_NEWS_THREAD || channel.type === ChannelType.GUILD_PUBLIC_THREAD ? Config.get().guild.publicThreadsInvitable : false,
locked: false,
...metadata,
},
};
if (!opts?.skipParentExistsCheck) {
if (!parent) throw new HTTPError("Parent channel doesn't exist", 400);
if (parent.guild_id !== channel.guild_id) throw new HTTPError("The category channel needs to be in the guild");
}
if (!opts?.skipNameChecks) {
const guild = await Guild.findOneOrFail({ where: { id: channel.guild_id } });
if (!guild.features.includes("ALLOW_INVALID_CHANNEL_NAMES") && channel.name) {
for (const character of InvisibleCharacters) if (channel.name.includes(character)) throw new HTTPError("Channel name cannot include invalid characters", 403);
channel.name = channel.name.trim(); //category names are trimmed client side on discord.com
}
if (!guild.features.includes("ALLOW_UNNAMED_CHANNELS")) {
if (!channel.name) throw new HTTPError("Channel name cannot be empty.", 403);
}
}
// TODO: eagerly auto generate position of all guild channels
const thread = await OrmUtils.mergeDeep(new Channel(), channel).save();
const member = {
id: thread.id,
user_id,
join_timestamp: new Date(),
muted: false,
mute_config: null,
flags: 0,
};
if (channel.member_count) channel.member_count++;
const threadMember = await OrmUtils.mergeDeep(new ThreadMember(), member).save();
if (!opts?.skipEventEmit) {
await Promise.all([
emitEvent({
event: "THREAD_CREATE",
data: {
...thread,
newly_created: true,
},
guild_id: channel.guild_id,
} satisfies ThreadCreateEvent),
emitEvent({
event: "THREAD_MEMBERS_UPDATE",
data: {
guild_id: channel.guild_id!, // TODO: is this the right fix?
id: thread.id,
member_count: channel.member_count ?? 0, //TODO: is this the right fix?
added_members: [threadMember],
removed_member_ids: [],
},
guild_id: channel.guild_id,
} satisfies ThreadMembersUpdateEvent),
]);
}
return thread;
}
static async createDMChannel(recipients: string[], creator_user_id: string, name?: string) {
recipients = [...new Set(recipients)].filter((x) => x !== creator_user_id);
// TODO: check config for max number of recipients
/** if you want to disallow note to self channels, uncomment the conditional below
const otherRecipientsUsers = await User.find({ where: recipients.map((x) => ({ id: x })) });
if (otherRecipientsUsers.length !== recipients.length) {
throw new HTTPError("Recipient/s not found");
}
**/
const type = recipients.length > 1 ? ChannelType.GROUP_DM : ChannelType.DM;
let channel = null;
let needsTx = true;
const channelRecipients = [...recipients, creator_user_id];
const userRecipients = await Recipient.find({
where: { user_id: creator_user_id },
relations: { channel: { recipients: true } },
});
for (const ur of userRecipients) {
if (!ur.channel.recipients) continue;
const re = ur.channel.recipients.map((r) => r.user_id);
if (re.length === channelRecipients.length) {
if (channelRecipients.every((_) => re.includes(_))) {
if (channel == null) {
channel = ur.channel;
if (!ur.closed) needsTx = false;
await ur.assign({ closed: false }).save();
}
}
}
}
if (channel == null) {
name = trimSpecial(name);
channel = await Channel.create({
name,
type,
owner_id: type === ChannelType.GROUP_DM ? creator_user_id : undefined,
created_at: new Date(),
last_message_id: undefined,
recipients: channelRecipients.map((x) =>
Recipient.create({
user_id: x,
closed: !(type === ChannelType.GROUP_DM || x === creator_user_id),
}),
),
nsfw: false,
}).save();
}
const channel_dto = await DmChannelDTO.from(channel);
if (!needsTx) {
/*ignored*/
} else if (type === ChannelType.GROUP_DM && channel.recipients) {
for (const recipient of channel.recipients) {
await emitEvent({
event: "CHANNEL_CREATE",
data: channel_dto.excludedRecipients([recipient.user_id]),
user_id: recipient.user_id,
});
}
} else {
await emitEvent({
event: "CHANNEL_CREATE",
data: channel_dto,
user_id: creator_user_id,
});
}
if (recipients.length === 1) return channel_dto;
else return channel_dto.excludedRecipients([creator_user_id]);
}
static async removeRecipientFromChannel(channel: Channel, user_id: string) {
await Recipient.delete({ channel_id: channel.id, user_id: user_id });
channel.recipients = channel.recipients?.filter((r) => r.user_id !== user_id);
if (channel.recipients?.length === 0) {
await Channel.deleteChannel(channel);
await emitEvent({
event: "CHANNEL_DELETE",
data: await DmChannelDTO.from(channel, [user_id]),
user_id: user_id,
});
return;
}
await emitEvent({
event: "CHANNEL_DELETE",
data: await DmChannelDTO.from(channel, [user_id]),
user_id: user_id,
});
//If the owner leave the server user is the new owner
if (channel.owner_id === user_id) {
channel.owner_id = "1"; // The channel is now owned by the server user
await emitEvent({
event: "CHANNEL_UPDATE",
data: await DmChannelDTO.from(channel, [user_id]),
channel_id: channel.id,
});
}
await channel.save();
await emitEvent({
event: "CHANNEL_RECIPIENT_REMOVE",
data: {
channel_id: channel.id,
user: await User.findOneOrFail({
where: { id: user_id },
select: PublicUserProjection,
}),
},
channel_id: channel.id,
} satisfies ChannelRecipientRemoveEvent);
}
static async deleteChannel(channel: Channel) {
// TODO Delete attachments from the CDN for messages in the channel
await Channel.delete({ id: channel.id });
if (channel.guild_id) {
const guild = await Guild.findOneOrFail({
where: { id: channel.guild_id },
select: { channel_ordering: true },
});
const updatedOrdering = guild.channel_ordering.filter((id) => id != channel.id);
await Guild.update({ id: channel.guild_id }, { channel_ordering: updatedOrdering });
}
}
static async calculatePosition(channel_id: string, guild_id: string, guild?: Guild) {
if (!guild)
guild = await Guild.findOneOrFail({
where: { id: guild_id },
select: { channel_ordering: true },
});
return guild.channel_ordering.findIndex((id) => channel_id == id);
}
static async getOrderedChannels(guild_id: string, guild?: Guild) {
if (!guild)
guild = await Guild.findOneOrFail({
where: { id: guild_id },
select: { channel_ordering: true },
});
const channels = await Promise.all(guild.channel_ordering.map((id) => Channel.findOne({ where: { id } })));
return channels
.filter((channel) => channel !== null)
.reduce((r, v) => {
v = v as Channel;
v.position = (guild as Guild).channel_ordering.indexOf(v.id);
r[v.position] = v;
return r;
}, [] as Array<Channel>);
}
isDm() {
return this.type === ChannelType.DM || this.type === ChannelType.GROUP_DM;
}
isThread() {
return this.type === ChannelType.GUILD_NEWS_THREAD || this.type === ChannelType.GUILD_PUBLIC_THREAD || this.type === ChannelType.GUILD_PRIVATE_THREAD;
}
isForum() {
return this.type === ChannelType.GUILD_FORUM || this.type === ChannelType.GUILD_MEDIA;
}
isPrivateThread() {
return this.type === ChannelType.GUILD_PRIVATE_THREAD;
}
isPublicThread() {
return this.type === ChannelType.GUILD_NEWS_THREAD || this.type === ChannelType.GUILD_PUBLIC_THREAD;
}
// Does the channel support sending messages ( eg categories do not )
isWritable() {
const disallowedChannelTypes = [ChannelType.GUILD_CATEGORY, ChannelType.GUILD_STAGE_VOICE];
return disallowedChannelTypes.indexOf(this.type) == -1;
}
async getUserPermissions(opts: { user_id?: string; user?: User; member?: Member; guild?: Guild }): Promise<Permissions> {
if (this.isDm()) return this.owner_id == (opts.user_id ?? opts.user?.id) ? Permissions.ALL : Permissions.DEFAULT_DM_PERMISSIONS;
let guild = opts.guild;
if (!guild) {
if (this.guild) guild = this.guild;
else if (this.guild_id) guild = await Guild.findOneOrFail({ where: { id: this.guild_id } });
else {
console.error("Channel.getUserPermissions: called without guild for non-DM channel.");
return Permissions.NONE;
}
}
// check if we can resolve here to short-circuit possibly calling the database unnecessarily
// TODO: do we want to have an instance-wide opt out of this behavior? It would just be an extra if statement here
const ownerId = guild?.owner?.id ?? guild?.owner_id;
if (!!opts.user_id && ownerId === opts.user_id) return Permissions.ALL;
if (!!opts.user?.id && ownerId === opts.user?.id) return Permissions.ALL;
if (!!opts.member?.id && ownerId === opts.member?.id) return Permissions.ALL;
let member = opts.member;
if (!member) {
if (opts.user) member = await Member.findOneOrFail({ where: { guild_id: guild.id, id: opts.user.id }, relations: { roles: true } });
else if (opts.user_id) member = await Member.findOneOrFail({ where: { guild_id: guild.id, id: opts.user_id }, relations: { roles: true } });
else {
console.error("Channel.getUserPermissions: called without user or member for non-DM channel.");
return Permissions.NONE;
}
}
const roles = (
member.roles ||
(
await Member.findOneOrFail({
where: { guild_id: guild.id, index: member.index },
relations: { roles: true },
select: {
roles: {
id: true,
permissions: true,
position: true,
},
},
loadEagerRelations: false,
})
).roles
).sort((a, b) => a.position - b.position); // ascending by position
return Permissions.finalPermission({
user: {
...member,
roles: roles.map((r) => r.id),
flags: member.user?.flags ?? (await User.findOneOrFail({ where: { id: member.id }, select: { flags: true } })).flags,
},
guild: { id: guild.id, owner_id: guild.owner_id!, roles }, // We don't care about including *all* guild roles, as not all of them are relevant...
channel: this,
});
}
// TODO: should we throw for missing args?
async canViewChannel(opts: { user_id?: string; user?: User; member?: Member; guild?: Guild }): Promise<boolean> {
if (this.isDm()) return await this.canViewDmChannel(opts.user_id, opts.user);
const userPerms = await this.getUserPermissions(opts);
return userPerms.has("VIEW_CHANNEL");
}
private async canViewDmChannel(user_id?: string, user?: User): Promise<boolean> {
const userId = user_id ?? user?.id;
if (!userId) {
console.error("Channel.canViewChannel: called without user for DM channel.");
return false;
}
if (!user) return false;
if (this.recipients) return this.recipients.some((r) => r.user_id === user.id && !r.closed);
else {
// we dont have recipients on hand
const recipient = await Recipient.findOne({ where: { channel_id: this.id, user_id: user.id } });
return recipient == null ? false : !recipient.closed;
}
}
toJSON(): PublicChannel {
return {
...this,
last_pin_timestamp: this.last_pin_timestamp?.toISOString(),
guild_id: this.guild_id ?? undefined,
recipients: undefined, //this.recipients?.map(x=>x.user.toPublicUser()), // TODO: fix me
owner: undefined, // TODO: fix me - this is thread owner
// these fields are not returned depending on the type of channel
bitrate: this.bitrate || undefined,
user_limit: this.user_limit || undefined,
rate_limit_per_user: this.rate_limit_per_user || undefined,
owner_id: this.owner_id || undefined,
...(this.isThread() && this.thread_members ? { member_ids_preview: this.thread_members.map((_) => _.member.id) } : {}),
default_auto_archive_duration: this.default_auto_archive_duration ?? undefined,
retention_policy_id: undefined,
thread_metadata: this.thread_metadata
? {
...this.thread_metadata,
archive_timestamp: new Date(this.thread_metadata.archive_timestamp).toISOString().replace("Z", "+00:00"),
create_timestamp: new Date(this.thread_metadata.create_timestamp).toISOString().replace("Z", "+00:00"),
}
: undefined,
member_count: this.member_count ?? undefined,
message_count: this.message_count ?? undefined,
total_message_sent: this.total_message_sent ?? undefined,
applied_tags: this.applied_tags ?? undefined,
permission_overwrites: this.isThread() ? undefined : this.permission_overwrites,
};
}
}
+43
View File
@@ -0,0 +1,43 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity } from "typeorm";
import { BaseClass } from "./BaseClass";
@Entity({
name: "client_release",
})
export class ClientRelease extends BaseClass {
@Column()
name: string;
@Column()
pub_date: Date;
@Column()
url: string;
@Column()
platform: string;
@Column()
enabled: boolean;
@Column({ nullable: true })
notes?: string;
}
+81
View File
@@ -0,0 +1,81 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { BaseClass } from "./BaseClass";
import { User } from "./User";
import { Channel } from "./Channel";
@Entity({
name: "cloud_attachments",
})
export class CloudAttachment extends BaseClass {
// Internal tracking metadata
@Column({ name: "user_id", nullable: true })
@RelationId((att: CloudAttachment) => att.user)
userId: string;
@JoinColumn({ name: "user_id" })
@ManyToOne(() => User, { nullable: true, onDelete: "SET NULL" })
user?: User;
@Column({ name: "channel_id", nullable: true })
@RelationId((att: CloudAttachment) => att.channel)
channelId?: string; // channel the file is uploaded to
@JoinColumn({ name: "channel_id" })
@ManyToOne(() => Channel, { nullable: true, onDelete: "SET NULL" })
channel?: Channel; // channel the file is uploaded to
@Column({ name: "upload_filename" })
uploadFilename: string;
// User-provided info
@Column({ name: "user_attachment_id", nullable: true })
userAttachmentId?: string;
@Column({ name: "user_filename" })
userFilename: string; // name of file attached
@Column({ name: "user_file_size", nullable: true })
userFileSize?: number; // size of file in bytes
@Column({ name: "user_original_content_type", nullable: true })
userOriginalContentType?: string;
@Column({ name: "user_is_clip", nullable: true })
userIsClip?: boolean; // whether the file is a clip
// Actual file info, initialised after upload
@Column({ nullable: true })
size?: number; // size of file in bytes
@Column({ nullable: true })
height?: number; // height of file (if image)
@Column({ nullable: true })
width?: number; // width of file (if image)
@Column({ name: "content_type", nullable: true })
contentType?: string;
// @BeforeRemove()
// onDelete() {
// return deleteFile(new URL(this.url).pathname);
// }
}
+31
View File
@@ -0,0 +1,31 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, PrimaryColumn } from "typeorm";
import { BaseClassWithoutId } from "./BaseClass";
@Entity({
name: "config",
})
export class ConfigEntity extends BaseClassWithoutId {
@PrimaryColumn()
key: string;
@Column({ type: "simple-json", nullable: true })
value: number | boolean | null | string | undefined;
}
+83
View File
@@ -0,0 +1,83 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { BaseClass } from "./BaseClass";
import { User } from "./User";
import { ConnectedAccountTokenData } from "@spacebar/schemas";
@Entity({
name: "connected_accounts",
})
export class ConnectedAccount extends BaseClass {
@Column()
external_id: string;
@Column({ nullable: true })
@RelationId((account: ConnectedAccount) => account.user)
user_id: string;
@JoinColumn({ name: "user_id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
user: User;
@Column({ select: false })
friend_sync?: boolean = false;
@Column()
name: string;
@Column({ select: false })
revoked?: boolean = false;
@Column({ select: false })
show_activity?: number = 0;
@Column()
type: string;
@Column()
verified?: boolean = true;
@Column({ select: false })
visibility?: number = 0;
@Column({ type: "varchar", array: true })
integrations?: string[] = [];
@Column({ type: "jsonb", name: "metadata", nullable: true })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
metadata_?: any;
@Column()
metadata_visibility?: number = 0;
@Column()
two_way_link?: boolean = false;
@Column({ select: false, nullable: true, type: "jsonb" })
token_data?: ConnectedAccountTokenData | null;
async revoke() {
this.revoked = true;
this.token_data = null;
await this.save();
}
}
@@ -0,0 +1,31 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, PrimaryColumn } from "typeorm";
import { BaseClassWithoutId } from "./BaseClass";
@Entity({
name: "connection_config",
})
export class ConnectionConfigEntity extends BaseClassWithoutId {
@PrimaryColumn()
key: string;
@Column({ type: "simple-json", nullable: true })
value: number | boolean | null | string | Date | undefined;
}
+38
View File
@@ -0,0 +1,38 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { BaseClass } from "./BaseClass";
import { Entity, Column } from "typeorm";
import { Embed } from "@spacebar/schemas";
@Entity({
name: "embed_cache",
})
export class EmbedCache extends BaseClass {
@Column()
url: string;
@Column({ type: "jsonb", nullable: true })
embed?: Embed;
@Column({ type: "jsonb", nullable: true })
embeds?: Embed[];
@Column({ name: "created_at", type: "timestamp with time zone" })
createdAt: Date;
}
+65
View File
@@ -0,0 +1,65 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { User } from "./index";
import { BaseClass } from "./BaseClass";
import { Guild } from "./Guild";
@Entity({
name: "emojis",
})
export class Emoji extends BaseClass {
@Column()
animated: boolean;
@Column()
available: boolean; // whether this emoji can be used, may be false due to various reasons
@Column()
guild_id: string;
@JoinColumn({ name: "guild_id" })
@ManyToOne(() => Guild, (guild) => guild.emojis, {
onDelete: "CASCADE",
})
guild: Guild;
@Column({ nullable: true })
@RelationId((emoji: Emoji) => emoji.user)
user_id: string;
@JoinColumn({ name: "user_id" })
@ManyToOne(() => User)
user: User;
@Column()
managed: boolean;
@Column()
name: string;
@Column()
require_colons: boolean;
@Column({ type: "int8", array: true })
roles: string[]; // roles this emoji is whitelisted to (new discord feature?)
@Column({ type: "int8", array: true, nullable: true })
groups: string[]; // user groups this emoji is whitelisted to (Spacebar extension)
}
+43
View File
@@ -0,0 +1,43 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity } from "typeorm";
import { BaseClass } from "./BaseClass";
@Entity({
name: "security_settings",
})
export class SecuritySettings extends BaseClass {
@Column({ nullable: true })
guild_id: string;
@Column({ nullable: true })
channel_id: string;
@Column()
encryption_permission_mask: number;
@Column({ type: "varchar", array: true })
allowed_algorithms: string[];
@Column()
current_algorithm: string;
@Column({ nullable: true })
used_since_message: string;
}
+492
View File
@@ -0,0 +1,492 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, JoinColumn, ManyToOne, OneToMany, RelationId } from "typeorm";
import { arrayRemove } from "@spacebar/extensions";
import { Config, GuildWelcomeScreen, Snowflake, handleFile } from "@spacebar/util";
import { Ban } from "./Ban";
import { BaseClass } from "./BaseClass";
import { Channel } from "./Channel";
import { Emoji } from "./Emoji";
import { Invite } from "./Invite";
import { Member } from "./Member";
import { Role } from "./Role";
import { Sticker } from "./Sticker";
import { Template } from "./Template";
import { User } from "./User";
import { VoiceState } from "./VoiceState";
import { Webhook } from "./Webhook";
// TODO: application_command_count, application_command_counts: {1: 0, 2: 0, 3: 0}
// TODO: guild_scheduled_events
// TODO: stage_instances
// TODO: threads
// TODO:
// "keywords": [
// "Genshin Impact",
// "Paimon",
// "Honkai Impact",
// "ARPG",
// "Open-World",
// "Waifu",
// "Anime",
// "Genshin",
// "miHoYo",
// "Gacha"
// ],
export const PublicGuildRelations = [
"channels",
"emojis",
"roles",
"stickers",
"voice_states",
// "members", // TODO: These are public, but all members should not be fetched.
// "members.user",
];
@Entity({
name: "guilds",
})
export class Guild extends BaseClass {
@Column({ type: String, nullable: true })
@RelationId((guild: Guild) => guild.afk_channel)
afk_channel_id?: string | null;
@JoinColumn({ name: "afk_channel_id" })
@ManyToOne(() => Channel)
afk_channel?: Channel;
@Column({ nullable: true })
afk_timeout?: number;
// * commented out -> use owner instead
// application id of the guild creator if it is bot-created
// @Column({ nullable: true })
// application?: string;
@JoinColumn({ name: "ban_ids" })
@OneToMany(() => Ban, (ban: Ban) => ban.guild, {
cascade: true,
orphanedRowAction: "delete",
})
bans: Ban[];
@Column({ nullable: true })
banner?: string;
@Column({ nullable: true })
default_message_notifications?: number;
@Column({ nullable: true })
description?: string;
@Column({ nullable: true })
discovery_splash?: string;
@Column({ nullable: true })
explicit_content_filter?: number;
@Column({ type: "varchar", array: true })
features: string[] = []; //TODO use enum
//TODO: https://discord.com/developers/docs/resources/guild#guild-object-guild-features
@Column({ nullable: true })
primary_category_id?: string; // TODO: this was number?
@Column({ nullable: true })
icon?: string;
@Column()
large?: boolean = false;
@Column({ nullable: true })
max_members?: number;
@Column({ nullable: true })
max_presences?: number;
@Column({ nullable: true })
max_video_channel_users?: number;
@Column({ nullable: true })
member_count?: number;
@Column({ nullable: true })
presence_count?: number; // users online
@OneToMany(() => Member, (member: Member) => member.guild, {
cascade: true,
orphanedRowAction: "delete",
onDelete: "CASCADE",
})
members: Member[];
@JoinColumn({ name: "role_ids" })
@OneToMany(() => Role, (role: Role) => role.guild, {
cascade: true,
orphanedRowAction: "delete",
onDelete: "CASCADE",
})
roles: Role[];
@JoinColumn({ name: "channel_ids" })
@OneToMany(() => Channel, (channel: Channel) => channel.guild, {
cascade: true,
orphanedRowAction: "delete",
})
channels: Channel[];
@Column({ nullable: true })
@RelationId((guild: Guild) => guild.template)
template_id?: string;
@JoinColumn({ name: "template_id", referencedColumnName: "id" })
@ManyToOne(() => Template)
template: Template;
@JoinColumn({ name: "emoji_ids" })
@OneToMany(() => Emoji, (emoji: Emoji) => emoji.guild, {
cascade: true,
orphanedRowAction: "delete",
onDelete: "CASCADE",
})
emojis: Emoji[];
@JoinColumn({ name: "sticker_ids" })
@OneToMany(() => Sticker, (sticker: Sticker) => sticker.guild, {
cascade: true,
orphanedRowAction: "delete",
onDelete: "CASCADE",
})
stickers: Sticker[];
@JoinColumn({ name: "invite_ids" })
@OneToMany(() => Invite, (invite: Invite) => invite.guild, {
cascade: true,
orphanedRowAction: "delete",
onDelete: "CASCADE",
})
invites: Invite[];
@JoinColumn({ name: "voice_state_ids" })
@OneToMany(() => VoiceState, (voicestate: VoiceState) => voicestate.guild, {
cascade: true,
orphanedRowAction: "delete",
onDelete: "CASCADE",
})
voice_states: VoiceState[];
@JoinColumn({ name: "webhook_ids" })
@OneToMany(() => Webhook, (webhook: Webhook) => webhook.guild, {
cascade: true,
orphanedRowAction: "delete",
onDelete: "CASCADE",
})
webhooks: Webhook[];
@Column({ nullable: true })
mfa_level?: number;
@Column()
name: string;
@Column({ nullable: true })
@RelationId((guild: Guild) => guild.owner)
owner_id?: string; // optional to allow for ownerless guilds
@JoinColumn({ name: "owner_id", referencedColumnName: "id" })
@ManyToOne(() => User)
owner?: User; // optional to allow for ownerless guilds
@Column({ nullable: true })
preferred_locale?: string;
@Column({ nullable: true })
premium_subscription_count?: number;
@Column()
premium_tier?: number; // crowd premium level
@Column({ type: String, nullable: true })
@RelationId((guild: Guild) => guild.public_updates_channel)
public_updates_channel_id: string | null;
@JoinColumn({ name: "public_updates_channel_id" })
@ManyToOne(() => Channel)
public_updates_channel?: Channel;
@Column({ type: String, nullable: true })
@RelationId((guild: Guild) => guild.rules_channel)
rules_channel_id?: string | null;
@JoinColumn({ name: "rules_channel_id" })
@ManyToOne(() => Channel)
rules_channel?: string;
@Column({ nullable: true })
region?: string;
@Column({ nullable: true })
splash?: string;
@Column({ type: String, nullable: true })
@RelationId((guild: Guild) => guild.system_channel)
system_channel_id?: string | null;
@JoinColumn({ name: "system_channel_id" })
@ManyToOne(() => Channel)
system_channel?: Channel;
@Column({ nullable: true })
system_channel_flags?: number;
@Column()
unavailable: boolean = false;
@Column({ nullable: true })
verification_level?: number;
/**
* DEPRECATED: Look at the new Guild onboarding screens.
*/
@Column({ type: "jsonb" })
welcome_screen: GuildWelcomeScreen;
@Column({ nullable: true, type: "int8" })
@RelationId((guild: Guild) => guild.widget_channel)
widget_channel_id?: string;
@JoinColumn({ name: "widget_channel_id" })
@ManyToOne(() => Channel)
widget_channel?: Channel;
@Column()
widget_enabled: boolean = true;
@Column({ nullable: true })
nsfw_level?: number;
@Column()
nsfw: boolean = false;
// TODO: nested guilds
@Column({ nullable: true })
parent?: string;
// only for developer portal
permissions?: number;
//new guild settings, 11/08/2022:
@Column({ nullable: true })
premium_progress_bar_enabled: boolean = false;
@Column({ select: false, type: "int8", array: true })
channel_ordering: string[];
@Column()
discovery_weight: number = 0;
@Column()
discovery_excluded: boolean = false;
async ToGuildSource() {
if (!this.features.includes("DISCOVERABLE")) {
return null;
}
return {
id: this.id,
name: this.name,
icon: this.icon,
description: this.description,
banner: this.banner,
splash: this.splash,
discovery_splash: this.discovery_splash,
features: this.features,
vanity_url_code: null,
preferred_locale: this.preferred_locale || "en",
premium_subscription_count: this.premium_subscription_count,
approximate_member_count: await Member.countBy({
guild_id: this.id,
}),
approximate_presence_count: await Member.countBy({
guild_id: this.id,
user: {
sessions: {
status: "online",
},
},
}),
emojis: this.emojis ?? undefined,
emoji_count: this.emojis ? this.emojis.length : undefined,
stickers: this.stickers ?? undefined,
sticker_count: this.stickers ? this.stickers.length : undefined,
auto_removed: false,
primary_category_id: this.primary_category_id,
keywords: [],
is_published: false,
reasons_to_join: [],
};
}
static async createGuild(body: {
name?: string;
icon?: string | null;
owner_id?: string;
roles?: Partial<Role>[];
channels?: Partial<Channel>[];
source_guild_id: string | null;
}) {
const guild_id = Snowflake.generate();
const guild = await Guild.create({
id: guild_id,
name: body.name || "Spacebar",
icon: await handleFile(`/icons/${guild_id}`, body.icon as string),
owner_id: body.owner_id, // TODO: need to figure out a way for ownerless guilds and multiply-owned guilds
presence_count: 0,
member_count: 0, // will automatically be increased by addMember()
mfa_level: 0,
preferred_locale: "en-US",
premium_subscription_count: 0,
premium_tier: 0,
system_channel_flags: 4, // defaults effect: suppress the setup tips to save performance
nsfw_level: 0,
verification_level: 0,
welcome_screen: {
enabled: false,
description: "",
welcome_channels: [],
},
channel_ordering: [],
afk_timeout: Config.get().defaults.guild.afkTimeout,
default_message_notifications: Config.get().defaults.guild.defaultMessageNotifications,
explicit_content_filter: Config.get().defaults.guild.explicitContentFilter,
features: Config.get().guild.defaultFeatures,
max_members: Config.get().limits.guild.maxMembers,
max_presences: Config.get().defaults.guild.maxPresences,
max_video_channel_users: Config.get().defaults.guild.maxVideoChannelUsers,
region: Config.get().regions.default,
}).save();
// we have to create the role _after_ the guild because else we would get a foreign key error
// TODO: make the @everyone a pseudorole that is dynamically generated at runtime so we can save storage
await Role.create({
id: guild_id,
guild_id: guild_id,
color: 0,
colors: { primary_color: 0 },
hoist: false,
managed: false,
mentionable: false,
name: "@everyone",
permissions: "2251804225",
position: 0,
icon: undefined,
unicode_emoji: undefined,
flags: 0, // TODO?
}).save();
// create custom roles if provided
if (body.roles && body.roles.length) {
await Promise.all(
body.roles?.map(
(role) =>
new Promise((resolve) => {
Role.create({
...role,
guild_id,
id:
// role.id === body.template_guild_id indicates that this is the @everyone role
role.id === body.source_guild_id || role.id == "0" ? guild_id : Snowflake.generate(),
})
.save()
.then(resolve);
}),
),
);
}
if (!body.channels || !body.channels.length) {
body.channels = [{ id: "01", type: 0, name: "general", nsfw: false }];
}
const ids = new Map();
body.channels.forEach((x) => {
if (x.id) {
ids.set(x.id, Snowflake.generate());
}
});
for (const channel of body.channels.sort((a) => (a.parent_id ? 1 : -1))) {
const id = ids.get(channel.id) || Snowflake.generate();
const parent_id = ids.get(channel.parent_id);
const saved = await Channel.createChannel({ ...channel, guild_id, id, parent_id }, body.owner_id, {
keepId: true,
skipExistsCheck: true,
skipPermissionCheck: true,
skipEventEmit: true,
});
await Guild.insertChannelInOrder(guild.id, saved.id, parent_id ?? channel.position ?? 0, guild);
}
return guild;
}
/** Insert a channel into the guild ordering by parent channel id or position */
static async insertChannelInOrder(guild_id: string, channel_id: string, position: number, guild?: Guild): Promise<number>;
static async insertChannelInOrder(guild_id: string, channel_id: string, parent_id: string, guild?: Guild): Promise<number>;
static async insertChannelInOrder(guild_id: string, channel_id: string, insertPoint: string | number, guild?: Guild): Promise<number>;
static async insertChannelInOrder(guild_id: string, channel_id: string, insertPoint: string | number, guild?: Guild): Promise<number> {
if (!guild)
guild = await Guild.findOneOrFail({
where: { id: guild_id },
select: { channel_ordering: true },
});
guild.channel_ordering ??= [];
let position;
if (typeof insertPoint == "string") position = guild.channel_ordering.indexOf(insertPoint) + 1;
else position = insertPoint;
arrayRemove(guild.channel_ordering, channel_id);
guild.channel_ordering.splice(position, 0, channel_id);
await Guild.update({ id: guild_id }, { channel_ordering: guild.channel_ordering });
return position;
}
toJSON(): Guild {
return {
...this,
unavailable: this.unavailable == false ? undefined : true,
channel_ordering: undefined,
discovery_weight: undefined,
discovery_excluded: undefined,
parent: undefined,
primary_category_id: undefined,
nsfw: undefined,
template_id: undefined,
presence_count: undefined,
};
}
}
+111
View File
@@ -0,0 +1,111 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, CreateDateColumn, Entity, FindOptionsWhere, Index, JoinColumn, OneToOne, RelationId } from "typeorm";
import { BaseClass } from "./BaseClass";
@Entity({
name: "instance_bans",
})
export class InstanceBan extends BaseClass {
@Column({ type: "bigint" })
@CreateDateColumn()
created_at: Date = new Date();
@Column()
reason: string;
@Index()
@Column({ nullable: true })
user_id?: string;
@Index()
@Column({ nullable: true })
fingerprint?: string;
@Index()
@Column({ nullable: true })
ip_address?: string;
// chain of trust type tracking
@Column({ default: false })
is_allowlisted: boolean = false;
@Column({ default: false })
is_from_other_instance_ban: boolean = false;
@Column({ nullable: true })
@RelationId((instance_ban: InstanceBan) => instance_ban.origin_instance_ban)
origin_instance_ban_id?: string;
@JoinColumn({ name: "origin_instance_ban_id" })
@OneToOne(() => InstanceBan, { nullable: true, onDelete: "SET NULL" })
origin_instance_ban?: InstanceBan;
static async findInstanceBans(opts: { userId?: string; ipAddress?: string; fingerprint?: string; propagateBan?: boolean }) {
const optionalChecks: FindOptionsWhere<InstanceBan>[] = [{ user_id: opts.userId }];
if (opts?.ipAddress) optionalChecks.push({ ip_address: opts.ipAddress });
if (opts?.fingerprint) optionalChecks.push({ fingerprint: opts.fingerprint });
const instanceBans = await InstanceBan.find({ where: optionalChecks });
const banReasons = [];
for (const ban of instanceBans) {
if (ban.is_allowlisted) continue;
if (opts?.fingerprint && ban.fingerprint === opts.fingerprint) banReasons.push("fingerprint");
if (opts?.ipAddress && ban.ip_address === opts.ipAddress) banReasons.push("ipAddress");
if (opts?.userId && ban.user_id === opts?.userId) banReasons.push("userId");
}
const banViralityPromises: Promise<InstanceBan>[] = [];
if (opts.propagateBan && banReasons.length > 0) {
if (opts?.ipAddress && !instanceBans.find((b) => b.ip_address === opts.ipAddress))
banViralityPromises.push(
InstanceBan.create({
user_id: opts.userId,
ip_address: opts.ipAddress,
reason: "Propagated from other instance ban",
is_from_other_instance_ban: true,
origin_instance_ban: instanceBans[0],
}).save(),
);
if (opts?.fingerprint && !instanceBans.find((b) => b.fingerprint === opts.fingerprint))
banViralityPromises.push(
InstanceBan.create({
user_id: opts.userId,
fingerprint: opts.fingerprint,
reason: "Propagated from other instance ban",
is_from_other_instance_ban: true,
origin_instance_ban: instanceBans[0],
}).save(),
);
if (opts?.userId && !instanceBans.find((b) => b.user_id === opts.userId))
banViralityPromises.push(
InstanceBan.create({
user_id: opts.userId,
reason: "Propagated from other instance ban",
is_from_other_instance_ban: true,
origin_instance_ban: instanceBans[0],
}).save(),
);
}
await Promise.all(banViralityPromises);
return banReasons;
}
}
+126
View File
@@ -0,0 +1,126 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn, RelationId } from "typeorm";
import { BaseClassWithoutId } from "./BaseClass";
import { Channel } from "./Channel";
import { Guild } from "./Guild";
import { Member } from "./Member";
import { User } from "./User";
export const PublicInviteRelation = ["inviter", "guild", "channel"];
@Entity({
name: "invites",
})
export class Invite extends BaseClassWithoutId {
@PrimaryColumn()
code: string;
@Column()
temporary: boolean;
@Column()
uses: number;
@Column()
max_uses: number;
@Column()
max_age: number;
@Column()
created_at: Date;
@Column({ nullable: true })
expires_at?: Date;
@Column({ nullable: true })
@RelationId((invite: Invite) => invite.guild)
guild_id: string;
@JoinColumn({ name: "guild_id" })
@ManyToOne(() => Guild, (guild) => guild.invites, {
onDelete: "CASCADE",
})
guild: Guild;
@Column({ nullable: true })
@RelationId((invite: Invite) => invite.channel)
channel_id: string;
@JoinColumn({ name: "channel_id" })
@ManyToOne(() => Channel, {
onDelete: "CASCADE",
})
channel: Channel;
@Column({ nullable: true })
@RelationId((invite: Invite) => invite.inviter)
inviter_id?: string;
@JoinColumn({ name: "inviter_id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
inviter: User;
@Column({ nullable: true })
@RelationId((invite: Invite) => invite.target_user)
target_user_id: string;
@JoinColumn({ name: "target_user_id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
target_user?: string; // could be used for "User specific invites" https://github.com/spacebarchat/server/issues/326
@Column({ nullable: true })
target_user_type?: number;
@Column({ nullable: true })
vanity_url?: boolean;
@Column()
flags: number;
isExpired() {
if (this.max_age !== 0 && this.expires_at && this.expires_at < new Date()) return true;
if (this.max_uses !== 0 && this.uses >= this.max_uses) return true;
return false;
}
toPublicJSON() {
return {
...this,
inviter: this.inviter.toPublicUser(),
};
}
static async joinGuild(user_id: string, code: string) {
const invite = await Invite.findOneOrFail({ where: { code } });
if (invite.isExpired()) {
await Invite.delete({ code });
throw new Error("Invite is expired");
}
if (invite.uses++ >= invite.max_uses && invite.max_uses !== 0) await Invite.delete({ code });
else await invite.save();
await Member.addToGuild(user_id, invite.guild_id);
return invite;
}
}
+488
View File
@@ -0,0 +1,488 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { HTTPError } from "lambert-server/HTTPError";
import { BeforeInsert, BeforeUpdate, Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne, Not, PrimaryGeneratedColumn, RelationId } from "typeorm";
import { Ban, Channel, PublicGuildRelations } from "./index";
import { ReadyGuildDTO } from "../../util/dtos";
import { GuildCreateEvent, GuildDeleteEvent, GuildMemberAddEvent, GuildMemberRemoveEvent, GuildMemberUpdateEvent, MessageCreateEvent } from "../../util/interfaces";
import { Config, emitEvent, DiscordApiErrors, Stopwatch } from "../../util/util";
import { BaseClassWithoutId } from "./BaseClass";
import { Guild } from "./Guild";
import { Message } from "./Message";
import { Role } from "./Role";
import { User } from "./User";
import { AvatarDecorationData, Collectibles, DisplayNameStyle, PublicMember, PublicMemberProjection, UserGuildSettings } from "@spacebar/schemas";
export const MemberPrivateProjection: (keyof Member)[] = [
"id",
"guild",
"guild_id",
"deaf",
"joined_at",
"last_message_id",
"mute",
"nick",
"pending",
"premium_since",
"roles",
"settings",
"user",
"avatar",
"banner",
"bio",
"theme_colors",
"pronouns",
"communication_disabled_until",
"flags",
];
@Entity({
name: "members",
})
@Index(["id", "guild_id"], { unique: true })
export class Member extends BaseClassWithoutId {
@PrimaryGeneratedColumn()
index: string;
@Column()
@RelationId((member: Member) => member.user)
id: string;
@JoinColumn({ name: "id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
user: User;
@Column()
@RelationId((member: Member) => member.guild)
guild_id: string;
@JoinColumn({ name: "guild_id" })
@ManyToOne(() => Guild, {
onDelete: "CASCADE",
})
guild: Guild;
@Column({ nullable: true })
nick?: string;
@JoinTable({
name: "member_roles",
joinColumn: { name: "index", referencedColumnName: "index" },
inverseJoinColumn: {
name: "role_id",
referencedColumnName: "id",
},
})
@ManyToMany(() => Role, { cascade: true })
roles: Role[];
@Column()
joined_at: Date;
@Column({ type: "bigint", nullable: true })
premium_since?: number;
@Column()
deaf: boolean;
@Column()
mute: boolean;
@Column()
pending: boolean;
@Column({ type: "jsonb", select: false })
settings: UserGuildSettings;
@Column({ nullable: true })
last_message_id?: string;
/**
@JoinColumn({ name: "id" })
@ManyToOne(() => User, {
onDelete: "DO NOTHING",
// do not auto-kick force-joined members just because their joiners left the server
}) **/
@Column({ nullable: true })
joined_by: string;
@Column({ nullable: true })
avatar?: string;
@Column({ nullable: true })
banner: string;
@Column()
bio: string;
@Column({ nullable: true, type: "int4", array: true })
theme_colors?: number[]; // TODO: Separate `User` and `UserProfile` models
@Column({ nullable: true })
pronouns?: string;
@Column({ nullable: true, type: Date })
communication_disabled_until: Date | null;
// TODO: add this when we have proper read receipts
// @Column({ type: "jsonb" })
// read_state: ReadState;
@Column({ type: "jsonb", nullable: true })
avatar_decoration_data?: AvatarDecorationData;
@Column({ type: "jsonb", nullable: true })
display_name_styles?: DisplayNameStyle;
@Column({ type: "jsonb", nullable: true })
collectibles?: Collectibles;
@Column({ type: "int", default: 0 })
flags: number = 0;
@BeforeUpdate()
@BeforeInsert()
validate() {
if (this.nick) {
this.nick = this.nick.split("\n").join("");
this.nick = this.nick.split("\t").join("");
}
if (this.nick === "") this.nick = undefined;
if (this.pronouns === "") this.pronouns = undefined;
}
static async IsInGuildOrFail(user_id: string, guild_id: string) {
if (
await Member.count({
where: { id: user_id, guild_id },
})
)
return;
throw new HTTPError("You are not member of this guild", 403);
}
static async removeFromGuild(user_id: string, guild_id: string) {
const guild = await Guild.findOneOrFail({
select: { owner_id: true },
where: { id: guild_id },
});
if (guild.owner_id === user_id) throw new Error("The owner cannot be removed from the guild");
const member = await Member.findOneOrFail({
where: { id: user_id, guild_id },
relations: { user: true },
});
// use promise all to execute all promises at the same time -> save time
return Promise.all([
Member.delete({
id: user_id,
guild_id,
}),
Guild.decrement({ id: guild_id }, "member_count", 1),
emitEvent({
event: "GUILD_DELETE",
data: {
id: guild_id,
},
user_id: user_id,
} satisfies GuildDeleteEvent),
emitEvent({
event: "GUILD_MEMBER_REMOVE",
data: { guild_id, user: member.user.toPublicUser() },
guild_id,
} satisfies GuildMemberRemoveEvent),
]);
}
static async addRole(user_id: string, guild_id: string, role_id: string) {
const [member] = await Promise.all([
Member.findOneOrFail({
where: { id: user_id, guild_id },
relations: { user: true, roles: true }, // we don't want to load the role objects just the ids
select: {
index: true,
roles: {
id: true,
},
},
}),
Role.findOneOrFail({
where: { id: role_id, guild_id },
select: { id: true },
}),
]);
member.roles.push(Role.create({ id: role_id }));
await Promise.all([
member.save(),
emitEvent({
event: "GUILD_MEMBER_UPDATE",
data: {
guild_id,
user: member.user,
roles: member.roles.map((x) => x.id),
},
guild_id,
} satisfies GuildMemberUpdateEvent),
]);
}
static async removeRole(user_id: string, guild_id: string, role_id: string) {
const [member] = await Promise.all([
Member.findOneOrFail({
where: { id: user_id, guild_id },
relations: { user: true, roles: true }, // we don't want to load the role objects just the ids
select: {
index: true,
roles: {
id: true,
},
},
}),
Role.findOneOrFail({ where: { id: role_id, guild_id } }),
]);
member.roles = member.roles.filter((x) => x.id !== role_id);
await Promise.all([
member.save(),
emitEvent({
event: "GUILD_MEMBER_UPDATE",
data: {
guild_id,
user: member.user,
roles: member.roles.map((x) => x.id),
},
guild_id,
} satisfies GuildMemberUpdateEvent),
]);
}
static async changeNickname(user_id: string, guild_id: string, nickname: string) {
const member = await Member.findOneOrFail({
where: {
id: user_id,
guild_id,
},
relations: { user: true, roles: true },
});
// @ts-expect-error Member nickname is nullable
member.nick = nickname || null;
await Promise.all([
member.save(),
emitEvent({
event: "GUILD_MEMBER_UPDATE",
data: {
guild_id,
user: member.user,
nick: nickname || undefined,
roles: member.roles.map((x) => x.id),
},
guild_id,
} satisfies GuildMemberUpdateEvent),
]);
}
static async addToGuild(user_id: string, guild_id: string, isRegistration: boolean = false) {
const totalSw = Stopwatch.startNew();
const incSw = Stopwatch.startNew();
const logTrace = (...data: unknown[]) => {
if (process.env.LOG_VERBOSE_TRACES !== "true") return;
console.log("[Member.addToGuild]", ...data, `[${totalSw.elapsed().toString()} (+${incSw.getElapsedAndReset().totalMilliseconds}ms)]`);
};
if (!isRegistration) {
const isBanned = Ban.exists({ where: { guild_id, user_id } });
const isMember = Member.exists({ where: { id: user_id, guild_id } });
if (await isBanned) throw DiscordApiErrors.USER_BANNED;
logTrace("Check bans");
if (await isMember) throw new HTTPError("You are already a member of this guild", 400);
logTrace("Check existing membership");
const { maxGuilds } = Config.get().limits.user;
const guild_count = await Member.count({ where: { id: user_id } });
if (guild_count >= maxGuilds) {
throw new HTTPError(`You are at the ${maxGuilds} guild limit.`, 403);
}
logTrace("Enforce max guilds");
}
const guild = await Guild.findOneOrFail({
where: {
id: guild_id,
},
relations: PublicGuildRelations,
relationLoadStrategy: "query",
});
const channelPositionsGuild = await Guild.findOneOrFail({
where: { id: guild_id },
select: { channel_ordering: true },
});
logTrace("Find guild");
const newMember = Member.create({
id: user_id,
guild_id,
nick: undefined,
joined_at: new Date(),
deaf: false,
mute: false,
pending: false,
bio: "",
roles: [Role.create({ id: guild_id })], // @everyone role
// read_state: {},
settings: {
guild_id: null,
mute_config: null,
mute_scheduled_events: false,
flags: 0,
hide_muted_channels: false,
notify_highlights: 0,
channel_overrides: {},
message_notifications: guild.default_message_notifications,
mobile_push: true,
muted: false,
suppress_everyone: false,
suppress_roles: false,
version: 0,
},
// Member.save is needed because else the roles relations wouldn't be updated
});
let memberCount = 0;
let memberPreview: PublicMember[] = [];
if (!isRegistration) {
for await (const channel of guild.channels) {
channel.position = await Channel.calculatePosition(channel.id, guild_id, channelPositionsGuild);
}
logTrace("Reorder channels");
memberCount = isRegistration ? 0 : await Member.count({ where: { guild_id } });
logTrace("Get member count");
memberPreview = (
await Member.find({
where: {
guild_id,
user: {
id: Not(user_id),
sessions: {
status: Not("invisible" as const), // lol typescript?
},
},
},
relations: { user: true, roles: true },
take: 10,
})
).map((member) => member.toPublicMember());
logTrace("Calculate member preview");
}
const user = await User.getPublicUser(user_id);
logTrace("Get user");
await Promise.all([
newMember.save(), // TODO: can we somehow insert the roles manually? We have no entity for this... Would skip a few select's
Guild.increment({ id: guild_id }, "member_count", 1),
emitEvent({
event: "GUILD_MEMBER_ADD",
data: {
...newMember.toPublicMember(),
user: user,
guild_id,
},
guild_id,
origin: "util/entities/Member.ts:377/addToGuild(user_id, guild_id)",
} satisfies GuildMemberAddEvent),
isRegistration
? null
: emitEvent({
event: "GUILD_CREATE",
data: {
...new ReadyGuildDTO(guild).toJSON(),
members: [...memberPreview, { ...newMember.toPublicMember(), user }],
member_count: memberCount + 1,
guild_hashes: {},
guild_scheduled_events: [],
joined_at: newMember.joined_at,
presences: [],
stage_instances: [],
threads: [],
embedded_activities: [],
voice_states: guild.voice_states.map((x) => x.toPublicVoiceState()),
},
user_id,
} satisfies GuildCreateEvent),
]);
logTrace("Save member info");
const welcomeChannelId = guild.system_channel_id;
if (welcomeChannelId && (await Channel.exists({ where: { id: welcomeChannelId } }))) {
// Send a welcome message
const message = Message.create({
type: 7,
guild_id: guild.id,
channel_id: welcomeChannelId,
author: user,
timestamp: new Date(),
reactions: [],
attachments: [],
embeds: [],
sticker_items: [],
edited_timestamp: undefined,
mentions: [],
mention_channels: [],
mention_roles: [],
mention_everyone: false,
});
await Promise.all([
message.insert(),
emitEvent({
event: "MESSAGE_CREATE",
channel_id: message.channel_id,
data: message.toJSON(),
} satisfies MessageCreateEvent),
Channel.update({ id: welcomeChannelId }, { last_message_id: message.id }),
]);
logTrace("Send welcome message");
}
}
toPublicMember() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const member: any = {};
PublicMemberProjection.forEach((x) => {
member[x] = this[x];
});
if (this.roles) member.roles = this.roles.map((x: Role) => x.id);
if (this.user) member.user = this.user.toPublicUser();
return member as PublicMember;
}
}
+486
View File
@@ -0,0 +1,486 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { User } from "./User";
import { Member } from "./Member";
import { Role } from "./Role";
import { Channel } from "./Channel";
import { Application } from "./Application";
import { Column, CreateDateColumn, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany, RelationId, FindOneOptions, Raw, Not, BaseEntity, In } from "typeorm";
import { BaseClass } from "./BaseClass";
import { Guild } from "./Guild";
import { Webhook } from "./Webhook";
import { Sticker } from "./Sticker";
import { Attachment } from "./Attachment";
import { NewUrlUserSignatureData } from "../../util/Signing";
import {
ApplicationCommandType,
BaseMessageComponents,
Embed,
MessageComponentType,
MessageSnapshot,
MessageType,
PartialMessage,
Poll,
PublicMessage,
Reaction,
UnfurledMediaItem,
PartialUser,
InteractionType,
} from "@spacebar/schemas";
import { MessageFlags } from "@spacebar/util";
import { JsonRemoveEmpty } from "../../util/util/Decorators";
@Entity({
name: "messages",
})
@Index(["channel_id", "id"], { unique: true })
export class Message extends BaseClass {
@Column({ nullable: true })
@RelationId((message: Message) => message.channel)
@Index()
channel_id?: string;
@JoinColumn({ name: "channel_id" })
@ManyToOne(() => Channel, {
onDelete: "CASCADE",
})
channel: Channel;
@Column({ nullable: true })
@RelationId((message: Message) => message.thread)
@JsonRemoveEmpty
thread_id?: string;
@JoinColumn({ name: "thread_id" })
@ManyToOne(() => Channel, {
onDelete: "CASCADE",
})
@JsonRemoveEmpty
thread?: Channel;
@Column({ nullable: true })
@RelationId((message: Message) => message.guild)
@JsonRemoveEmpty
guild_id?: string;
@JoinColumn({ name: "guild_id" })
@ManyToOne(() => Guild, {
onDelete: "CASCADE",
})
guild?: Guild;
@Column({ nullable: true })
@RelationId((message: Message) => message.author)
@Index()
author_id?: string;
@JoinColumn({ name: "author_id", referencedColumnName: "id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
author?: User;
@Column({ nullable: true })
@RelationId((message: Message) => message.member)
member_id?: string;
@JoinColumn({ name: "member_id", referencedColumnName: "id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
member?: Member;
@Column({ nullable: true })
@RelationId((message: Message) => message.webhook)
@JsonRemoveEmpty
webhook_id?: string;
@JoinColumn({ name: "webhook_id" })
@ManyToOne(() => Webhook)
webhook?: Webhook;
@Column({ nullable: true })
@RelationId((message: Message) => message.application)
application_id?: string;
@JoinColumn({ name: "application_id" })
@ManyToOne(() => Application)
application?: Application;
@Column({ nullable: true })
content?: string;
@Column()
@CreateDateColumn()
timestamp: Date;
@Column({ nullable: true })
edited_timestamp?: Date;
@Column({ nullable: true })
tts?: boolean;
@Column({ nullable: true })
mention_everyone?: boolean;
@JoinTable({ name: "message_user_mentions" })
@ManyToMany(() => User)
mentions: User[];
@JoinTable({ name: "message_role_mentions" })
@ManyToMany(() => Role)
mention_roles: Role[];
@JoinTable({ name: "message_channel_mentions" })
@ManyToMany(() => Channel)
@JsonRemoveEmpty
mention_channels: Channel[];
@JoinTable({ name: "message_stickers" })
@ManyToMany(() => Sticker, { cascade: true, onDelete: "CASCADE" })
@JsonRemoveEmpty
sticker_items?: Sticker[];
@OneToMany(() => Attachment, (attachment: Attachment) => attachment.message, {
cascade: true,
orphanedRowAction: "delete",
})
attachments?: Attachment[];
@Column({ type: "jsonb" })
embeds: Embed[];
@Column({ type: "jsonb" })
@JsonRemoveEmpty
reactions: Reaction[];
@Column({ type: "text", nullable: true })
@JsonRemoveEmpty
nonce?: string;
@Column({ nullable: true, type: Date })
pinned_at?: Date | null;
get pinned(): boolean {
return this.pinned_at != null;
}
@Column({ type: "int" })
type: MessageType;
@Column({ type: "jsonb", nullable: true })
@JsonRemoveEmpty
activity?: {
type: number;
party_id: string;
};
@Column({ default: 0 })
flags: number;
@Column({ type: "jsonb", nullable: true })
@JsonRemoveEmpty
message_reference?: {
message_id?: string;
channel_id?: string;
guild_id?: string;
type?: number; // 0 = DEFAULT, 1 = FORWARD
};
@JoinColumn({ name: "message_reference_id" })
@ManyToOne(() => Message, { onDelete: "SET NULL" })
referenced_message?: Message | null;
@Column({ type: "jsonb", nullable: true })
@JsonRemoveEmpty
interaction?: {
id: string;
type: InteractionType;
name: string;
};
@Column({ type: "jsonb", nullable: true })
@JsonRemoveEmpty
interaction_metadata?: {
id: string;
type: InteractionType;
user_id: string;
authorizing_integration_owners: object;
name: string;
command_type: ApplicationCommandType;
};
@Column({ type: "jsonb", nullable: true })
components?: BaseMessageComponents[];
@Column({ type: "jsonb", nullable: true })
@JsonRemoveEmpty
poll?: Poll;
@Column({ nullable: true })
username?: string;
@Column({ nullable: true })
avatar?: string;
@Column({ default: "[]", type: "jsonb" })
message_snapshots: MessageSnapshot[];
get isWebhook() {
return this.webhook_id != null && this.webhook != null;
}
static async fillReplies(messages: Message[]) {
const ms = messages
.filter((msg) => msg.message_reference && !msg.referenced_message?.id && msg.message_reference.message_id)
.filter((msg) => [MessageType.REPLY, MessageType.THREAD_STARTER_MESSAGE, MessageType.CONTEXT_MENU_COMMAND].includes(msg.type));
if (!ms.length) return;
const curMs = new Map(messages.map((m) => [m.id, m] as const));
const neededIds = new Set(ms.map((m) => m.message_reference!.message_id as string)).difference(curMs);
if (neededIds.size) {
const newMessages = await Message.find({
where: {
id: In([...neededIds]),
},
relations: { author: true, mentions: true, mention_roles: true, mention_channels: true },
});
newMessages.forEach((msg) => curMs.set(msg.id, msg));
}
for (const message of ms) {
message.referenced_message = curMs.get(message.message_reference!.message_id as string) || null;
}
}
toJSON(shallow = false): PublicMessage {
// this.clean_data();
return {
...this,
channel_id: this.channel_id ?? this.channel.id,
channel: undefined,
timestamp: this.timestamp.toISOString(),
edited_timestamp: this.edited_timestamp ? this.edited_timestamp.toISOString() : null,
author_id: undefined,
member_id: undefined,
webhook_id: this.webhook_id ?? undefined,
application_id: undefined,
mentions: this.mentions?.map((user) => {
if (user && !user.toPublicUser) console.trace("toPublic user missing!!!");
return (user?.toPublicUser?.() ?? user ?? undefined) as unknown as PartialUser;
}),
mention_roles: this.mention_roles?.map((role) => role.id) ?? [],
mention_channels: this.mention_channels?.map((ch) => ch.toJSON()) ?? [],
attachments: this.attachments?.map((att) => att.toJSON()) ?? [],
nonce: this.nonce ?? undefined,
tts: this.tts ?? false,
guild: this.guild ?? undefined,
webhook: this.webhook ?? undefined,
interaction: this.interaction ?? undefined,
interaction_metadata: this.interaction_metadata ?? undefined,
reactions: this.reactions ?? undefined,
sticker_items: this.sticker_items ?? undefined,
message_reference: this.message_reference ?? undefined,
mention_everyone: this.mention_everyone ?? false,
author: {
...(this.author?.toPublicUser() ?? undefined),
// Webhooks
username: this.username ?? this.author?.username ?? null,
avatar: this.avatar ?? this.author?.avatar ?? null,
},
activity: this.activity ?? undefined,
application: this.application ?? undefined,
components: this.components ?? [],
poll: this.poll ?? undefined,
content: this.content ?? "",
pinned: this.pinned,
thread: this.thread ? this.thread.toJSON() : this.thread,
referenced_message: this.referenced_message && !shallow ? this.referenced_message.toJSON(true) : undefined,
};
}
toPartialMessage(): PartialMessage {
return {
id: this.id,
// lobby_id: this.lobby_id,
channel_id: this.channel_id!,
type: this.type,
content: this.content!,
author: { ...this.author!, avatar: this.author?.avatar ?? null },
flags: this.flags,
application_id: this.application_id,
//channel: this.channel, // TODO: ephemeral DM channels
// recipient_id: this.recipient_id, // TODO: ephemeral DM channels
};
}
toSnapshot(): MessageSnapshot {
return {
message: {
attachments: this.attachments?.map((x) => x.toJSON()),
components: this.components,
content: this.content!,
edited_timestamp: this.edited_timestamp,
embeds: this.embeds,
flags: this.flags,
mention_roles: this.mention_roles?.map((x) => x.id),
mentions: this.mentions.map((x) => x.toPublicUser() as unknown as PartialUser), // TODO: write a proper method for this
timestamp: this.timestamp,
type: this.type,
},
};
}
withSignedAttachments(data: NewUrlUserSignatureData) {
function signMedia(media: UnfurledMediaItem) {
Object.assign(media, Attachment.prototype.signUrls.call(media, data));
}
return {
...this,
attachments: this.attachments?.map((attachment: Attachment) => Attachment.prototype.signUrls.call(attachment, data)),
components: this.components
? this.components.map((comp) => {
comp = structuredClone(comp);
if (comp.type === MessageComponentType.Section) {
const accessory = comp.accessory;
if (accessory.type === MessageComponentType.Thumbnail) {
signMedia(accessory.media);
}
} else if (comp.type === MessageComponentType.MediaGallery) {
comp.items.forEach(({ media }) => signMedia(media));
} else if (comp.type === MessageComponentType.File) {
signMedia(comp.file);
} else if (comp.type === MessageComponentType.Container) {
for (const elm of comp.components) {
switch (elm.type) {
case MessageComponentType.Separator:
case MessageComponentType.TextDisplay:
case MessageComponentType.ActionRow:
break;
case MessageComponentType.Section: {
const accessory = elm.accessory;
if (accessory.type === MessageComponentType.Thumbnail) {
signMedia(accessory.media);
}
break;
}
case MessageComponentType.MediaGallery:
elm.items.forEach(({ media }) => signMedia(media));
break;
case MessageComponentType.File: {
signMedia(elm.file);
break;
}
default:
elm satisfies never;
}
}
}
return comp;
})
: this.components,
};
}
static async createWithDefaults(opts: Partial<Message>): Promise<Message> {
const message = Message.create();
if (!opts.author) {
if (!opts.author_id) throw new Error("Either author or author_id must be provided to create a Message");
opts.author = await User.findOneOrFail({ where: { id: opts.author_id! } });
}
if (!opts.channel) {
if (!opts.channel_id) throw new Error("Either channel or channel_id must be provided to create a Message");
opts.channel = await Channel.findOneOrFail({ where: { id: opts.channel_id! } });
opts.guild_id ??= opts.channel.guild_id;
}
if (!opts.member_id) opts.member_id = message.author_id;
if (!opts.member) opts.member = await Member.findOneOrFail({ where: { id: opts.member_id! } });
if (!opts.guild) {
if (opts.guild_id) opts.guild = await Guild.findOneOrFail({ where: { id: opts.guild_id! } });
else if (opts.channel?.guild?.id) opts.guild = opts.channel.guild;
else if (opts.channel?.guild_id) opts.guild = await Guild.findOneOrFail({ where: { id: opts.channel.guild_id! } });
else if (opts.member?.guild?.id) opts.guild = opts.member.guild;
else if (opts.member?.guild_id) opts.guild = await Guild.findOneOrFail({ where: { id: opts.member.guild_id! } });
else throw new Error("Either guild, guild_id, channel.guild, channel.guild_id, member.guild or member.guild_id must be provided to create a Message");
}
// try 2 now that we have a guild
if (!opts.member) opts.member = await Member.findOneOrFail({ where: { id: opts.author!.id, guild_id: opts.guild!.id } });
// set reply type if a message if referenced
if (opts.message_reference && !opts.type) message.type = MessageType.REPLY;
// backpropagate ids
opts.channel_id = opts.channel.id;
opts.guild_id = opts.guild.id;
opts.author_id = opts.author.id;
opts.member_id = opts.member.id;
opts.webhook_id = opts.webhook?.id;
opts.application_id = opts.application?.id;
delete opts.member;
Object.assign(message, {
tts: false,
embeds: [],
reactions: [],
flags: 0,
type: 0,
timestamp: new Date(),
...opts,
});
return message;
}
static addDefault(options: FindOneOptions<Message>) {
if (options.where) {
const arr = options.where instanceof Array ? options.where : [options.where];
for (const thing of arr) {
if (!("flags" in thing)) {
thing.flags = Not(Raw((alias) => `${alias} & ${MessageFlags.FLAGS.EPHEMERAL} = ${MessageFlags.FLAGS.EPHEMERAL}`));
}
}
}
}
}
//@ts-expect-error It works but TS types hate it
Message.findOneOrFail = function (this: Message, options: FindOneOptions<Message>): Promise<Message> {
Message.addDefault(options as FindOneOptions<Message>);
//@ts-expect-error how to use generics on call, who knows!
return BaseEntity.findOneOrFail.call(Message, options);
};
//@ts-expect-error It works but TS types hate it
Message.findOne = function (this: Message, options: FindOneOptions<Message>): Promise<Message> {
Message.addDefault(options as FindOneOptions<Message>);
//@ts-expect-error how to use generics on call, who knows!
return BaseEntity.findOne.call(Message, options);
};
//@ts-expect-error It works but TS types hate it
Message.find = function (this: Message, options: FindOneOptions<Message>): Promise<Message[]> {
Message.addDefault(options as FindOneOptions<Message>);
//@ts-expect-error how to use generics on call, who knows!
return BaseEntity.find.call(Message, options);
};
+33
View File
@@ -0,0 +1,33 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, PrimaryGeneratedColumn, BaseEntity } from "typeorm";
@Entity({
name: "migrations",
})
export class Migration extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: "bigint" })
timestamp: number;
@Column()
name: string;
}
+38
View File
@@ -0,0 +1,38 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, JoinColumn, ManyToOne, Unique } from "typeorm";
import { BaseClass } from "./BaseClass";
import { User } from "./User";
@Entity({
name: "notes",
})
@Unique(["owner", "target"])
export class Note extends BaseClass {
@JoinColumn({ name: "owner_id" })
@ManyToOne(() => User, { onDelete: "CASCADE" })
owner: User;
@JoinColumn({ name: "target_id" })
@ManyToOne(() => User, { onDelete: "CASCADE" })
target: User;
@Column()
content: string;
}
+37
View File
@@ -0,0 +1,37 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity } from "typeorm";
import { BaseClass } from "./BaseClass";
@Entity({
name: "rate_limits",
})
export class RateLimit extends BaseClass {
@Column() // no relation as it also
executor_id: string;
@Column()
hits: number;
@Column()
blocked: boolean;
@Column()
expires_at: Date;
}
+93
View File
@@ -0,0 +1,93 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, Index, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { BaseClass } from "./BaseClass";
import { Channel } from "./Channel";
import { User } from "./User";
import { ReadStateFlags, ReadStateType } from "@spacebar/schemas";
// for read receipts
// notification cursor and public read receipt need to be forwards-only (the former to prevent re-pinging when marked as unread, and the latter to be acceptable as a legal acknowledgement in criminal proceedings), and private read marker needs to be advance-rewind capable
// public read receipt ≥ notification cursor ≥ private fully read marker
@Entity({
name: "read_states",
})
@Index(["channel_id", "user_id"], { unique: true })
export class ReadState extends BaseClass {
@Column()
@RelationId((read_state: ReadState) => read_state.channel)
channel_id: string;
@JoinColumn({ name: "channel_id" })
@ManyToOne(() => Channel, {
onDelete: "CASCADE",
})
channel: Channel;
@Column()
@RelationId((read_state: ReadState) => read_state.user)
user_id: string;
@JoinColumn({ name: "user_id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
user: User;
@Column({ nullable: true })
last_message_id?: string;
@Column({ nullable: true })
last_acked_id?: string;
@Column({ nullable: true })
notifications_cursor: string;
@Column({ default: 0 })
mention_count: number;
@Column({ default: 0 })
badge_count: number;
@Column({ nullable: true })
last_pin_timestamp?: Date;
@Column({ default: ReadStateType.CHANNEL })
read_state_type: ReadStateType;
@Column({ default: 0 })
flags: ReadStateFlags;
// toJSON() {
// const res = { ...this } as Partial<ReadState>;
// if (this.read_state_type === ReadStateType.CHANNEL) {
// delete res.badge_count;
// delete res.last_acked_id;
// } else {
// delete res.mention_count; // mutually exclusive with badge_count
// delete res.last_message_id; // mutually exclusive with last_acked_id
// // these only apply to channels:
// delete res.last_pin_timestamp;
// delete res.flags;
// // delete res.last_viewed; // TODO
// }
// return res;
// }
}
+50
View File
@@ -0,0 +1,50 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { BaseClass } from "./BaseClass";
@Entity({
name: "recipients",
})
export class Recipient extends BaseClass {
@Column()
@RelationId((recipient: Recipient) => recipient.channel)
channel_id: string;
@JoinColumn({ name: "channel_id" })
@ManyToOne(() => require("./Channel").Channel, {
onDelete: "CASCADE",
})
channel: import("./Channel").Channel;
@Column()
@RelationId((recipient: Recipient) => recipient.user)
user_id: string;
@JoinColumn({ name: "user_id" })
@ManyToOne(() => require("./User").User, {
onDelete: "CASCADE",
})
user: import("./User").User;
@Column({ default: false })
closed: boolean;
// TODO: settings/mute/nick/added at/encryption keys/read_state
}
+63
View File
@@ -0,0 +1,63 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, Index, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { BaseClass } from "./BaseClass";
import { User } from "./User";
import { RelationshipType } from "@spacebar/schemas";
@Entity({
name: "relationships",
})
@Index(["from_id", "to_id"], { unique: true })
export class Relationship extends BaseClass {
@Column({})
@RelationId((relationship: Relationship) => relationship.from)
from_id: string;
@JoinColumn({ name: "from_id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
from: User;
@Column({})
@RelationId((relationship: Relationship) => relationship.to)
to_id: string;
@JoinColumn({ name: "to_id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
to: User;
@Column({ nullable: true })
nickname?: string;
@Column({ type: "int" })
type: RelationshipType;
toPublicRelationship() {
return {
id: this.to?.id || this.to_id,
type: this.type,
nickname: this.nickname,
user: this.to?.toPublicUser(),
};
}
}
+41
View File
@@ -0,0 +1,41 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2024 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { BaseClass } from "./BaseClass";
import { Entity, Column } from "typeorm";
import { ReportMenuType } from "../../schemas/api/reports/ReportMenu";
@Entity({
name: "report_menus",
})
export class ReportMenu extends BaseClass {
@Column()
type: ReportMenuType;
@Column()
variant: string;
@Column()
isCurrent: boolean;
@Column({ nullable: true })
inherits?: string;
@Column({ type: "jsonb" })
content: unknown;
}
+85
View File
@@ -0,0 +1,85 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { BaseClass } from "./BaseClass";
import { Guild } from "./Guild";
import { RoleColors } from "@spacebar/schemas";
@Entity({
name: "roles",
})
export class Role extends BaseClass {
@Column()
@RelationId((role: Role) => role.guild)
guild_id: string;
@JoinColumn({ name: "guild_id" })
@ManyToOne(() => Guild, (guild) => guild.roles, {
onDelete: "CASCADE",
})
guild: Guild;
@Column()
color: number;
@Column()
hoist: boolean;
@Column({ default: false })
managed: boolean;
@Column()
mentionable: boolean;
@Column()
name: string;
@Column()
permissions: string;
@Column()
position: number;
@Column({ nullable: true })
icon?: string;
@Column({ nullable: true })
unicode_emoji?: string;
@Column({ type: "jsonb", nullable: true })
tags?: {
bot_id?: string;
integration_id?: string;
premium_subscriber?: boolean;
};
@Column({ default: 0 })
flags: number;
@Column({ nullable: false, type: "jsonb" })
colors: RoleColors;
toJSON(): Role {
return {
...this,
tags: this.tags ?? undefined,
};
}
}
+48
View File
@@ -0,0 +1,48 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { BaseClass } from "./BaseClass";
import { User } from "./User";
@Entity({
name: "security_keys",
})
export class SecurityKey extends BaseClass {
@Column({ nullable: true })
@RelationId((key: SecurityKey) => key.user)
user_id: string;
@JoinColumn({ name: "user_id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
user: User;
@Column()
key_id: string;
@Column()
public_key: string;
@Column()
counter: number;
@Column()
name: string;
}
+181
View File
@@ -0,0 +1,181 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2025 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import crypto from "node:crypto";
import { User } from "./User";
import { BaseClassWithoutId } from "./BaseClass";
import { Column, CreateDateColumn, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn, RelationId } from "typeorm";
import { Activity, ClientStatus, GatewaySession, GatewaySessionClientInfo, PrivateStatus } from "../../util/interfaces";
import { randomUpperString } from "@spacebar/api";
import { DateBuilder, IpDataClient, TimeSpan } from "../../util/util";
@Entity({
name: "sessions",
})
export class Session extends BaseClassWithoutId {
@PrimaryColumn({ nullable: false })
session_id: string = randomUpperString();
@Column()
@RelationId((session: Session) => session.user)
@Index({})
user_id: string;
@JoinColumn({ name: "user_id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
user: User;
@Column({ type: "jsonb", default: "[]" })
activities: Activity[];
@Column({ type: "jsonb" })
client_info: {
platform?: string;
os?: string;
version?: number;
location?: string;
};
@Column({ type: "jsonb" })
client_status: ClientStatus;
@Column({ nullable: false, type: String })
status: PrivateStatus;
@Column({ default: false })
is_admin_session: boolean;
@CreateDateColumn({ type: Date })
created_at: Date;
@Column({ nullable: true, type: Date })
last_seen?: Date;
@Column({ nullable: true, type: String })
last_seen_ip?: string;
@Column({ nullable: true, type: String })
last_seen_location?: string;
@Column({ nullable: true, type: "jsonb" })
last_seen_location_info?: ExtendedLocationInfo;
@Column({ nullable: true, type: String })
session_nickname?: string;
getPublicStatus() {
return this.status === "invisible" ? "offline" : this.status;
}
getDiscordDeviceInfo() {
return {
id_hash: crypto.createHash("sha256").update(this.session_id).digest("hex"),
approx_last_used_time: (this.last_seen ?? new Date(0)).toISOString(),
client_info: {
os: this.client_info?.os,
platform:
this.client_info?.platform + (this.client_info?.version ? ` ${this.client_info?.version}` : "") + (this.session_nickname ? ` (${this.session_nickname})` : ""),
location: this.last_seen_location,
},
};
}
getExtendedDeviceInfo() {
return {
id: this.session_id,
id_hash: crypto.createHash("sha256").update(this.session_id).digest("hex"),
status: this.status,
activities: this.activities,
client_status: this.client_status,
approx_last_used_time: (this.last_seen ?? new Date(0)).toISOString(),
client_info: {
...(this.client_info ?? {}),
location: this.last_seen_location,
},
last_seen: this.last_seen,
last_seen_ip: this.last_seen_ip,
last_seen_location: this.last_seen_location,
last_seen_location_info: this.last_seen_location_info,
};
}
toPrivateGatewayDeviceInfo(): GatewaySession {
// TODO: ... or has `show_current_game` privacy setting enabled - except spotify (always visible)
const hasPrivateActivities = this.status == "offline" || this.status == "invisible";
const inactiveTreshold = new DateBuilder(new Date(0)).addMinutes(5).buildTimestamp();
return {
session_id: this.session_id,
client_info: {
client: this.client_info?.platform ?? "",
os: this.client_info?.os ?? "",
version: this.client_info?.version ?? 0,
} as GatewaySessionClientInfo,
status: this.status,
activities: hasPrivateActivities ? [] : this.activities,
hidden_activities: hasPrivateActivities ? this.activities : [],
active: TimeSpan.fromDates(this.last_seen?.getTime() ?? 0, new Date().getTime()).totalMillis < inactiveTreshold,
};
}
async updateIpInfo() {
const ipInfo = await IpDataClient.getIpInfo(this.last_seen_ip!);
if (ipInfo?.ip) {
this.last_seen_location = `${ipInfo.emoji_flag} ${ipInfo.postal} ${ipInfo.city}, ${ipInfo.region}, ${ipInfo.country_name}`;
this.last_seen_location_info = {
is_eu: ipInfo.is_eu,
city: ipInfo.city,
region: ipInfo.region,
region_code: ipInfo.region_code,
country_name: ipInfo.country_name,
country_code: ipInfo.country_code,
continent_name: ipInfo.continent_name,
continent_code: ipInfo.continent_code,
latitude: ipInfo.latitude,
longitude: ipInfo.longitude,
postal: ipInfo.postal,
calling_code: ipInfo.calling_code,
flag: ipInfo.flag,
emoji_flag: ipInfo.emoji_flag,
emoji_unicode: ipInfo.emoji_unicode,
};
}
}
}
export interface ExtendedLocationInfo {
is_eu: boolean;
city: string;
region: string;
region_code: string;
country_name: string;
country_code: string;
continent_name: string;
continent_code: string;
latitude: number;
longitude: number;
postal: string;
calling_code: string;
flag: string;
emoji_flag: string;
emoji_unicode: string;
}
export const PrivateSessionProjection: (keyof Session)[] = ["user_id", "session_id", "activities", "client_info", "status"];
+75
View File
@@ -0,0 +1,75 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { BaseClass } from "./BaseClass";
import { Guild } from "./Guild";
import { User } from "./User";
import { StickerFormatType, StickerType } from "@spacebar/schemas";
@Entity({
name: "stickers",
})
export class Sticker extends BaseClass {
@Column()
name: string;
@Column({ nullable: true })
description?: string;
@Column({ nullable: true })
available?: boolean;
@Column({ nullable: true })
tags?: string;
@Column({ nullable: true })
@RelationId((sticker: Sticker) => sticker.pack)
pack_id?: string;
@JoinColumn({ name: "pack_id" })
@ManyToOne(() => require("./StickerPack").StickerPack, {
onDelete: "CASCADE",
nullable: true,
})
pack: import("./StickerPack").StickerPack;
@Column({ nullable: true })
guild_id?: string;
@JoinColumn({ name: "guild_id" })
@ManyToOne(() => Guild, (guild) => guild.stickers, {
onDelete: "CASCADE",
})
guild?: Guild;
@Column({ nullable: true })
user_id?: string;
@JoinColumn({ name: "user_id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
user?: User;
@Column({ type: "int" })
type: StickerType;
@Column({ type: "int" })
format_type: StickerFormatType;
}
+51
View File
@@ -0,0 +1,51 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, JoinColumn, ManyToOne, OneToMany, RelationId } from "typeorm";
import { Sticker } from "./index";
import { BaseClass } from "./BaseClass";
@Entity({
name: "sticker_packs",
})
export class StickerPack extends BaseClass {
@Column()
name: string;
@Column({ nullable: true })
description?: string;
@Column({ nullable: true })
banner_asset_id?: string;
@OneToMany(() => Sticker, (sticker: Sticker) => sticker.pack, {
cascade: true,
orphanedRowAction: "delete",
})
stickers: Sticker[];
// sku_id: string
@Column({ nullable: true })
@RelationId((pack: StickerPack) => pack.cover_sticker)
cover_sticker_id?: string;
@ManyToOne(() => Sticker, { nullable: true })
@JoinColumn()
cover_sticker?: Sticker;
}
+32
View File
@@ -0,0 +1,32 @@
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { BaseClass } from "./BaseClass";
import { User } from "./User";
import { Channel } from "./Channel";
@Entity({
name: "streams",
})
export class Stream extends BaseClass {
@Column()
@RelationId((stream: Stream) => stream.owner)
owner_id: string;
@JoinColumn({ name: "owner_id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
owner: User;
@Column()
@RelationId((stream: Stream) => stream.channel)
channel_id: string;
@JoinColumn({ name: "channel_id" })
@ManyToOne(() => Channel, {
onDelete: "CASCADE",
})
channel: Channel;
@Column()
endpoint: string;
}
+39
View File
@@ -0,0 +1,39 @@
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { BaseClass } from "./BaseClass";
import { User } from "./User";
import { Stream } from "./Stream";
@Entity({
name: "stream_sessions",
})
export class StreamSession extends BaseClass {
@Column()
@RelationId((session: StreamSession) => session.stream)
stream_id: string;
@JoinColumn({ name: "stream_id" })
@ManyToOne(() => Stream, {
onDelete: "CASCADE",
})
stream: Stream;
@Column()
@RelationId((session: StreamSession) => session.user)
user_id: string;
@JoinColumn({ name: "user_id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
user: User;
@Column({ nullable: true })
token: string;
// this is for gateway session
@Column()
session_id: string;
@Column({ default: false })
used: boolean;
}
+57
View File
@@ -0,0 +1,57 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
import { BaseClass } from "./BaseClass";
import { Channel } from "./Channel";
@Entity({
name: "tags",
})
export class Tag extends BaseClass {
@Column()
channel_id: string;
@JoinColumn({ name: "channel_id" })
@ManyToOne(() => Channel, (channel) => channel.available_tags, {
onDelete: "CASCADE",
})
channel: Channel;
@Column()
name: string;
@Column()
moderated: boolean = false;
@Column({ nullable: true })
emoji_id?: string;
@Column({ nullable: true })
emoji_name?: string;
toJSON() {
return {
name: this.name,
id: this.id,
moderated: this.moderated,
emoji_id: this.emoji_id,
emoji_name: this.emoji_name,
};
}
}
+47
View File
@@ -0,0 +1,47 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, JoinColumn, ManyToOne, OneToMany, RelationId } from "typeorm";
import { BaseClass } from "./BaseClass";
import { TeamMember } from "./TeamMember";
import { User } from "./User";
@Entity({
name: "teams",
})
export class Team extends BaseClass {
@Column({ nullable: true })
icon?: string;
@JoinColumn({ name: "member_ids" })
@OneToMany(() => TeamMember, (member: TeamMember) => member.team, {
orphanedRowAction: "delete",
})
members: TeamMember[];
@Column()
name: string;
@Column({ nullable: true })
@RelationId((team: Team) => team.owner_user)
owner_user_id: string;
@JoinColumn({ name: "owner_user_id" })
@ManyToOne(() => User)
owner_user: User;
}
+56
View File
@@ -0,0 +1,56 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { BaseClass } from "./BaseClass";
import { User } from "./User";
import { TeamMemberRole, TeamMemberState } from "@spacebar/schemas";
@Entity({
name: "team_members",
})
export class TeamMember extends BaseClass {
@Column({ type: "int" })
membership_state: TeamMemberState;
@Column({ type: "varchar", array: true })
permissions: string[];
@Column()
role: TeamMemberRole;
@Column({ nullable: true })
@RelationId((member: TeamMember) => member.team)
team_id: string;
@JoinColumn({ name: "team_id" })
@ManyToOne(() => require("./Team").Team, (team: import("./Team").Team) => team.members, {
onDelete: "CASCADE",
})
team: import("./Team").Team;
@Column({ nullable: true })
@RelationId((member: TeamMember) => member.user)
user_id: string;
@JoinColumn({ name: "user_id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
user: User;
}
+64
View File
@@ -0,0 +1,64 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { BaseClass } from "./BaseClass";
import { Guild } from "./Guild";
import { User } from "./User";
@Entity({
name: "templates",
})
export class Template extends BaseClass {
@Column({ unique: true })
code: string;
@Column()
name: string;
@Column({ nullable: true })
description?: string;
@Column({ nullable: true })
usage_count?: number;
@Column({ nullable: true })
@RelationId((template: Template) => template.creator)
creator_id: string;
@JoinColumn({ name: "creator_id" })
@ManyToOne(() => User)
creator: User;
@Column()
created_at: Date;
@Column()
updated_at: Date;
@Column({ nullable: true })
@RelationId((template: Template) => template.source_guild)
source_guild_id: string;
@JoinColumn({ name: "source_guild_id" })
@ManyToOne(() => Guild, { onDelete: "CASCADE" })
source_guild: Guild;
@Column({ type: "jsonb" })
serialized_source_guild: Guild;
}
+195
View File
@@ -0,0 +1,195 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2026 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn, RelationId } from "typeorm";
import { ThreadMembersUpdateEvent } from "../../util/interfaces";
import { emitEvent } from "../../util/util";
import { BaseClassWithoutId } from "./BaseClass";
import { Channel } from "./Channel";
import { HTTPError } from "lambert-server/HTTPError";
import { Member } from "./Member";
// TODO: move
interface ThreadMemberMuteConfig {
end_time?: Date;
selected_time_window?: number;
}
// TODO: move
export enum ThreadMemberFlags {
NONE = 0,
HAS_INTERACTED = 1 << 0,
ALL_MESSAGES = 1 << 1,
ONLY_MENTIONS = 1 << 2,
NO_MESSAGES = 1 << 3,
}
@Entity("thread_members")
@Index(["id", "member_idx"], { unique: true })
export class ThreadMember extends BaseClassWithoutId {
@PrimaryGeneratedColumn()
index: string;
@Column()
@RelationId((member: ThreadMember) => member.channel)
id: string;
@JoinColumn({ name: "id" })
@ManyToOne(() => Channel, {
onDelete: "CASCADE",
})
channel: Channel;
@Column()
@RelationId((member: ThreadMember) => member.member)
member_idx: string;
@JoinColumn({ name: "member_idx" })
@ManyToOne(() => Member, {
onDelete: "CASCADE",
})
member: Member;
@Column()
join_timestamp: Date;
@Column()
muted: boolean;
@Column({ nullable: true, type: "jsonb" })
mute_config?: ThreadMemberMuteConfig;
@Column()
flags: ThreadMemberFlags;
static async IsInThreadOrFail(member_id: string, thread_id: string) {
if (await ThreadMember.count({ where: { id: thread_id, member_idx: member_id } })) return true;
throw new HTTPError("You are not member of this thread", 403);
}
static async removeFromThread(member_id: string, thread_id: string) {
const channel = await Channel.findOneOrFail({ where: { id: thread_id } });
if (
!(await ThreadMember.count({
where: {
id: thread_id,
member_idx: member_id,
},
}))
)
throw new HTTPError("You are not member of this thread", 403);
// // use promise all to execute all promises at the same time -> save time
// TODO: check for bugs
if (channel.member_count) channel.member_count--;
return Promise.all([
ThreadMember.delete({
id: thread_id,
member_idx: member_id,
}),
// //Guild.decrement({ id: guild_id }, "member_count", -1),
emitEvent({
event: "THREAD_MEMBERS_UPDATE",
data: {
guild_id: channel.guild_id!, // TODO: is this the right fix?
id: channel.id,
member_count: channel.member_count ?? 0,
removed_member_ids: [member_id],
},
channel_id: thread_id,
} satisfies ThreadMembersUpdateEvent),
]);
}
// static async addRole(user_id: string, guild_id: string, role_id: string) {
// const [member, role] = await Promise.all([
// // @ts-ignore
// Member.findOneOrFail({
// where: { id: user_id, guild_id },
// relations: ["user", "roles"], // we don't want to load the role objects just the ids
// select: ["index"]
// }),
// Role.findOneOrFail({ where: { id: role_id, guild_id }, select: ["id"] })
// ]);
// member.roles.push(OrmUtils.mergeDeep(new Role(), { id: role_id }));
// await Promise.all([
// member.save(),
// emitEvent({
// event: "GUILD_MEMBER_UPDATE",
// data: {
// guild_id,
// user: member.user,
// roles: member.roles.map((x) => x.id)
// },
// guild_id
// } satisfies GuildMemberUpdateEvent)
// ]);
// }
// static async removeRole(user_id: string, guild_id: string, role_id: string) {
// const [member] = await Promise.all([
// // @ts-ignore
// Member.findOneOrFail({
// where: { id: user_id, guild_id },
// relations: ["user", "roles"], // we don't want to load the role objects just the ids
// select: ["index"]
// }),
// await Role.findOneOrFail({ where: { id: role_id, guild_id } })
// ]);
// member.roles = member.roles.filter((x) => x.id == role_id);
// await Promise.all([
// member.save(),
// emitEvent({
// event: "GUILD_MEMBER_UPDATE",
// data: {
// guild_id,
// user: member.user,
// roles: member.roles.map((x) => x.id)
// },
// guild_id
// } satisfies GuildMemberUpdateEvent)
// ]);
// }
// static async changeNickname(user_id: string, guild_id: string, nickname: string) {
// const member = await Member.findOneOrFail({
// where: {
// id: user_id,
// guild_id
// },
// relations: ["user"]
// });
// member.nick = nickname;
// await Promise.all([
// member.save(),
// emitEvent({
// event: "GUILD_MEMBER_UPDATE",
// data: {
// guild_id,
// user: member.user,
// nick: nickname
// },
// guild_id
// } satisfies GuildMemberUpdateEvent)
// ]);
// }
}
+409
View File
@@ -0,0 +1,409 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Request } from "express";
import { Column, Entity, JoinColumn, OneToMany, OneToOne } from "typeorm";
import { Config, Email, FieldErrors, Snowflake, Stopwatch, trimSpecial } from "@spacebar/util";
import { Random } from "@spacebar/util/util/Random";
import { BaseClass } from "./BaseClass";
import { Channel } from "./Channel";
import { ConnectedAccount } from "./ConnectedAccount";
import { Member } from "./Member";
import { Relationship } from "./Relationship";
import { SecurityKey } from "./SecurityKey";
import { Session } from "./Session";
import { UserSettings } from "./UserSettings";
import {
AvatarDecorationData,
ChannelType,
Collectibles,
DisplayNameStyle,
PrimaryGuild,
PrivateUserProjection,
PublicUser,
PublicUserProjection,
UserPrivate,
} from "@spacebar/schemas";
import { JsonNumber } from "../../util/util/Decorators";
@Entity({
name: "users",
})
export class User extends BaseClass {
@Column()
username: string; // username max length 32, min 2 (should be configurable)
@Column()
discriminator: string; // opaque string: 4 digits on discord.com
@Column({ nullable: true })
avatar?: string; // hash of the user avatar
@Column({ nullable: true })
accent_color?: number; // banner color of user
@Column({ nullable: true })
banner?: string; // hash of the user banner
// TODO: Separate `User` and `UserProfile` models
// puyo: changed from [number, number] because it breaks openapi
@Column({ nullable: true, type: "int4", array: true })
theme_colors?: number[];
@Column({ nullable: true })
pronouns?: string;
@Column({ nullable: true, select: false })
phone?: string; // phone number of the user
@Column({ select: false })
desktop: boolean = false; // if the user has desktop app installed
@Column({ select: false })
mobile: boolean = false; // if the user has mobile app installed
@Column()
premium: boolean; // if user bought individual premium
@Column()
premium_type: number; // individual premium level
@Column()
bot: boolean = false; // if user is bot
@Column()
bio: string = ""; // short description of the user
@Column()
system: boolean = false; // shouldn't be used, the api sends this field type true, if the generated message comes from a system generated author
@Column({ select: false })
nsfw_allowed: boolean = true; // if the user can do age-restricted actions (NSFW channels/guilds/commands) // TODO: depending on age
@Column({ select: false })
mfa_enabled: boolean = false; // if multi factor authentication is enabled
@Column({ select: false, default: false })
webauthn_enabled: boolean = false; // if webauthn multi factor authentication is enabled
@Column({ select: false, nullable: true })
totp_secret?: string = "";
@Column({ nullable: true, select: false })
totp_last_ticket?: string = "";
@Column()
created_at: Date; // registration date
@Column({ nullable: true })
premium_since: Date; // premium date
@Column({ select: false })
verified: boolean; // email is verified
@Column()
disabled: boolean = false; // if the account is disabled
@Column()
deleted: boolean = false; // if the user was deleted
@Column({ nullable: true, select: false })
email?: string; // email of the user
@Column({ type: "bigint" })
@JsonNumber
flags: number = 0; // UserFlags // TODO: generate
@Column({ type: "bigint" })
@JsonNumber
public_flags: number = 0;
@Column({ type: "bigint" })
@JsonNumber
purchased_flags: number = 0;
@Column()
premium_usage_flags: number = 0;
@Column({ type: "bigint" })
@JsonNumber
rights: string;
@OneToMany(() => Session, (session: Session) => session.user)
sessions: Session[];
@JoinColumn({ name: "relationship_ids" })
@OneToMany(() => Relationship, (relationship: Relationship) => relationship.from, {
cascade: true,
orphanedRowAction: "delete",
})
relationships: Relationship[];
@JoinColumn({ name: "connected_account_ids" })
@OneToMany(() => ConnectedAccount, (account: ConnectedAccount) => account.user, {
cascade: true,
orphanedRowAction: "delete",
})
connected_accounts: ConnectedAccount[];
@Column({ type: "jsonb", select: false })
data: {
valid_tokens_since: Date; // all tokens with a previous issue date are invalid
hash?: string; // hash of the password, salt is saved in password (bcrypt)
};
@Column({ type: "varchar", array: true, select: false })
fingerprints: string[] = []; // array of fingerprints -> used to prevent multiple accounts
@OneToOne(() => UserSettings, {
cascade: true,
orphanedRowAction: "delete",
nullable: true,
})
@JoinColumn()
settings?: UserSettings;
@OneToMany(() => SecurityKey, (key: SecurityKey) => key.user)
security_keys: SecurityKey[];
@Column({ type: "int8", array: true, nullable: true })
badge_ids?: string[];
@Column({ type: "jsonb", nullable: true })
avatar_decoration_data?: AvatarDecorationData;
@Column({ type: "jsonb", nullable: true })
display_name_styles?: DisplayNameStyle;
@Column({ type: "jsonb", nullable: true })
collectibles?: Collectibles;
@Column({ type: "jsonb", nullable: true })
primary_guild?: PrimaryGuild;
// TODO: I don't like this method?
validate() {
if (this.discriminator) {
const discrim = Number(this.discriminator);
if (isNaN(discrim) || !Number.isInteger(discrim) || discrim <= 0 || discrim >= 10000)
throw FieldErrors({
discriminator: {
message: "Discriminator must be a number.",
code: "DISCRIMINATOR_INVALID",
},
});
this.discriminator = discrim.toString().padStart(4, "0");
}
}
toPublicUser() {
this.clean_data();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const user: any = {};
PublicUserProjection.forEach((x) => {
user[x] = this[x];
});
return user as PublicUser;
}
toPrivateUser(extraFields: (keyof User)[] = []) {
this.clean_data();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const user: any = {};
[...PrivateUserProjection, ...extraFields].forEach((x) => {
user[x] = this[x];
});
return user as UserPrivate;
}
static async getPublicUser(user_id: string): Promise<PublicUser> {
const user = await User.findOneOrFail({
where: { id: user_id },
select: PublicUserProjection,
});
return user.toPublicUser();
}
public static async generateDiscriminator(username: string): Promise<string | undefined> {
if (Config.get().register.incrementingDiscriminators) {
// discriminator will be incrementally generated
// First we need to figure out the currently highest discrimnator for the given username and then increment it
const users = await User.find({
where: { username },
select: { discriminator: true },
});
const highestDiscriminator = Math.max(0, ...users.map((u) => Number(u.discriminator)));
const discriminator = highestDiscriminator + 1;
if (discriminator >= 10000) {
return undefined;
}
return discriminator.toString().padStart(4, "0");
} else {
// discriminator will be randomly generated
// randomly generates a discriminator between 1 and 9999 and checks max five times if it already exists
// TODO: is there any better way to generate a random discriminator only once, without checking if it already exists in the database?
const takenDiscriminators = (await User.find({ where: { username }, select: { discriminator: true } })).map((x) => x.discriminator);
if (takenDiscriminators.length >= 9999) return undefined;
for (let tries = 0; tries < 15; tries++) {
const discriminator = Random.nextInt(1, 9999).toString().padStart(4, "0");
if (!takenDiscriminators.includes(discriminator)) return discriminator;
}
return undefined;
}
}
public get tag(): string {
//const { uniqueUsernames } = Config.get().general;
const uniqueUsernames = false;
return uniqueUsernames ? this.username : `${this.username}#${this.discriminator}`;
}
static async register({
email,
username,
password,
id,
req,
bot,
}: {
username: string;
password?: string;
email?: string;
date_of_birth?: Date; // "2000-04-03"
id?: string;
req?: Request;
bot?: boolean;
}) {
const totalSw = Stopwatch.startNew();
const incSw = Stopwatch.startNew();
const logTrace = (...data: unknown[]) => {
if (process.env.LOG_VERBOSE_TRACES !== "true") return;
console.log("[User.register]", ...data, `[${totalSw.elapsed().toString()} (+${incSw.getElapsedAndReset().totalMilliseconds}ms)]`);
};
// trim special utf8 control characters -> Backspace, Newline, ...
username = trimSpecial(username);
const discriminator = await User.generateDiscriminator(username);
if (!discriminator) {
// We've failed to generate a valid and unused discriminator
throw FieldErrors({
username: {
code: "USERNAME_TOO_MANY_USERS",
message: req?.t("auth:register.USERNAME_TOO_MANY_USERS") || "",
},
});
}
logTrace("Generate discriminator");
// TODO: save date_of_birth
// apparently discord doesn't save the date of birth and just calculate if nsfw is allowed
// if nsfw_allowed is null/undefined it'll require date_of_birth to set it to true/false
const language = req?.language === "en" ? "en-US" : req?.language || "en-US";
const settings = UserSettings.create({
locale: language,
});
const user = User.create({
username: username,
discriminator,
id: id || Snowflake.generate(),
email: email,
data: {
hash: password,
valid_tokens_since: new Date(),
},
settings: settings,
premium_since: Config.get().defaults.user.premium ? new Date() : undefined,
rights: Config.get().register.defaultRights,
premium: Config.get().defaults.user.premium ?? false,
premium_type: Config.get().defaults.user.premiumType ?? 0,
verified: Config.get().defaults.user.verified ?? true,
created_at: new Date(),
bot: !!bot,
});
user.validate();
logTrace("Generate/validate user");
await Promise.all([user.save(), settings.save()]);
logTrace("Save user");
// send verification email if users aren't verified by default and we have an email
if (!Config.get().defaults.user.verified && email) {
await Email.sendVerifyEmail(user, email).catch((e) => {
console.error(`Failed to send verification email to ${user.tag}: ${e}`);
});
logTrace("Send verify email");
}
const { autoJoin } = Config.get().guild;
if (autoJoin.enabled && autoJoin.guilds.length > 0 && !(bot && !autoJoin.bots)) {
await Promise.all(autoJoin.guilds.map((guild) => Member.addToGuild(user.id, guild, true).catch((e) => console.error("[Autojoin]", e))));
logTrace("Autojoin", autoJoin.guilds.length, "guilds");
}
return user;
}
async getDmChannelWith(user_id: string) {
const qry = await Channel.getRepository()
.createQueryBuilder()
.leftJoinAndSelect("Channel.recipients", "rcp")
.where("Channel.type = :type", { type: ChannelType.DM })
.andWhere("rcp.user_id IN (:...user_ids)", { user_ids: [this.id, user_id] })
.groupBy("Channel.id")
.having("COUNT(rcp.user_id) = 2")
.getMany();
// Emma [it/its]@Rory&: is this technically a bug, or am I being too over-cautious?
if (qry.length > 1) {
console.warn(`[WARN] User(${this.id})#getDmChannel(${user_id}) returned multiple channels:`);
for (const channel of qry) {
console.warn(JSON.stringify(channel));
}
throw new Error("Array contains more than one matching element");
}
return qry[0];
}
async getDmChannels() {
const qry = await Channel.getRepository()
.createQueryBuilder("channel")
.leftJoinAndSelect("channel.recipients", "rcp")
.where("channel.type = :type", { type: ChannelType.DM })
.andWhere("rcp.user_id = :user_id", { user_id: this.id })
.groupBy("channel.id")
.addGroupBy("rcp.id")
.having("COUNT(rcp.id) = 2")
.getMany();
return qry;
}
}
+142
View File
@@ -0,0 +1,142 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
import { BaseClassWithoutId } from "./BaseClass";
import { CustomStatus, FriendSourceFlags, GuildFolder } from "@spacebar/schemas";
@Entity({
name: "user_settings",
})
export class UserSettings extends BaseClassWithoutId {
@PrimaryGeneratedColumn()
index: string;
@Column({ nullable: true })
afk_timeout: number = 3600;
@Column({ nullable: true })
allow_accessibility_detection: boolean = true;
@Column({ nullable: true })
animate_emoji: boolean = true;
@Column({ nullable: true })
animate_stickers: number = 0;
@Column({ nullable: true })
contact_sync_enabled: boolean = false;
@Column({ nullable: true })
convert_emoticons: boolean = false;
@Column({ nullable: true, type: "jsonb" })
custom_status: CustomStatus | null = null;
@Column({ nullable: true })
default_guilds_restricted: boolean = false;
@Column({ nullable: true })
detect_platform_accounts: boolean = false;
@Column({ nullable: true })
developer_mode: boolean = true;
@Column({ nullable: true })
disable_games_tab: boolean = true;
@Column({ nullable: true })
enable_tts_command: boolean = false;
@Column({ nullable: true })
explicit_content_filter: number = 0;
@Column({ nullable: true })
friend_discovery_flags: number = 0;
@Column({ nullable: true, type: "jsonb" })
friend_source_flags: FriendSourceFlags = { all: true };
@Column({ nullable: true })
gateway_connected: boolean = false;
@Column({ nullable: true })
gif_auto_play: boolean = false;
@Column({ nullable: true, type: "jsonb" })
guild_folders: GuildFolder[] = []; // every top guild is displayed as a "folder"
@Column({ nullable: true, type: "jsonb" })
guild_positions: string[] = []; // guild ids ordered by position
@Column({ nullable: true })
inline_attachment_media: boolean = true;
@Column({ nullable: true })
inline_embed_media: boolean = true;
@Column({ nullable: true })
locale: string = "en-US"; // en_US
@Column({ nullable: true })
message_display_compact: boolean = false;
@Column({ nullable: true })
native_phone_integration_enabled: boolean = true;
@Column({ nullable: true })
render_embeds: boolean = true;
@Column({ nullable: true })
render_reactions: boolean = true;
@Column({ nullable: true, type: "jsonb" })
restricted_guilds: string[] = [];
@Column({ nullable: true })
show_current_game: boolean = true;
@Column({ nullable: true })
status: "online" | "offline" | "dnd" | "idle" | "invisible" = "online";
@Column({ nullable: true })
stream_notifications_enabled: boolean = false;
@Column({ nullable: true })
theme: "dark" | "light" = "dark"; // dark
@Column({ nullable: true })
timezone_offset: number = 0; // e.g -60
@Column({ nullable: true })
view_nsfw_guilds: boolean = true;
public static async getOrDefault(userId: string) {
// raw sql query
const userSettingsIndex = (await this.getRepository().query('SELECT "settingsIndex" FROM users WHERE id = $1', [userId]))[0]?.settingsIndex as string | null;
console.log(`[INFO/UserSettings] Fetched settings index for user ${userId}:`, userSettingsIndex);
if (!userSettingsIndex) return new UserSettings();
const settings = await UserSettings.findOne({ where: { index: userSettingsIndex } });
if (!settings) return new UserSettings();
return settings;
}
}
+145
View File
@@ -0,0 +1,145 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2025 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from "typeorm";
import { BaseClassWithoutId } from "./BaseClass";
import { User } from "./User";
import { FrecencyUserSettings, PreloadedUserSettings } from "discord-protos";
@Entity({
name: "user_settings_protos",
})
export class UserSettingsProtos extends BaseClassWithoutId {
@OneToOne(() => User, {
cascade: true,
orphanedRowAction: "delete",
eager: false,
})
@JoinColumn({ name: "user_id" })
user: User;
@PrimaryColumn({ type: "text" })
user_id: string;
@Column({ nullable: true, type: String, name: "userSettings" })
_userSettings: string | undefined;
@Column({ nullable: true, type: String, name: "frecencySettings" })
_frecencySettings: string | undefined;
// @Column({nullable: true, type: "simple-json"})
// testSettings: {};
bigintReplacer(_key: string, value: unknown): unknown {
if (typeof value === "bigint") {
return (value as bigint).toString();
} else if (value instanceof Uint8Array) {
return {
__type: "Uint8Array",
data: Array.from(value as Uint8Array)
.map((b) => b.toString(16).padStart(2, "0"))
.join(""),
};
} else {
return value;
}
}
bigintReviver(_key: string, value: unknown): unknown {
if (typeof value === "string" && /^\d+n$/.test(value)) {
return BigInt((value as string).slice(0, -1));
} else if (typeof value === "object" && value !== null && "__type" in value) {
if (value.__type === "Uint8Array" && "data" in value) {
return new Uint8Array((value.data as string).match(/.{1,2}/g)!.map((byte: string) => parseInt(byte, 16)));
}
}
return value;
}
get userSettings(): PreloadedUserSettings | undefined {
if (!this._userSettings) return undefined;
return PreloadedUserSettings.fromJson(JSON.parse(this._userSettings, this.bigintReviver));
}
set userSettings(value: PreloadedUserSettings | undefined) {
if (value) {
// this._userSettings = JSON.stringify(value, this.bigintReplacer);
this._userSettings = PreloadedUserSettings.toJsonString(value);
} else {
this._userSettings = undefined;
}
}
get frecencySettings(): FrecencyUserSettings | undefined {
if (!this._frecencySettings) return undefined;
return FrecencyUserSettings.fromJson(JSON.parse(this._frecencySettings, this.bigintReviver));
}
set frecencySettings(value: FrecencyUserSettings | undefined) {
if (value) {
this._frecencySettings = JSON.stringify(value, this.bigintReplacer);
} else {
this._frecencySettings = undefined;
}
}
static async getOrDefault(user_id: string, save: boolean = false): Promise<UserSettingsProtos> {
await User.findOneOrFail({
where: { id: user_id },
select: { settings: true },
});
let userSettings = await UserSettingsProtos.findOne({
where: { user_id },
});
let modified = false;
if (!userSettings) {
userSettings = UserSettingsProtos.create({
user_id,
});
modified = true;
}
if (!userSettings.userSettings) {
userSettings.userSettings = PreloadedUserSettings.create({
versions: {
dataVersion: 0,
clientVersion: 0,
serverVersion: 0,
},
});
modified = true;
}
if (!userSettings.frecencySettings) {
userSettings.frecencySettings = FrecencyUserSettings.create({
versions: {
dataVersion: 0,
clientVersion: 0,
serverVersion: 0,
},
});
modified = true;
}
if (modified && save) userSettings = await userSettings.save();
return userSettings;
}
}
@@ -0,0 +1,33 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { BaseEntity, Column, Entity, PrimaryColumn } from "typeorm";
@Entity({
name: "valid_registration_tokens",
})
export class ValidRegistrationToken extends BaseEntity {
@PrimaryColumn()
token: string;
@Column()
created_at: Date = new Date();
@Column()
expires_at: Date;
}
+107
View File
@@ -0,0 +1,107 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { BaseClass } from "./BaseClass";
import { Channel } from "./Channel";
import { Guild } from "./Guild";
import { Member } from "./Member";
import { User } from "./User";
import { PublicVoiceState, PublicVoiceStateProjection } from "@spacebar/schemas";
//https://gist.github.com/vassjozsef/e482c65df6ee1facaace8b3c9ff66145#file-voice_state-ex
@Entity({
name: "voice_states",
})
export class VoiceState extends BaseClass {
@Column({ nullable: true })
@RelationId((voice_state: VoiceState) => voice_state.guild)
guild_id: string;
@JoinColumn({ name: "guild_id" })
@ManyToOne(() => Guild, (guild) => guild.voice_states, {
onDelete: "CASCADE",
})
guild?: Guild;
@Column({ nullable: true })
@RelationId((voice_state: VoiceState) => voice_state.channel)
channel_id: string;
@JoinColumn({ name: "channel_id" })
@ManyToOne(() => Channel, {
onDelete: "CASCADE",
})
channel: Channel;
@Column({ nullable: true })
@RelationId((voice_state: VoiceState) => voice_state.user)
user_id: string;
@JoinColumn({ name: "user_id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
user: User;
// @JoinColumn([{ name: "user_id", referencedColumnName: "id" },{ name: "guild_id", referencedColumnName: "guild_id" }])
// @ManyToOne(() => Member, {
// onDelete: "CASCADE",
// })
//TODO find a way to make it work without breaking Guild.voice_states
member: Member;
@Column()
session_id: string;
@Column({ nullable: true })
token: string;
@Column()
deaf: boolean;
@Column()
mute: boolean;
@Column()
self_deaf: boolean;
@Column()
self_mute: boolean;
@Column({ nullable: true })
self_stream?: boolean;
@Column()
self_video: boolean;
@Column()
suppress: boolean; // whether this user is muted by the current user
@Column({ nullable: true, default: null })
request_to_speak_timestamp?: Date;
toPublicVoiceState() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const voiceState: any = {};
PublicVoiceStateProjection.forEach((x) => {
voiceState[x] = this[x];
});
return voiceState as PublicVoiceState;
}
}
+104
View File
@@ -0,0 +1,104 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { Application } from "./Application";
import { BaseClass } from "./BaseClass";
import { Channel } from "./Channel";
import { Guild } from "./Guild";
import { User } from "./User";
import { WebhookType } from "@spacebar/schemas";
@Entity({
name: "webhooks",
})
export class Webhook extends BaseClass {
@Column({ type: "int" })
type: WebhookType;
@Column({ nullable: true })
name: string;
@Column({ nullable: true })
avatar: string;
@Column({ nullable: true })
token?: string;
@Column({ nullable: true })
@RelationId((webhook: Webhook) => webhook.guild)
guild_id?: string;
@JoinColumn({ name: "guild_id" })
@ManyToOne(() => Guild, {
onDelete: "CASCADE",
})
guild?: Guild;
@Column({ nullable: true })
@RelationId((webhook: Webhook) => webhook.channel)
channel_id: string;
@JoinColumn({ name: "channel_id" })
@ManyToOne(() => Channel, {
onDelete: "CASCADE",
})
channel: Channel;
@Column({ nullable: true })
@RelationId((webhook: Webhook) => webhook.application)
application_id: string;
@JoinColumn({ name: "application_id" })
@ManyToOne(() => Application, {
onDelete: "CASCADE",
})
application: Application;
@Column({ nullable: true })
@RelationId((webhook: Webhook) => webhook.user)
user_id: string;
@JoinColumn({ name: "user_id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
user: User;
@Column({ nullable: true })
@RelationId((webhook: Webhook) => webhook.guild)
source_guild_id?: string;
@JoinColumn({ name: "source_guild_id" })
@ManyToOne(() => Guild, {
onDelete: "CASCADE",
})
source_guild?: Guild;
@Column({ nullable: true })
@RelationId((webhook: Webhook) => webhook.channel)
source_channel_id: string;
@JoinColumn({ name: "source_channel_id" })
@ManyToOne(() => Channel, {
onDelete: "CASCADE",
})
source_channel: Channel;
url: string;
}
+66
View File
@@ -0,0 +1,66 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2024 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export * from "./Application";
export * from "./ApplicationCommand";
export * from "./Attachment";
export * from "./AuditLog";
export * from "./AutomodRule";
export * from "./BackupCodes";
export * from "./Badge";
export * from "./Ban";
export * from "./BaseClass";
export * from "./Categories";
export * from "./Channel";
export * from "./ClientRelease";
export * from "./CloudAttachment";
export * from "./Config";
export * from "./ConnectedAccount";
export * from "./ConnectionConfigEntity";
export * from "./EmbedCache";
export * from "./Emoji";
export * from "./Encryption";
export * from "./Guild";
export * from "./InstanceBan";
export * from "./Invite";
export * from "./Member";
export * from "./Message";
export * from "./Migration";
export * from "./Note";
export * from "./RateLimit";
export * from "./ReadState";
export * from "./Recipient";
export * from "./Relationship";
export * from "./Role";
export * from "./SecurityKey";
export * from "./Session";
export * from "./Sticker";
export * from "./StickerPack";
export * from "./Stream";
export * from "./StreamSession";
export * from "./Team";
export * from "./TeamMember";
export * from "./Template";
export * from "./ThreadMember";
export * from "./User";
export * from "./UserSettings";
export * from "./UserSettingsProtos";
export * from "./ValidRegistrationTokens";
export * from "./VoiceState";
export * from "./Webhook";
export * from "./Tag";
+3
View File
@@ -16,4 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import "reflect-metadata";
export * from "./Database";
export * from "./entities/index";