mirror of
https://github.com/spacebarchat/server.git
synced 2026-08-27 22:34:11 +00:00
Move entities to database
This commit is contained in:
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
// }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
// }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
// ]);
|
||||
// }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user