diff options
author | Madeline <46743919+MaddyUnderStars@users.noreply.github.com> | 2023-04-02 11:30:31 +1000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-04-02 11:30:31 +1000 |
commit | 86ac90b1e4e83cb1f55c45055d9ab3a488fe67bd (patch) | |
tree | 83c97c8d7464bbfb0b1924597f1dde8c69528ba9 /src/util | |
parent | Remove ALL fosscord mentions (diff) | |
parent | Less spammy user connection logs (diff) | |
download | server-86ac90b1e4e83cb1f55c45055d9ab3a488fe67bd.tar.xz |
Merge pull request #1009 from Puyodead1/refactor/dev/connections
Connections Part 1
Diffstat (limited to 'src/util')
-rw-r--r-- | src/util/config/types/ApiConfiguration.ts | 2 | ||||
-rw-r--r-- | src/util/connections/Connection.ts | 100 | ||||
-rw-r--r-- | src/util/connections/ConnectionConfig.ts | 80 | ||||
-rw-r--r-- | src/util/connections/ConnectionLoader.ts | 68 | ||||
-rw-r--r-- | src/util/connections/ConnectionStore.ts | 7 | ||||
-rw-r--r-- | src/util/connections/RefreshableConnection.ts | 30 | ||||
-rw-r--r-- | src/util/connections/index.ts | 5 | ||||
-rw-r--r-- | src/util/dtos/ConnectedAccountDTO.ts | 43 | ||||
-rw-r--r-- | src/util/dtos/index.ts | 1 | ||||
-rw-r--r-- | src/util/entities/ConnectedAccount.ts | 38 | ||||
-rw-r--r-- | src/util/entities/ConnectionConfigEntity.ts | 11 | ||||
-rw-r--r-- | src/util/entities/index.ts | 1 | ||||
-rw-r--r-- | src/util/index.ts | 1 | ||||
-rw-r--r-- | src/util/interfaces/ConnectedAccount.ts | 17 | ||||
-rw-r--r-- | src/util/interfaces/Event.ts | 7 | ||||
-rw-r--r-- | src/util/interfaces/index.ts | 5 | ||||
-rw-r--r-- | src/util/schemas/ConnectedAccountSchema.ts | 18 | ||||
-rw-r--r-- | src/util/schemas/ConnectionCallbackSchema.ts | 7 | ||||
-rw-r--r-- | src/util/schemas/ConnectionUpdateSchema.ts | 5 | ||||
-rw-r--r-- | src/util/schemas/index.ts | 3 | ||||
-rw-r--r-- | src/util/util/Constants.ts | 6 |
21 files changed, 444 insertions, 11 deletions
diff --git a/src/util/config/types/ApiConfiguration.ts b/src/util/config/types/ApiConfiguration.ts index 4d61521a..e5a317c7 100644 --- a/src/util/config/types/ApiConfiguration.ts +++ b/src/util/config/types/ApiConfiguration.ts @@ -19,5 +19,5 @@ export class ApiConfiguration { defaultVersion: string = "9"; activeVersions: string[] = ["6", "7", "8", "9"]; - endpointPublic: string = "/api"; + endpointPublic: string | null = null; } diff --git a/src/util/connections/Connection.ts b/src/util/connections/Connection.ts new file mode 100644 index 00000000..26279299 --- /dev/null +++ b/src/util/connections/Connection.ts @@ -0,0 +1,100 @@ +import crypto from "crypto"; +import { ConnectedAccount } from "../entities"; +import { ConnectedAccountSchema, ConnectionCallbackSchema } from "../schemas"; +import { Config, DiscordApiErrors } from "../util"; + +/** + * A connection that can be used to connect to an external service. + */ +export default abstract class Connection { + id: string; + settings: { enabled: boolean }; + states: Map<string, string> = new Map(); + + abstract init(): void; + + /** + * Generates an authorization url for the connection. + * @param args + */ + abstract getAuthorizationUrl(userId: string): string; + + /** + * Returns the redirect_uri for a connection type + * @returns redirect_uri for this connection + */ + getRedirectUri() { + const endpointPublic = + Config.get().api.endpointPublic ?? "http://localhost:3001"; + return `${endpointPublic}/connections/${this.id}/callback`; + } + + /** + * Processes the callback + * @param args Callback arguments + */ + abstract handleCallback( + params: ConnectionCallbackSchema, + ): Promise<ConnectedAccount | null>; + + /** + * Gets a user id from state + * @param state the state to get the user id from + * @returns the user id associated with the state + */ + getUserId(state: string): string { + if (!this.states.has(state)) throw DiscordApiErrors.INVALID_OAUTH_STATE; + return this.states.get(state) as string; + } + + /** + * Generates a state + * @param user_id The user id to generate a state for. + * @returns a new state + */ + createState(userId: string): string { + const state = crypto.randomBytes(16).toString("hex"); + this.states.set(state, userId); + + return state; + } + + /** + * Takes a state and checks if it is valid, and deletes it. + * @param state The state to check. + */ + validateState(state: string): void { + if (!this.states.has(state)) throw DiscordApiErrors.INVALID_OAUTH_STATE; + this.states.delete(state); + } + + /** + * Creates a Connected Account in the database. + * @param data connected account data + * @returns the new connected account + */ + async createConnection( + data: ConnectedAccountSchema, + ): Promise<ConnectedAccount> { + const ca = ConnectedAccount.create({ ...data }); + await ca.save(); + return ca; + } + + /** + * Checks if a user has an exist connected account for the given extenal id. + * @param userId the user id + * @param externalId the connection id to find + * @returns + */ + async hasConnection(userId: string, externalId: string): Promise<boolean> { + const existing = await ConnectedAccount.findOne({ + where: { + user_id: userId, + external_id: externalId, + }, + }); + + return !!existing; + } +} diff --git a/src/util/connections/ConnectionConfig.ts b/src/util/connections/ConnectionConfig.ts new file mode 100644 index 00000000..7d1f9857 --- /dev/null +++ b/src/util/connections/ConnectionConfig.ts @@ -0,0 +1,80 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ConnectionConfigEntity } from "../entities/ConnectionConfigEntity"; + +let config: any; +let pairs: ConnectionConfigEntity[]; + +export const ConnectionConfig = { + init: async function init() { + if (config) return config; + console.log("[Connections] Loading configuration..."); + pairs = await ConnectionConfigEntity.find(); + config = pairsToConfig(pairs); + + return this.set(config); + }, + get: function get() { + if (!config) { + return {}; + } + return config; + }, + set: function set(val: Partial<any>) { + if (!config || !val) return; + config = val.merge(config); + + // return applyConfig(config); + return applyConfig(val); + }, +}; + +function applyConfig(val: any) { + async function apply(obj: any, key = ""): Promise<any> { + if (typeof obj === "object" && obj !== null && !(obj instanceof Date)) + return Promise.all( + Object.keys(obj).map((k) => + apply(obj[k], key ? `${key}_${k}` : k), + ), + ); + + let pair = pairs.find((x) => x.key === key); + if (!pair) pair = new ConnectionConfigEntity(); + + pair.key = key; + + if (pair.value !== obj) { + pair.value = obj; + if (!pair.key || pair.key == null) { + console.log(`[Connections] WARN: Empty config key`); + console.log(pair); + } else return pair.save(); + } + } + + return apply(val); +} + +function pairsToConfig(pairs: ConnectionConfigEntity[]) { + const value: any = {}; + + pairs.forEach((p) => { + const keys = p.key.split("_"); + let obj = value; + let prev = ""; + let prevObj = obj; + let i = 0; + + for (const key of keys) { + if (!isNaN(Number(key)) && !prevObj[prev]?.length) + prevObj[prev] = obj = []; + if (i++ === keys.length - 1) obj[key] = p.value; + else if (!obj[key]) obj[key] = {}; + + prev = key; + prevObj = obj; + obj = obj[key]; + } + }); + + return value; +} diff --git a/src/util/connections/ConnectionLoader.ts b/src/util/connections/ConnectionLoader.ts new file mode 100644 index 00000000..b32f77cd --- /dev/null +++ b/src/util/connections/ConnectionLoader.ts @@ -0,0 +1,68 @@ +import fs from "fs"; +import path from "path"; +import Connection from "./Connection"; +import { ConnectionConfig } from "./ConnectionConfig"; +import { ConnectionStore } from "./ConnectionStore"; + +const root = "dist/connections"; +const connectionsLoaded = false; + +export class ConnectionLoader { + public static async loadConnections() { + if (connectionsLoaded) return; + ConnectionConfig.init(); + const dirs = fs.readdirSync(root).filter((x) => { + try { + fs.readdirSync(path.join(root, x)); + return true; + } catch (e) { + return false; + } + }); + + dirs.forEach(async (x) => { + const modPath = path.resolve(path.join(root, x)); + const mod = new (require(modPath).default)() as Connection; + ConnectionStore.connections.set(mod.id, mod); + + mod.init(); + // console.log(`[Connections] Loaded connection '${mod.id}'`); + }); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public static getConnectionConfig(id: string, defaults?: any): any { + let cfg = ConnectionConfig.get()[id]; + if (defaults) { + if (cfg) cfg = Object.assign({}, defaults, cfg); + else { + cfg = defaults; + this.setConnectionConfig(id, cfg); + } + } + + if (cfg?.enabled) console.log(`[Connections] ${id} enabled`); + + // if (!cfg) + // console.log( + // `[ConnectionConfig/WARN] Getting connection settings for '${id}' returned null! (Did you forget to add settings?)`, + // ); + return cfg; + } + + public static async setConnectionConfig( + id: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + config: Partial<any>, + ): Promise<void> { + if (!config) + console.warn(`[Connections/WARN] ${id} tried to set config=null!`); + + await ConnectionConfig.set({ + [id]: Object.assign( + config, + ConnectionLoader.getConnectionConfig(id) || {}, + ), + }); + } +} diff --git a/src/util/connections/ConnectionStore.ts b/src/util/connections/ConnectionStore.ts new file mode 100644 index 00000000..759b6de7 --- /dev/null +++ b/src/util/connections/ConnectionStore.ts @@ -0,0 +1,7 @@ +import Connection from "./Connection"; +import RefreshableConnection from "./RefreshableConnection"; + +export class ConnectionStore { + public static connections: Map<string, Connection | RefreshableConnection> = + new Map(); +} diff --git a/src/util/connections/RefreshableConnection.ts b/src/util/connections/RefreshableConnection.ts new file mode 100644 index 00000000..87f5f6dd --- /dev/null +++ b/src/util/connections/RefreshableConnection.ts @@ -0,0 +1,30 @@ +import { ConnectedAccount } from "../entities"; +import { ConnectedAccountCommonOAuthTokenResponse } from "../interfaces"; +import Connection from "./Connection"; + +/** + * A connection that can refresh its token. + */ +export default abstract class RefreshableConnection extends Connection { + refreshEnabled = true; + /** + * Refreshes the token for a connected account. + * @param connectedAccount The connected account to refresh + */ + abstract refreshToken( + connectedAccount: ConnectedAccount, + ): Promise<ConnectedAccountCommonOAuthTokenResponse>; + + /** + * Refreshes the token for a connected account and saves it to the database. + * @param connectedAccount The connected account to refresh + */ + async refresh( + connectedAccount: ConnectedAccount, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + const tokenData = await this.refreshToken(connectedAccount); + connectedAccount.token_data = { ...tokenData, fetched_at: Date.now() }; + await connectedAccount.save(); + return tokenData; + } +} diff --git a/src/util/connections/index.ts b/src/util/connections/index.ts new file mode 100644 index 00000000..8d20bf27 --- /dev/null +++ b/src/util/connections/index.ts @@ -0,0 +1,5 @@ +export * from "./Connection"; +export * from "./ConnectionConfig"; +export * from "./ConnectionLoader"; +export * from "./ConnectionStore"; +export * from "./RefreshableConnection"; diff --git a/src/util/dtos/ConnectedAccountDTO.ts b/src/util/dtos/ConnectedAccountDTO.ts new file mode 100644 index 00000000..a3618fd1 --- /dev/null +++ b/src/util/dtos/ConnectedAccountDTO.ts @@ -0,0 +1,43 @@ +import { ConnectedAccount } from "../entities"; + +export class ConnectedAccountDTO { + id: string; + user_id: string; + access_token?: string; + friend_sync?: boolean; + name: string; + revoked?: boolean; + show_activity?: number; + type: string; + verified?: boolean; + visibility?: number; + integrations?: string[]; + metadata_?: any; + metadata_visibility?: number; + two_way_link?: boolean; + + constructor( + connectedAccount: ConnectedAccount, + with_token: boolean = false, + ) { + this.id = connectedAccount.external_id; + this.user_id = connectedAccount.user_id; + this.access_token = + connectedAccount.token_data && with_token + ? connectedAccount.token_data.access_token + : undefined; + this.friend_sync = connectedAccount.friend_sync; + this.name = connectedAccount.name; + this.revoked = connectedAccount.revoked; + this.show_activity = connectedAccount.show_activity; + this.type = connectedAccount.type; + this.verified = connectedAccount.verified; + this.visibility = +(connectedAccount.visibility || false); + this.integrations = connectedAccount.integrations; + this.metadata_ = connectedAccount.metadata_; + this.metadata_visibility = +( + connectedAccount.metadata_visibility || false + ); + this.two_way_link = connectedAccount.two_way_link; + } +} diff --git a/src/util/dtos/index.ts b/src/util/dtos/index.ts index 04cd7b72..b7094227 100644 --- a/src/util/dtos/index.ts +++ b/src/util/dtos/index.ts @@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ +export * from "./ConnectedAccountDTO"; export * from "./DmChannelDTO"; export * from "./ReadyGuildDTO"; export * from "./UserDTO"; diff --git a/src/util/entities/ConnectedAccount.ts b/src/util/entities/ConnectedAccount.ts index 33550197..5dd21250 100644 --- a/src/util/entities/ConnectedAccount.ts +++ b/src/util/entities/ConnectedAccount.ts @@ -17,6 +17,7 @@ */ import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm"; +import { ConnectedAccountTokenData } from "../interfaces"; import { BaseClass } from "./BaseClass"; import { User } from "./User"; @@ -27,6 +28,9 @@ export type PublicConnectedAccount = Pick< @Entity("connected_accounts") export class ConnectedAccount extends BaseClass { + @Column() + external_id: string; + @Column({ nullable: true }) @RelationId((account: ConnectedAccount) => account.user) user_id: string; @@ -38,26 +42,44 @@ export class ConnectedAccount extends BaseClass { user: User; @Column({ select: false }) - access_token: string; - - @Column({ select: false }) - friend_sync: boolean; + friend_sync?: boolean = false; @Column() name: string; @Column({ select: false }) - revoked: boolean; + revoked?: boolean = false; @Column({ select: false }) - show_activity: boolean; + show_activity?: number = 0; @Column() type: string; @Column() - verified: boolean; + verified?: boolean = true; @Column({ select: false }) - visibility: number; + visibility?: number = 0; + + @Column({ type: "simple-array" }) + integrations?: string[] = []; + + @Column({ type: "simple-json", name: "metadata", nullable: true }) + metadata_?: any; + + @Column() + metadata_visibility?: number = 0; + + @Column() + two_way_link?: boolean = false; + + @Column({ select: false, nullable: true, type: "simple-json" }) + token_data?: ConnectedAccountTokenData | null; + + async revoke() { + this.revoked = true; + this.token_data = null; + await this.save(); + } } diff --git a/src/util/entities/ConnectionConfigEntity.ts b/src/util/entities/ConnectionConfigEntity.ts new file mode 100644 index 00000000..9c212b15 --- /dev/null +++ b/src/util/entities/ConnectionConfigEntity.ts @@ -0,0 +1,11 @@ +import { Column, Entity } from "typeorm"; +import { BaseClassWithoutId, PrimaryIdColumn } from "./BaseClass"; + +@Entity("connection_config") +export class ConnectionConfigEntity extends BaseClassWithoutId { + @PrimaryIdColumn() + key: string; + + @Column({ type: "simple-json", nullable: true }) + value: number | boolean | null | string | Date | undefined; +} diff --git a/src/util/entities/index.ts b/src/util/entities/index.ts index 9b01aa77..aa943dca 100644 --- a/src/util/entities/index.ts +++ b/src/util/entities/index.ts @@ -27,6 +27,7 @@ export * from "./Channel"; export * from "./ClientRelease"; export * from "./Config"; export * from "./ConnectedAccount"; +export * from "./ConnectionConfigEntity"; export * from "./EmbedCache"; export * from "./Emoji"; export * from "./Encryption"; diff --git a/src/util/index.ts b/src/util/index.ts index 9174c3a1..c3d32bba 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -25,3 +25,4 @@ export * from "./dtos/index"; export * from "./schemas"; export * from "./imports"; export * from "./config"; +export * from "./connections"; diff --git a/src/util/interfaces/ConnectedAccount.ts b/src/util/interfaces/ConnectedAccount.ts new file mode 100644 index 00000000..ede02f6d --- /dev/null +++ b/src/util/interfaces/ConnectedAccount.ts @@ -0,0 +1,17 @@ +export interface ConnectedAccountCommonOAuthTokenResponse { + access_token: string; + token_type: string; + scope: string; + refresh_token?: string; + expires_in?: number; +} + +export interface ConnectedAccountTokenData { + access_token: string; + token_type?: string; + scope?: string; + refresh_token?: string; + expires_in?: number; + expires_at?: number; + fetched_at: number; +} diff --git a/src/util/interfaces/Event.ts b/src/util/interfaces/Event.ts index 3a0eadc5..76a5f8d0 100644 --- a/src/util/interfaces/Event.ts +++ b/src/util/interfaces/Event.ts @@ -420,6 +420,10 @@ export interface UserDeleteEvent extends Event { }; } +export interface UserConnectionsUpdateEvent extends Event { + event: "USER_CONNECTIONS_UPDATE"; +} + export interface VoiceStateUpdateEvent extends Event { event: "VOICE_STATE_UPDATE"; data: VoiceState & { @@ -561,6 +565,7 @@ export type EventData = | TypingStartEvent | UserUpdateEvent | UserDeleteEvent + | UserConnectionsUpdateEvent | VoiceStateUpdateEvent | VoiceServerUpdateEvent | WebhooksUpdateEvent @@ -612,6 +617,7 @@ export enum EVENTEnum { TypingStart = "TYPING_START", UserUpdate = "USER_UPDATE", UserDelete = "USER_DELETE", + UserConnectionsUpdate = "USER_CONNECTIONS_UPDATE", WebhooksUpdate = "WEBHOOKS_UPDATE", InteractionCreate = "INTERACTION_CREATE", VoiceStateUpdate = "VOICE_STATE_UPDATE", @@ -663,6 +669,7 @@ export type EVENT = | "TYPING_START" | "USER_UPDATE" | "USER_DELETE" + | "USER_CONNECTIONS_UPDATE" | "USER_NOTE_UPDATE" | "WEBHOOKS_UPDATE" | "INTERACTION_CREATE" diff --git a/src/util/interfaces/index.ts b/src/util/interfaces/index.ts index e37b8874..c6a00458 100644 --- a/src/util/interfaces/index.ts +++ b/src/util/interfaces/index.ts @@ -17,7 +17,8 @@ */ export * from "./Activity"; -export * from "./Presence"; -export * from "./Interaction"; +export * from "./ConnectedAccount"; export * from "./Event"; +export * from "./Interaction"; +export * from "./Presence"; export * from "./Status"; diff --git a/src/util/schemas/ConnectedAccountSchema.ts b/src/util/schemas/ConnectedAccountSchema.ts new file mode 100644 index 00000000..fa834bd6 --- /dev/null +++ b/src/util/schemas/ConnectedAccountSchema.ts @@ -0,0 +1,18 @@ +import { ConnectedAccountTokenData } from "../interfaces"; + +export interface ConnectedAccountSchema { + external_id: string; + user_id: string; + token_data?: ConnectedAccountTokenData; + friend_sync?: boolean; + name: string; + revoked?: boolean; + show_activity?: number; + type: string; + verified?: boolean; + visibility?: number; + integrations?: string[]; + metadata_?: any; + metadata_visibility?: number; + two_way_link?: boolean; +} diff --git a/src/util/schemas/ConnectionCallbackSchema.ts b/src/util/schemas/ConnectionCallbackSchema.ts new file mode 100644 index 00000000..09ae8a46 --- /dev/null +++ b/src/util/schemas/ConnectionCallbackSchema.ts @@ -0,0 +1,7 @@ +export interface ConnectionCallbackSchema { + code?: string; + state: string; + insecure: boolean; + friend_sync: boolean; + openid_params?: any; // TODO: types +} diff --git a/src/util/schemas/ConnectionUpdateSchema.ts b/src/util/schemas/ConnectionUpdateSchema.ts new file mode 100644 index 00000000..e1e6523a --- /dev/null +++ b/src/util/schemas/ConnectionUpdateSchema.ts @@ -0,0 +1,5 @@ +export interface ConnectionUpdateSchema { + visibility?: boolean; + show_activity?: boolean; + metadata_visibility?: boolean; +} diff --git a/src/util/schemas/index.ts b/src/util/schemas/index.ts index 498b5ad7..2d254752 100644 --- a/src/util/schemas/index.ts +++ b/src/util/schemas/index.ts @@ -30,6 +30,9 @@ export * from "./ChannelModifySchema"; export * from "./ChannelPermissionOverwriteSchema"; export * from "./ChannelReorderSchema"; export * from "./CodesVerificationSchema"; +export * from "./ConnectedAccountSchema"; +export * from "./ConnectionCallbackSchema"; +export * from "./ConnectionUpdateSchema"; export * from "./DmChannelCreateSchema"; export * from "./EmojiCreateSchema"; export * from "./EmojiModifySchema"; diff --git a/src/util/util/Constants.ts b/src/util/util/Constants.ts index d4adb54e..e68bb0b7 100644 --- a/src/util/util/Constants.ts +++ b/src/util/util/Constants.ts @@ -578,6 +578,7 @@ export const DiscordApiErrors = { UNKNOWN_EMOJI: new ApiError("Unknown emoji", 10014), UNKNOWN_WEBHOOK: new ApiError("Unknown webhook", 10015), UNKNOWN_WEBHOOK_SERVICE: new ApiError("Unknown webhook service", 10016), + UNKNOWN_CONNECTION: new ApiError("Unknown connection", 10017, 400), UNKNOWN_SESSION: new ApiError("Unknown session", 10020), UNKNOWN_BAN: new ApiError("Unknown ban", 10026), UNKNOWN_SKU: new ApiError("Unknown SKU", 10027), @@ -786,6 +787,11 @@ export const DiscordApiErrors = { 40006, ), USER_BANNED: new ApiError("The user is banned from this guild", 40007), + CONNECTION_REVOKED: new ApiError( + "The connection has been revoked", + 40012, + 400, + ), TARGET_USER_IS_NOT_CONNECTED_TO_VOICE: new ApiError( "Target user is not connected to voice", 40032, |