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 | |
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')
54 files changed, 2377 insertions, 62 deletions
diff --git a/src/api/Server.ts b/src/api/Server.ts index 447a4802..472ab1d6 100644 --- a/src/api/Server.ts +++ b/src/api/Server.ts @@ -25,6 +25,8 @@ import { registerRoutes, Sentry, WebAuthn, + ConnectionConfig, + ConnectionLoader, } from "@spacebar/util"; import { Request, Response, Router } from "express"; import { Server, ServerOptions } from "lambert-server"; @@ -72,6 +74,7 @@ export class SpacebarServer extends Server { await Config.init(); await initEvent(); await Email.init(); + await ConnectionConfig.init(); await initInstance(); await Sentry.init(this.app); WebAuthn.init(); @@ -142,6 +145,8 @@ export class SpacebarServer extends Server { Sentry.errorHandler(this.app); + ConnectionLoader.loadConnections(); + if (logRequests) console.log( red( diff --git a/src/api/middlewares/Authentication.ts b/src/api/middlewares/Authentication.ts index 09644eee..d0e4d8a0 100644 --- a/src/api/middlewares/Authentication.ts +++ b/src/api/middlewares/Authentication.ts @@ -52,6 +52,8 @@ export const NO_AUTHORIZATION_ROUTES = [ "/oauth2/callback", // Asset delivery /\/guilds\/\d+\/widget\.(json|png)/, + // Connections + /\/connections\/\w+\/callback/, ]; export const API_PREFIX = /^\/api(\/v\d+)?/; diff --git a/src/api/routes/connections/#connection_name/#connection_id/refresh.ts b/src/api/routes/connections/#connection_name/#connection_id/refresh.ts new file mode 100644 index 00000000..4fa64978 --- /dev/null +++ b/src/api/routes/connections/#connection_name/#connection_id/refresh.ts @@ -0,0 +1,11 @@ +import { route } from "@spacebar/api"; +import { Request, Response, Router } from "express"; +const router = Router(); + +router.post("/", route({}), async (req: Request, res: Response) => { + // TODO: + const { connection_name, connection_id } = req.params; + res.sendStatus(204); +}); + +export default router; diff --git a/src/api/routes/connections/#connection_name/authorize.ts b/src/api/routes/connections/#connection_name/authorize.ts new file mode 100644 index 00000000..63738e7c --- /dev/null +++ b/src/api/routes/connections/#connection_name/authorize.ts @@ -0,0 +1,34 @@ +import { route } from "@spacebar/api"; +import { Request, Response, Router } from "express"; +import { ConnectionStore, FieldErrors } from "../../../../util"; + +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { connection_name } = req.params; + const connection = ConnectionStore.connections.get(connection_name); + if (!connection) + throw FieldErrors({ + provider_id: { + code: "BASE_TYPE_CHOICES", + message: req.t("common:field.BASE_TYPE_CHOICES", { + types: Array.from(ConnectionStore.connections.keys()).join( + ", ", + ), + }), + }, + }); + + if (!connection.settings.enabled) + throw FieldErrors({ + provider_id: { + message: "This connection has been disabled server-side.", + }, + }); + + res.json({ + url: await connection.getAuthorizationUrl(req.user_id), + }); +}); + +export default router; diff --git a/src/api/routes/connections/#connection_name/callback.ts b/src/api/routes/connections/#connection_name/callback.ts new file mode 100644 index 00000000..74021170 --- /dev/null +++ b/src/api/routes/connections/#connection_name/callback.ts @@ -0,0 +1,53 @@ +import { route } from "@spacebar/api"; +import { + ConnectionCallbackSchema, + ConnectionStore, + emitEvent, + FieldErrors, +} from "@spacebar/util"; +import { Request, Response, Router } from "express"; + +const router = Router(); + +router.post( + "/", + route({ body: "ConnectionCallbackSchema" }), + async (req: Request, res: Response) => { + const { connection_name } = req.params; + const connection = ConnectionStore.connections.get(connection_name); + if (!connection) + throw FieldErrors({ + provider_id: { + code: "BASE_TYPE_CHOICES", + message: req.t("common:field.BASE_TYPE_CHOICES", { + types: Array.from( + ConnectionStore.connections.keys(), + ).join(", "), + }), + }, + }); + + if (!connection.settings.enabled) + throw FieldErrors({ + provider_id: { + message: "This connection has been disabled server-side.", + }, + }); + + const body = req.body as ConnectionCallbackSchema; + const userId = connection.getUserId(body.state); + const connectedAccnt = await connection.handleCallback(body); + + // whether we should emit a connections update event, only used when a connection doesnt already exist + if (connectedAccnt) + emitEvent({ + event: "USER_CONNECTIONS_UPDATE", + data: { ...connectedAccnt, token_data: undefined }, + user_id: userId, + }); + + res.sendStatus(204); + }, +); + +export default router; diff --git a/src/api/routes/policies/instance/domains.ts b/src/api/routes/policies/instance/domains.ts index fe032b50..696a8510 100644 --- a/src/api/routes/policies/instance/domains.ts +++ b/src/api/routes/policies/instance/domains.ts @@ -31,7 +31,7 @@ router.get("/", route({}), async (req: Request, res: Response) => { process.env.GATEWAY || "ws://localhost:3001", defaultApiVersion: api.defaultVersion ?? 9, - apiEndpoint: api.endpointPublic ?? "/api", + apiEndpoint: api.endpointPublic ?? "http://localhost:3001/api/", }; res.json(IdentityForm); diff --git a/src/api/routes/users/#id/profile.ts b/src/api/routes/users/#id/profile.ts index 4727e215..2836c563 100644 --- a/src/api/routes/users/#id/profile.ts +++ b/src/api/routes/users/#id/profile.ts @@ -133,7 +133,9 @@ router.get( guild_id, }; res.json({ - connected_accounts: user.connected_accounts, + connected_accounts: user.connected_accounts.filter( + (x) => x.visibility != 0, + ), premium_guild_since: premium_guild_since, // TODO premium_since: user.premium_since, // TODO mutual_guilds: mutual_guilds, // TODO {id: "", nick: null} when ?with_mutual_guilds=true diff --git a/src/api/routes/users/@me/connections/#connection_name/#connection_id/access-token.ts b/src/api/routes/users/@me/connections/#connection_name/#connection_id/access-token.ts new file mode 100644 index 00000000..a290d88a --- /dev/null +++ b/src/api/routes/users/@me/connections/#connection_name/#connection_id/access-token.ts @@ -0,0 +1,81 @@ +import { route } from "@spacebar/api"; +import { + ApiError, + ConnectedAccount, + ConnectionStore, + DiscordApiErrors, + FieldErrors, +} from "@spacebar/util"; +import { Request, Response, Router } from "express"; +import RefreshableConnection from "../../../../../../../util/connections/RefreshableConnection"; +const router = Router(); + +// TODO: this route is only used for spotify, twitch, and youtube. (battlenet seems to be able to PUT, maybe others also) + +// spotify is disabled here because it cant be used +const ALLOWED_CONNECTIONS = ["twitch", "youtube"]; + +// NOTE: this route has not been extensively tested, as the required connections are not implemented as of writing +router.get("/", route({}), async (req: Request, res: Response) => { + const { connection_name, connection_id } = req.params; + + const connection = ConnectionStore.connections.get(connection_name); + + if (!ALLOWED_CONNECTIONS.includes(connection_name) || !connection) + throw FieldErrors({ + provider_id: { + code: "BASE_TYPE_CHOICES", + message: req.t("common:field.BASE_TYPE_CHOICES", { + types: ALLOWED_CONNECTIONS.join(", "), + }), + }, + }); + + if (!connection.settings.enabled) + throw FieldErrors({ + provider_id: { + message: "This connection has been disabled server-side.", + }, + }); + + const connectedAccount = await ConnectedAccount.findOne({ + where: { + type: connection_name, + external_id: connection_id, + user_id: req.user_id, + }, + select: [ + "external_id", + "type", + "name", + "verified", + "visibility", + "show_activity", + "revoked", + "token_data", + "friend_sync", + "integrations", + ], + }); + if (!connectedAccount) throw DiscordApiErrors.UNKNOWN_CONNECTION; + if (connectedAccount.revoked) throw DiscordApiErrors.CONNECTION_REVOKED; + if (!connectedAccount.token_data) + throw new ApiError("No token data", 0, 400); + + let access_token = connectedAccount.token_data.access_token; + const { expires_at, expires_in, fetched_at } = connectedAccount.token_data; + + if ( + (expires_at && expires_at < Date.now()) || + (expires_in && fetched_at + expires_in * 1000 < Date.now()) + ) { + if (!(connection instanceof RefreshableConnection)) + throw new ApiError("Access token expired", 0, 400); + const tokenData = await connection.refresh(connectedAccount); + access_token = tokenData.access_token; + } + + res.json({ access_token }); +}); + +export default router; diff --git a/src/api/routes/users/@me/connections/#connection_name/#connection_id/index.ts b/src/api/routes/users/@me/connections/#connection_name/#connection_id/index.ts new file mode 100644 index 00000000..afb0df06 --- /dev/null +++ b/src/api/routes/users/@me/connections/#connection_name/#connection_id/index.ts @@ -0,0 +1,88 @@ +import { route } from "@spacebar/api"; +import { + ConnectedAccount, + ConnectionUpdateSchema, + DiscordApiErrors, + emitEvent, +} from "@spacebar/util"; +import { Request, Response, Router } from "express"; +const router = Router(); + +// TODO: connection update schema +router.patch( + "/", + route({ body: "ConnectionUpdateSchema" }), + async (req: Request, res: Response) => { + const { connection_name, connection_id } = req.params; + const body = req.body as ConnectionUpdateSchema; + + const connection = await ConnectedAccount.findOne({ + where: { + user_id: req.user_id, + external_id: connection_id, + type: connection_name, + }, + select: [ + "external_id", + "type", + "name", + "verified", + "visibility", + "show_activity", + "revoked", + "friend_sync", + "integrations", + ], + }); + + if (!connection) return DiscordApiErrors.UNKNOWN_CONNECTION; + // TODO: do we need to do anything if the connection is revoked? + + if (typeof body.visibility === "boolean") + //@ts-expect-error For some reason the client sends this as a boolean, even tho docs say its a number? + body.visibility = body.visibility ? 1 : 0; + if (typeof body.show_activity === "boolean") + //@ts-expect-error For some reason the client sends this as a boolean, even tho docs say its a number? + body.show_activity = body.show_activity ? 1 : 0; + if (typeof body.metadata_visibility === "boolean") + //@ts-expect-error For some reason the client sends this as a boolean, even tho docs say its a number? + body.metadata_visibility = body.metadata_visibility ? 1 : 0; + + connection.assign(req.body); + + await ConnectedAccount.update( + { + user_id: req.user_id, + external_id: connection_id, + type: connection_name, + }, + connection, + ); + res.json(connection.toJSON()); + }, +); + +router.delete("/", route({}), async (req: Request, res: Response) => { + const { connection_name, connection_id } = req.params; + + const account = await ConnectedAccount.findOneOrFail({ + where: { + user_id: req.user_id, + external_id: connection_id, + type: connection_name, + }, + }); + + await Promise.all([ + ConnectedAccount.remove(account), + emitEvent({ + event: "USER_CONNECTIONS_UPDATE", + data: account, + user_id: req.user_id, + }), + ]); + + return res.sendStatus(200); +}); + +export default router; diff --git a/src/api/routes/users/@me/connections.ts b/src/api/routes/users/@me/connections/index.ts index 1d4564da..620ce3b5 100644 --- a/src/api/routes/users/@me/connections.ts +++ b/src/api/routes/users/@me/connections/index.ts @@ -18,12 +18,30 @@ import { Request, Response, Router } from "express"; import { route } from "@spacebar/api"; +import { ConnectedAccount, ConnectedAccountDTO } from "@spacebar/util"; const router: Router = Router(); router.get("/", route({}), async (req: Request, res: Response) => { - //TODO - res.json([]).status(200); + const connections = await ConnectedAccount.find({ + where: { + user_id: req.user_id, + }, + select: [ + "external_id", + "type", + "name", + "verified", + "visibility", + "show_activity", + "revoked", + "token_data", + "friend_sync", + "integrations", + ], + }); + + res.json(connections.map((x) => new ConnectedAccountDTO(x, true))); }); export default router; diff --git a/src/connections/BattleNet/BattleNetSettings.ts b/src/connections/BattleNet/BattleNetSettings.ts new file mode 100644 index 00000000..75e5c3ae --- /dev/null +++ b/src/connections/BattleNet/BattleNetSettings.ts @@ -0,0 +1,5 @@ +export class BattleNetSettings { + enabled: boolean = false; + clientId: string | null = null; + clientSecret: string | null = null; +} diff --git a/src/connections/BattleNet/index.ts b/src/connections/BattleNet/index.ts new file mode 100644 index 00000000..7ea919f1 --- /dev/null +++ b/src/connections/BattleNet/index.ts @@ -0,0 +1,116 @@ +import { + ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, + ConnectionCallbackSchema, + ConnectionLoader, + DiscordApiErrors, +} from "@spacebar/util"; +import wretch from "wretch"; +import Connection from "../../util/connections/Connection"; +import { BattleNetSettings } from "./BattleNetSettings"; + +interface BattleNetConnectionUser { + sub: string; + id: number; + battletag: string; +} + +interface BattleNetErrorResponse { + error: string; + error_description: string; +} + +export default class BattleNetConnection extends Connection { + public readonly id = "battlenet"; + public readonly authorizeUrl = "https://oauth.battle.net/authorize"; + public readonly tokenUrl = "https://oauth.battle.net/token"; + public readonly userInfoUrl = "https://us.battle.net/oauth/userinfo"; + public readonly scopes = []; + settings: BattleNetSettings = new BattleNetSettings(); + + init(): void { + this.settings = ConnectionLoader.getConnectionConfig( + this.id, + this.settings, + ) as BattleNetSettings; + } + + getAuthorizationUrl(userId: string): string { + const state = this.createState(userId); + const url = new URL(this.authorizeUrl); + + url.searchParams.append("client_id", this.settings.clientId!); + url.searchParams.append("redirect_uri", this.getRedirectUri()); + url.searchParams.append("scope", this.scopes.join(" ")); + url.searchParams.append("state", state); + url.searchParams.append("response_type", "code"); + return url.toString(); + } + + getTokenUrl(): string { + return this.tokenUrl; + } + + async exchangeCode( + state: string, + code: string, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + this.validateState(state); + + const url = this.getTokenUrl(); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + }) + .body( + new URLSearchParams({ + grant_type: "authorization_code", + code: code, + client_id: this.settings.clientId!, + client_secret: this.settings.clientSecret!, + redirect_uri: this.getRedirectUri(), + }), + ) + .post() + .json<ConnectedAccountCommonOAuthTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async getUser(token: string): Promise<BattleNetConnectionUser> { + const url = new URL(this.userInfoUrl); + return wretch(url.toString()) + .headers({ + Authorization: `Bearer ${token}`, + }) + .get() + .json<BattleNetConnectionUser>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async handleCallback( + params: ConnectionCallbackSchema, + ): Promise<ConnectedAccount | null> { + const userId = this.getUserId(params.state); + const tokenData = await this.exchangeCode(params.state, params.code!); + const userInfo = await this.getUser(tokenData.access_token); + + const exists = await this.hasConnection(userId, userInfo.id.toString()); + + if (exists) return null; + + return await this.createConnection({ + user_id: userId, + external_id: userInfo.id.toString(), + friend_sync: params.friend_sync, + name: userInfo.battletag, + type: this.id, + }); + } +} diff --git a/src/connections/Discord/DiscordSettings.ts b/src/connections/Discord/DiscordSettings.ts new file mode 100644 index 00000000..3751b041 --- /dev/null +++ b/src/connections/Discord/DiscordSettings.ts @@ -0,0 +1,5 @@ +export class DiscordSettings { + enabled: boolean = false; + clientId: string | null = null; + clientSecret: string | null = null; +} diff --git a/src/connections/Discord/index.ts b/src/connections/Discord/index.ts new file mode 100644 index 00000000..24e90860 --- /dev/null +++ b/src/connections/Discord/index.ts @@ -0,0 +1,115 @@ +import { + ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, + ConnectionCallbackSchema, + ConnectionLoader, + DiscordApiErrors, +} from "@spacebar/util"; +import wretch from "wretch"; +import Connection from "../../util/connections/Connection"; +import { DiscordSettings } from "./DiscordSettings"; + +interface UserResponse { + id: string; + username: string; + discriminator: string; + avatar_url: string | null; +} + +export default class DiscordConnection extends Connection { + public readonly id = "discord"; + public readonly authorizeUrl = "https://discord.com/api/oauth2/authorize"; + public readonly tokenUrl = "https://discord.com/api/oauth2/token"; + public readonly userInfoUrl = "https://discord.com/api/users/@me"; + public readonly scopes = ["identify"]; + settings: DiscordSettings = new DiscordSettings(); + + init(): void { + this.settings = ConnectionLoader.getConnectionConfig( + this.id, + this.settings, + ) as DiscordSettings; + } + + getAuthorizationUrl(userId: string): string { + const state = this.createState(userId); + const url = new URL(this.authorizeUrl); + + url.searchParams.append("state", state); + url.searchParams.append("client_id", this.settings.clientId!); + url.searchParams.append("scope", this.scopes.join(" ")); + url.searchParams.append("response_type", "code"); + // controls whether, on repeated authorizations, the consent screen is shown + url.searchParams.append("consent", "none"); + url.searchParams.append("redirect_uri", this.getRedirectUri()); + + return url.toString(); + } + + getTokenUrl(): string { + return this.tokenUrl; + } + + async exchangeCode( + state: string, + code: string, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + this.validateState(state); + const url = this.getTokenUrl(); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }) + .body( + new URLSearchParams({ + client_id: this.settings.clientId!, + client_secret: this.settings.clientSecret!, + grant_type: "authorization_code", + code: code, + redirect_uri: this.getRedirectUri(), + }), + ) + .post() + .json<ConnectedAccountCommonOAuthTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async getUser(token: string): Promise<UserResponse> { + const url = new URL(this.userInfoUrl); + return wretch(url.toString()) + .headers({ + Authorization: `Bearer ${token}`, + }) + .get() + .json<UserResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async handleCallback( + params: ConnectionCallbackSchema, + ): Promise<ConnectedAccount | null> { + const userId = this.getUserId(params.state); + const tokenData = await this.exchangeCode(params.state, params.code!); + const userInfo = await this.getUser(tokenData.access_token); + + const exists = await this.hasConnection(userId, userInfo.id); + + if (exists) return null; + + return await this.createConnection({ + user_id: userId, + external_id: userInfo.id, + friend_sync: params.friend_sync, + name: `${userInfo.username}#${userInfo.discriminator}`, + type: this.id, + }); + } +} diff --git a/src/connections/EpicGames/EpicGamesSettings.ts b/src/connections/EpicGames/EpicGamesSettings.ts new file mode 100644 index 00000000..4820a88a --- /dev/null +++ b/src/connections/EpicGames/EpicGamesSettings.ts @@ -0,0 +1,5 @@ +export class EpicGamesSettings { + enabled: boolean = false; + clientId: string | null = null; + clientSecret: string | null = null; +} diff --git a/src/connections/EpicGames/index.ts b/src/connections/EpicGames/index.ts new file mode 100644 index 00000000..5e758540 --- /dev/null +++ b/src/connections/EpicGames/index.ts @@ -0,0 +1,128 @@ +import { + ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, + ConnectionCallbackSchema, + ConnectionLoader, + DiscordApiErrors, +} from "@spacebar/util"; +import wretch from "wretch"; +import Connection from "../../util/connections/Connection"; +import { EpicGamesSettings } from "./EpicGamesSettings"; + +export interface UserResponse { + accountId: string; + displayName: string; + preferredLanguage: string; +} + +export interface EpicTokenResponse + extends ConnectedAccountCommonOAuthTokenResponse { + expires_at: string; + refresh_expires_in: number; + refresh_expires_at: string; + account_id: string; + client_id: string; + application_id: string; +} + +export default class EpicGamesConnection extends Connection { + public readonly id = "epicgames"; + public readonly authorizeUrl = "https://www.epicgames.com/id/authorize"; + public readonly tokenUrl = "https://api.epicgames.dev/epic/oauth/v1/token"; + public readonly userInfoUrl = + "https://api.epicgames.dev/epic/id/v1/accounts"; + public readonly scopes = ["basic profile"]; + settings: EpicGamesSettings = new EpicGamesSettings(); + + init(): void { + this.settings = ConnectionLoader.getConnectionConfig( + this.id, + this.settings, + ) as EpicGamesSettings; + } + + getAuthorizationUrl(userId: string): string { + const state = this.createState(userId); + const url = new URL(this.authorizeUrl); + + url.searchParams.append("client_id", this.settings.clientId!); + url.searchParams.append("redirect_uri", this.getRedirectUri()); + url.searchParams.append("response_type", "code"); + url.searchParams.append("scope", this.scopes.join(" ")); + url.searchParams.append("state", state); + return url.toString(); + } + + getTokenUrl(): string { + return this.tokenUrl; + } + + async exchangeCode( + state: string, + code: string, + ): Promise<EpicTokenResponse> { + this.validateState(state); + + const url = this.getTokenUrl(); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + Authorization: `Basic ${Buffer.from( + `${this.settings.clientId}:${this.settings.clientSecret}`, + ).toString("base64")}`, + "Content-Type": "application/x-www-form-urlencoded", + }) + .body( + new URLSearchParams({ + grant_type: "authorization_code", + code, + }), + ) + .post() + .json<EpicTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async getUser(token: string): Promise<UserResponse[]> { + const { sub } = JSON.parse( + Buffer.from(token.split(".")[1], "base64").toString("utf8"), + ); + const url = new URL(this.userInfoUrl); + url.searchParams.append("accountId", sub); + + return wretch(url.toString()) + .headers({ + Authorization: `Bearer ${token}`, + }) + .get() + .json<UserResponse[]>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async handleCallback( + params: ConnectionCallbackSchema, + ): Promise<ConnectedAccount | null> { + const userId = this.getUserId(params.state); + const tokenData = await this.exchangeCode(params.state, params.code!); + const userInfo = await this.getUser(tokenData.access_token); + + const exists = await this.hasConnection(userId, userInfo[0].accountId); + + if (exists) return null; + + return await this.createConnection({ + user_id: userId, + external_id: userInfo[0].accountId, + friend_sync: params.friend_sync, + name: userInfo[0].displayName, + type: this.id, + }); + } +} diff --git a/src/connections/Facebook/FacebookSettings.ts b/src/connections/Facebook/FacebookSettings.ts new file mode 100644 index 00000000..cc3e3402 --- /dev/null +++ b/src/connections/Facebook/FacebookSettings.ts @@ -0,0 +1,5 @@ +export class FacebookSettings { + enabled: boolean = false; + clientId: string | null = null; + clientSecret: string | null = null; +} diff --git a/src/connections/Facebook/index.ts b/src/connections/Facebook/index.ts new file mode 100644 index 00000000..8e0f8d27 --- /dev/null +++ b/src/connections/Facebook/index.ts @@ -0,0 +1,119 @@ +import { + ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, + ConnectionCallbackSchema, + ConnectionLoader, + DiscordApiErrors, +} from "@spacebar/util"; +import wretch from "wretch"; +import Connection from "../../util/connections/Connection"; +import { FacebookSettings } from "./FacebookSettings"; + +export interface FacebookErrorResponse { + error: { + message: string; + type: string; + code: number; + fbtrace_id: string; + }; +} + +interface UserResponse { + name: string; + id: string; +} + +export default class FacebookConnection extends Connection { + public readonly id = "facebook"; + public readonly authorizeUrl = + "https://www.facebook.com/v14.0/dialog/oauth"; + public readonly tokenUrl = + "https://graph.facebook.com/v14.0/oauth/access_token"; + public readonly userInfoUrl = "https://graph.facebook.com/v14.0/me"; + public readonly scopes = ["public_profile"]; + settings: FacebookSettings = new FacebookSettings(); + + init(): void { + this.settings = ConnectionLoader.getConnectionConfig( + this.id, + this.settings, + ) as FacebookSettings; + } + + getAuthorizationUrl(userId: string): string { + const state = this.createState(userId); + const url = new URL(this.authorizeUrl); + + url.searchParams.append("client_id", this.settings.clientId!); + url.searchParams.append("redirect_uri", this.getRedirectUri()); + url.searchParams.append("state", state); + url.searchParams.append("response_type", "code"); + url.searchParams.append("scope", this.scopes.join(" ")); + url.searchParams.append("display", "popup"); + return url.toString(); + } + + getTokenUrl(code: string): string { + const url = new URL(this.tokenUrl); + url.searchParams.append("client_id", this.settings.clientId!); + url.searchParams.append("client_secret", this.settings.clientSecret!); + url.searchParams.append("code", code); + url.searchParams.append("redirect_uri", this.getRedirectUri()); + return url.toString(); + } + + async exchangeCode( + state: string, + code: string, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + this.validateState(state); + + const url = this.getTokenUrl(code); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + }) + .get() + .json<ConnectedAccountCommonOAuthTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async getUser(token: string): Promise<UserResponse> { + const url = new URL(this.userInfoUrl); + + return wretch(url.toString()) + .headers({ + Authorization: `Bearer ${token}`, + }) + .get() + .json<UserResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async handleCallback( + params: ConnectionCallbackSchema, + ): Promise<ConnectedAccount | null> { + const userId = this.getUserId(params.state); + const tokenData = await this.exchangeCode(params.state, params.code!); + const userInfo = await this.getUser(tokenData.access_token); + + const exists = await this.hasConnection(userId, userInfo.id); + + if (exists) return null; + + return await this.createConnection({ + user_id: userId, + external_id: userInfo.id, + friend_sync: params.friend_sync, + name: userInfo.name, + type: this.id, + }); + } +} diff --git a/src/connections/GitHub/GitHubSettings.ts b/src/connections/GitHub/GitHubSettings.ts new file mode 100644 index 00000000..1b4070d2 --- /dev/null +++ b/src/connections/GitHub/GitHubSettings.ts @@ -0,0 +1,5 @@ +export class GitHubSettings { + enabled: boolean = false; + clientId: string | null = null; + clientSecret: string | null = null; +} diff --git a/src/connections/GitHub/index.ts b/src/connections/GitHub/index.ts new file mode 100644 index 00000000..638a34af --- /dev/null +++ b/src/connections/GitHub/index.ts @@ -0,0 +1,106 @@ +import { + ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, + ConnectionCallbackSchema, + ConnectionLoader, + DiscordApiErrors, +} from "@spacebar/util"; +import wretch from "wretch"; +import Connection from "../../util/connections/Connection"; +import { GitHubSettings } from "./GitHubSettings"; + +interface UserResponse { + login: string; + id: number; + name: string; +} + +export default class GitHubConnection extends Connection { + public readonly id = "github"; + public readonly authorizeUrl = "https://github.com/login/oauth/authorize"; + public readonly tokenUrl = "https://github.com/login/oauth/access_token"; + public readonly userInfoUrl = "https://api.github.com/user"; + public readonly scopes = ["read:user"]; + settings: GitHubSettings = new GitHubSettings(); + + init(): void { + this.settings = ConnectionLoader.getConnectionConfig( + this.id, + this.settings, + ) as GitHubSettings; + } + + getAuthorizationUrl(userId: string): string { + const state = this.createState(userId); + const url = new URL(this.authorizeUrl); + + url.searchParams.append("client_id", this.settings.clientId!); + url.searchParams.append("redirect_uri", this.getRedirectUri()); + url.searchParams.append("scope", this.scopes.join(" ")); + url.searchParams.append("state", state); + return url.toString(); + } + + getTokenUrl(code: string): string { + const url = new URL(this.tokenUrl); + url.searchParams.append("client_id", this.settings.clientId!); + url.searchParams.append("client_secret", this.settings.clientSecret!); + url.searchParams.append("code", code); + return url.toString(); + } + + async exchangeCode( + state: string, + code: string, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + this.validateState(state); + + const url = this.getTokenUrl(code); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + }) + + .post() + .json<ConnectedAccountCommonOAuthTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async getUser(token: string): Promise<UserResponse> { + const url = new URL(this.userInfoUrl); + return wretch(url.toString()) + .headers({ + Authorization: `Bearer ${token}`, + }) + .get() + .json<UserResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async handleCallback( + params: ConnectionCallbackSchema, + ): Promise<ConnectedAccount | null> { + const userId = this.getUserId(params.state); + const tokenData = await this.exchangeCode(params.state, params.code!); + const userInfo = await this.getUser(tokenData.access_token); + + const exists = await this.hasConnection(userId, userInfo.id.toString()); + + if (exists) return null; + + return await this.createConnection({ + user_id: userId, + external_id: userInfo.id.toString(), + friend_sync: params.friend_sync, + name: userInfo.login, + type: this.id, + }); + } +} diff --git a/src/connections/Reddit/RedditSettings.ts b/src/connections/Reddit/RedditSettings.ts new file mode 100644 index 00000000..13208fb5 --- /dev/null +++ b/src/connections/Reddit/RedditSettings.ts @@ -0,0 +1,5 @@ +export class RedditSettings { + enabled: boolean = false; + clientId: string | null = null; + clientSecret: string | null = null; +} diff --git a/src/connections/Reddit/index.ts b/src/connections/Reddit/index.ts new file mode 100644 index 00000000..2f3418c0 --- /dev/null +++ b/src/connections/Reddit/index.ts @@ -0,0 +1,128 @@ +import { + ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, + ConnectionCallbackSchema, + ConnectionLoader, + DiscordApiErrors, +} from "@spacebar/util"; +import wretch from "wretch"; +import Connection from "../../util/connections/Connection"; +import { RedditSettings } from "./RedditSettings"; + +export interface UserResponse { + verified: boolean; + coins: number; + id: string; + is_mod: boolean; + has_verified_email: boolean; + total_karma: number; + name: string; + created: number; + gold_creddits: number; + created_utc: number; +} + +export interface ErrorResponse { + message: string; + error: number; +} + +export default class RedditConnection extends Connection { + public readonly id = "reddit"; + public readonly authorizeUrl = "https://www.reddit.com/api/v1/authorize"; + public readonly tokenUrl = "https://www.reddit.com/api/v1/access_token"; + public readonly userInfoUrl = "https://oauth.reddit.com/api/v1/me"; + public readonly scopes = ["identity"]; + settings: RedditSettings = new RedditSettings(); + + init(): void { + this.settings = ConnectionLoader.getConnectionConfig( + this.id, + this.settings, + ) as RedditSettings; + } + + getAuthorizationUrl(userId: string): string { + const state = this.createState(userId); + const url = new URL(this.authorizeUrl); + + url.searchParams.append("client_id", this.settings.clientId!); + url.searchParams.append("redirect_uri", this.getRedirectUri()); + url.searchParams.append("response_type", "code"); + url.searchParams.append("scope", this.scopes.join(" ")); + url.searchParams.append("state", state); + return url.toString(); + } + + getTokenUrl(): string { + return this.tokenUrl; + } + + async exchangeCode( + state: string, + code: string, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + this.validateState(state); + + const url = this.getTokenUrl(); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + Authorization: `Basic ${Buffer.from( + `${this.settings.clientId}:${this.settings.clientSecret}`, + ).toString("base64")}`, + "Content-Type": "application/x-www-form-urlencoded", + }) + .body( + new URLSearchParams({ + grant_type: "authorization_code", + code: code, + redirect_uri: this.getRedirectUri(), + }), + ) + .post() + .json<ConnectedAccountCommonOAuthTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async getUser(token: string): Promise<UserResponse> { + const url = new URL(this.userInfoUrl); + return wretch(url.toString()) + .headers({ + Authorization: `Bearer ${token}`, + }) + .get() + .json<UserResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async handleCallback( + params: ConnectionCallbackSchema, + ): Promise<ConnectedAccount | null> { + const userId = this.getUserId(params.state); + const tokenData = await this.exchangeCode(params.state, params.code!); + const userInfo = await this.getUser(tokenData.access_token); + + const exists = await this.hasConnection(userId, userInfo.id.toString()); + + if (exists) return null; + + // TODO: connection metadata + + return await this.createConnection({ + user_id: userId, + external_id: userInfo.id.toString(), + friend_sync: params.friend_sync, + name: userInfo.name, + verified: userInfo.has_verified_email, + type: this.id, + }); + } +} diff --git a/src/connections/Spotify/SpotifySettings.ts b/src/connections/Spotify/SpotifySettings.ts new file mode 100644 index 00000000..e73c0304 --- /dev/null +++ b/src/connections/Spotify/SpotifySettings.ts @@ -0,0 +1,5 @@ +export class SpotifySettings { + enabled: boolean = false; + clientId: string | null = null; + clientSecret: string | null = null; +} diff --git a/src/connections/Spotify/index.ts b/src/connections/Spotify/index.ts new file mode 100644 index 00000000..c9517b1a --- /dev/null +++ b/src/connections/Spotify/index.ts @@ -0,0 +1,171 @@ +import { + ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, + ConnectionCallbackSchema, + ConnectionLoader, + DiscordApiErrors, +} from "@spacebar/util"; +import wretch from "wretch"; +import RefreshableConnection from "../../util/connections/RefreshableConnection"; +import { SpotifySettings } from "./SpotifySettings"; + +export interface UserResponse { + display_name: string; + id: string; +} + +export interface TokenErrorResponse { + error: string; + error_description: string; +} + +export interface ErrorResponse { + error: { + status: number; + message: string; + }; +} + +export default class SpotifyConnection extends RefreshableConnection { + public readonly id = "spotify"; + public readonly authorizeUrl = "https://accounts.spotify.com/authorize"; + public readonly tokenUrl = "https://accounts.spotify.com/api/token"; + public readonly userInfoUrl = "https://api.spotify.com/v1/me"; + public readonly scopes = [ + "user-read-private", + "user-read-playback-state", + "user-modify-playback-state", + "user-read-currently-playing", + ]; + settings: SpotifySettings = new SpotifySettings(); + + init(): void { + /** + * The way Discord shows the currently playing song is by using Spotifys partner API. This is obviously not possible for us. + * So to prevent spamming the spotify api we disable the ability to refresh. + */ + this.refreshEnabled = false; + this.settings = ConnectionLoader.getConnectionConfig( + this.id, + this.settings, + ) as SpotifySettings; + } + + getAuthorizationUrl(userId: string): string { + const state = this.createState(userId); + const url = new URL(this.authorizeUrl); + + url.searchParams.append("client_id", this.settings.clientId!); + url.searchParams.append("redirect_uri", this.getRedirectUri()); + url.searchParams.append("response_type", "code"); + url.searchParams.append("scope", this.scopes.join(" ")); + url.searchParams.append("state", state); + return url.toString(); + } + + getTokenUrl(): string { + return this.tokenUrl; + } + + async exchangeCode( + state: string, + code: string, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + this.validateState(state); + + const url = this.getTokenUrl(); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${Buffer.from( + `${this.settings.clientId!}:${this.settings.clientSecret!}`, + ).toString("base64")}`, + }) + .body( + new URLSearchParams({ + grant_type: "authorization_code", + code: code, + redirect_uri: this.getRedirectUri(), + }), + ) + .post() + .json<ConnectedAccountCommonOAuthTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async refreshToken( + connectedAccount: ConnectedAccount, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + if (!connectedAccount.token_data?.refresh_token) + throw new Error("No refresh token available."); + const refresh_token = connectedAccount.token_data.refresh_token; + const url = this.getTokenUrl(); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${Buffer.from( + `${this.settings.clientId!}:${this.settings.clientSecret!}`, + ).toString("base64")}`, + }) + .body( + new URLSearchParams({ + grant_type: "refresh_token", + refresh_token, + }), + ) + .post() + .unauthorized(async () => { + // assume the token was revoked + await connectedAccount.revoke(); + return DiscordApiErrors.CONNECTION_REVOKED; + }) + .json<ConnectedAccountCommonOAuthTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async getUser(token: string): Promise<UserResponse> { + const url = new URL(this.userInfoUrl); + + return wretch(url.toString()) + .headers({ + Authorization: `Bearer ${token}`, + }) + .get() + .json<UserResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async handleCallback( + params: ConnectionCallbackSchema, + ): Promise<ConnectedAccount | null> { + const userId = this.getUserId(params.state); + const tokenData = await this.exchangeCode(params.state, params.code!); + const userInfo = await this.getUser(tokenData.access_token); + + const exists = await this.hasConnection(userId, userInfo.id); + + if (exists) return null; + + return await this.createConnection({ + token_data: { ...tokenData, fetched_at: Date.now() }, + user_id: userId, + external_id: userInfo.id, + friend_sync: params.friend_sync, + name: userInfo.display_name, + type: this.id, + }); + } +} diff --git a/src/connections/Twitch/TwitchSettings.ts b/src/connections/Twitch/TwitchSettings.ts new file mode 100644 index 00000000..eb732c82 --- /dev/null +++ b/src/connections/Twitch/TwitchSettings.ts @@ -0,0 +1,5 @@ +export class TwitchSettings { + enabled: boolean = false; + clientId: string | null = null; + clientSecret: string | null = null; +} diff --git a/src/connections/Twitch/index.ts b/src/connections/Twitch/index.ts new file mode 100644 index 00000000..d53215f1 --- /dev/null +++ b/src/connections/Twitch/index.ts @@ -0,0 +1,163 @@ +import { + ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, + ConnectionCallbackSchema, + ConnectionLoader, + DiscordApiErrors, +} from "@spacebar/util"; +import wretch from "wretch"; +import RefreshableConnection from "../../util/connections/RefreshableConnection"; +import { TwitchSettings } from "./TwitchSettings"; + +interface TwitchConnectionUserResponse { + data: { + id: string; + login: string; + display_name: string; + type: string; + broadcaster_type: string; + description: string; + profile_image_url: string; + offline_image_url: string; + view_count: number; + created_at: string; + }[]; +} + +export default class TwitchConnection extends RefreshableConnection { + public readonly id = "twitch"; + public readonly authorizeUrl = "https://id.twitch.tv/oauth2/authorize"; + public readonly tokenUrl = "https://id.twitch.tv/oauth2/token"; + public readonly userInfoUrl = "https://api.twitch.tv/helix/users"; + public readonly scopes = [ + "channel_subscriptions", + "channel_check_subscription", + "channel:read:subscriptions", + ]; + settings: TwitchSettings = new TwitchSettings(); + + init(): void { + this.settings = ConnectionLoader.getConnectionConfig( + this.id, + this.settings, + ) as TwitchSettings; + } + + getAuthorizationUrl(userId: string): string { + const state = this.createState(userId); + const url = new URL(this.authorizeUrl); + + url.searchParams.append("client_id", this.settings.clientId!); + url.searchParams.append("redirect_uri", this.getRedirectUri()); + url.searchParams.append("response_type", "code"); + url.searchParams.append("scope", this.scopes.join(" ")); + url.searchParams.append("state", state); + return url.toString(); + } + + getTokenUrl(): string { + return this.tokenUrl; + } + + async exchangeCode( + state: string, + code: string, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + this.validateState(state); + + const url = this.getTokenUrl(); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }) + .body( + new URLSearchParams({ + grant_type: "authorization_code", + code: code, + client_id: this.settings.clientId!, + client_secret: this.settings.clientSecret!, + redirect_uri: this.getRedirectUri(), + }), + ) + .post() + .json<ConnectedAccountCommonOAuthTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async refreshToken( + connectedAccount: ConnectedAccount, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + if (!connectedAccount.token_data?.refresh_token) + throw new Error("No refresh token available."); + const refresh_token = connectedAccount.token_data.refresh_token; + + const url = this.getTokenUrl(); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }) + .body( + new URLSearchParams({ + grant_type: "refresh_token", + client_id: this.settings.clientId!, + client_secret: this.settings.clientSecret!, + refresh_token: refresh_token, + }), + ) + .post() + .unauthorized(async () => { + // assume the token was revoked + await connectedAccount.revoke(); + return DiscordApiErrors.CONNECTION_REVOKED; + }) + .json<ConnectedAccountCommonOAuthTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async getUser(token: string): Promise<TwitchConnectionUserResponse> { + const url = new URL(this.userInfoUrl); + + return wretch(url.toString()) + .headers({ + Authorization: `Bearer ${token}`, + "Client-Id": this.settings.clientId!, + }) + .get() + .json<TwitchConnectionUserResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async handleCallback( + params: ConnectionCallbackSchema, + ): Promise<ConnectedAccount | null> { + const userId = this.getUserId(params.state); + const tokenData = await this.exchangeCode(params.state, params.code!); + const userInfo = await this.getUser(tokenData.access_token); + + const exists = await this.hasConnection(userId, userInfo.data[0].id); + + if (exists) return null; + + return await this.createConnection({ + token_data: { ...tokenData, fetched_at: Date.now() }, + user_id: userId, + external_id: userInfo.data[0].id, + friend_sync: params.friend_sync, + name: userInfo.data[0].display_name, + type: this.id, + }); + } +} diff --git a/src/connections/Twitter/TwitterSettings.ts b/src/connections/Twitter/TwitterSettings.ts new file mode 100644 index 00000000..e4aa58eb --- /dev/null +++ b/src/connections/Twitter/TwitterSettings.ts @@ -0,0 +1,5 @@ +export class TwitterSettings { + enabled: boolean = false; + clientId: string | null = null; + clientSecret: string | null = null; +} diff --git a/src/connections/Twitter/index.ts b/src/connections/Twitter/index.ts new file mode 100644 index 00000000..f8eac894 --- /dev/null +++ b/src/connections/Twitter/index.ts @@ -0,0 +1,165 @@ +import { + ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, + ConnectionCallbackSchema, + ConnectionLoader, + DiscordApiErrors, +} from "@spacebar/util"; +import wretch from "wretch"; +import RefreshableConnection from "../../util/connections/RefreshableConnection"; +import { TwitterSettings } from "./TwitterSettings"; + +interface TwitterUserResponse { + data: { + id: string; + name: string; + username: string; + created_at: string; + location: string; + url: string; + description: string; + verified: string; + }; +} + +interface TwitterErrorResponse { + error: string; + error_description: string; +} + +export default class TwitterConnection extends RefreshableConnection { + public readonly id = "twitter"; + public readonly authorizeUrl = "https://twitter.com/i/oauth2/authorize"; + public readonly tokenUrl = "https://api.twitter.com/2/oauth2/token"; + public readonly userInfoUrl = + "https://api.twitter.com/2/users/me?user.fields=created_at%2Cdescription%2Cid%2Cname%2Cusername%2Cverified%2Clocation%2Curl"; + public readonly scopes = ["users.read", "tweet.read"]; + settings: TwitterSettings = new TwitterSettings(); + + init(): void { + this.settings = ConnectionLoader.getConnectionConfig( + this.id, + this.settings, + ) as TwitterSettings; + } + + getAuthorizationUrl(userId: string): string { + const state = this.createState(userId); + const url = new URL(this.authorizeUrl); + + url.searchParams.append("client_id", this.settings.clientId!); + url.searchParams.append("redirect_uri", this.getRedirectUri()); + url.searchParams.append("response_type", "code"); + url.searchParams.append("scope", this.scopes.join(" ")); + url.searchParams.append("state", state); + url.searchParams.append("code_challenge", "challenge"); // TODO: properly use PKCE challenge + url.searchParams.append("code_challenge_method", "plain"); + return url.toString(); + } + + getTokenUrl(): string { + return this.tokenUrl; + } + + async exchangeCode( + state: string, + code: string, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + this.validateState(state); + + const url = this.getTokenUrl(); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${Buffer.from( + `${this.settings.clientId!}:${this.settings.clientSecret!}`, + ).toString("base64")}`, + }) + .body( + new URLSearchParams({ + grant_type: "authorization_code", + code: code, + client_id: this.settings.clientId!, + redirect_uri: this.getRedirectUri(), + code_verifier: "challenge", // TODO: properly use PKCE challenge + }), + ) + .post() + .json<ConnectedAccountCommonOAuthTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async refreshToken( + connectedAccount: ConnectedAccount, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + if (!connectedAccount.token_data?.refresh_token) + throw new Error("No refresh token available."); + const refresh_token = connectedAccount.token_data.refresh_token; + + const url = this.getTokenUrl(); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${Buffer.from( + `${this.settings.clientId!}:${this.settings.clientSecret!}`, + ).toString("base64")}`, + }) + .body( + new URLSearchParams({ + grant_type: "refresh_token", + refresh_token, + client_id: this.settings.clientId!, + redirect_uri: this.getRedirectUri(), + code_verifier: "challenge", // TODO: properly use PKCE challenge + }), + ) + .post() + .json<ConnectedAccountCommonOAuthTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async getUser(token: string): Promise<TwitterUserResponse> { + const url = new URL(this.userInfoUrl); + return wretch(url.toString()) + .headers({ + Authorization: `Bearer ${token}`, + }) + .get() + .json<TwitterUserResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async handleCallback( + params: ConnectionCallbackSchema, + ): Promise<ConnectedAccount | null> { + const userId = this.getUserId(params.state); + const tokenData = await this.exchangeCode(params.state, params.code!); + const userInfo = await this.getUser(tokenData.access_token); + + const exists = await this.hasConnection(userId, userInfo.data.id); + + if (exists) return null; + + return await this.createConnection({ + token_data: { ...tokenData, fetched_at: Date.now() }, + user_id: userId, + external_id: userInfo.data.id, + friend_sync: params.friend_sync, + name: userInfo.data.name, + type: this.id, + }); + } +} diff --git a/src/connections/Xbox/XboxSettings.ts b/src/connections/Xbox/XboxSettings.ts new file mode 100644 index 00000000..c1a41056 --- /dev/null +++ b/src/connections/Xbox/XboxSettings.ts @@ -0,0 +1,5 @@ +export class XboxSettings { + enabled: boolean = false; + clientId: string | null = null; + clientSecret: string | null = null; +} diff --git a/src/connections/Xbox/index.ts b/src/connections/Xbox/index.ts new file mode 100644 index 00000000..011f87a8 --- /dev/null +++ b/src/connections/Xbox/index.ts @@ -0,0 +1,180 @@ +import { + ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, + ConnectionCallbackSchema, + ConnectionLoader, + DiscordApiErrors, +} from "@spacebar/util"; +import wretch from "wretch"; +import Connection from "../../util/connections/Connection"; +import { XboxSettings } from "./XboxSettings"; + +interface XboxUserResponse { + IssueInstant: string; + NotAfter: string; + Token: string; + DisplayClaims: { + xui: { + gtg: string; + xid: string; + uhs: string; + agg: string; + usr: string; + utr: string; + prv: string; + }[]; + }; +} + +interface XboxErrorResponse { + error: string; + error_description: string; +} + +export default class XboxConnection extends Connection { + public readonly id = "xbox"; + public readonly authorizeUrl = + "https://login.live.com/oauth20_authorize.srf"; + public readonly tokenUrl = "https://login.live.com/oauth20_token.srf"; + public readonly userInfoUrl = + "https://xsts.auth.xboxlive.com/xsts/authorize"; + public readonly userAuthUrl = + "https://user.auth.xboxlive.com/user/authenticate"; + public readonly scopes = ["Xboxlive.signin", "Xboxlive.offline_access"]; + settings: XboxSettings = new XboxSettings(); + + init(): void { + this.settings = ConnectionLoader.getConnectionConfig( + this.id, + this.settings, + ) as XboxSettings; + } + + getAuthorizationUrl(userId: string): string { + const state = this.createState(userId); + const url = new URL(this.authorizeUrl); + + url.searchParams.append("client_id", this.settings.clientId!); + url.searchParams.append("redirect_uri", this.getRedirectUri()); + url.searchParams.append("response_type", "code"); + url.searchParams.append("scope", this.scopes.join(" ")); + url.searchParams.append("state", state); + url.searchParams.append("approval_prompt", "auto"); + return url.toString(); + } + + getTokenUrl(): string { + return this.tokenUrl; + } + + async getUserToken(token: string): Promise<string> { + return wretch(this.userAuthUrl) + .headers({ + "x-xbl-contract-version": "3", + "Content-Type": "application/json", + Accept: "application/json", + }) + .body( + JSON.stringify({ + RelyingParty: "http://auth.xboxlive.com", + TokenType: "JWT", + Properties: { + AuthMethod: "RPS", + SiteName: "user.auth.xboxlive.com", + RpsTicket: `d=${token}`, + }, + }), + ) + .post() + .json((res: XboxUserResponse) => res.Token) + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async exchangeCode( + state: string, + code: string, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + this.validateState(state); + + const url = this.getTokenUrl(); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${Buffer.from( + `${this.settings.clientId!}:${this.settings.clientSecret!}`, + ).toString("base64")}`, + }) + .body( + new URLSearchParams({ + grant_type: "authorization_code", + code: code, + client_id: this.settings.clientId!, + redirect_uri: this.getRedirectUri(), + scope: this.scopes.join(" "), + }), + ) + .post() + .json<ConnectedAccountCommonOAuthTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async getUser(token: string): Promise<XboxUserResponse> { + const url = new URL(this.userInfoUrl); + + return wretch(url.toString()) + .headers({ + "x-xbl-contract-version": "3", + "Content-Type": "application/json", + Accept: "application/json", + }) + .body( + JSON.stringify({ + RelyingParty: "http://xboxlive.com", + TokenType: "JWT", + Properties: { + UserTokens: [token], + SandboxId: "RETAIL", + }, + }), + ) + .post() + .json<XboxUserResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async handleCallback( + params: ConnectionCallbackSchema, + ): Promise<ConnectedAccount | null> { + const userId = this.getUserId(params.state); + const tokenData = await this.exchangeCode(params.state, params.code!); + const userToken = await this.getUserToken(tokenData.access_token); + const userInfo = await this.getUser(userToken); + + const exists = await this.hasConnection( + userId, + userInfo.DisplayClaims.xui[0].xid, + ); + + if (exists) return null; + + return await this.createConnection({ + token_data: { ...tokenData, fetched_at: Date.now() }, + user_id: userId, + external_id: userInfo.DisplayClaims.xui[0].xid, + friend_sync: params.friend_sync, + name: userInfo.DisplayClaims.xui[0].gtg, + type: this.id, + }); + } +} diff --git a/src/connections/Youtube/YoutubeSettings.ts b/src/connections/Youtube/YoutubeSettings.ts new file mode 100644 index 00000000..5d11fa40 --- /dev/null +++ b/src/connections/Youtube/YoutubeSettings.ts @@ -0,0 +1,5 @@ +export class YoutubeSettings { + enabled: boolean = false; + clientId: string | null = null; + clientSecret: string | null = null; +} diff --git a/src/connections/Youtube/index.ts b/src/connections/Youtube/index.ts new file mode 100644 index 00000000..4d90e452 --- /dev/null +++ b/src/connections/Youtube/index.ts @@ -0,0 +1,133 @@ +import { + ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, + ConnectionCallbackSchema, + ConnectionLoader, + DiscordApiErrors, +} from "@spacebar/util"; +import wretch from "wretch"; +import Connection from "../../util/connections/Connection"; +import { YoutubeSettings } from "./YoutubeSettings"; + +interface YouTubeConnectionChannelListResult { + items: { + snippet: { + // thumbnails: Thumbnails; + title: string; + country: string; + publishedAt: string; + // localized: Localized; + description: string; + }; + kind: string; + etag: string; + id: string; + }[]; + kind: string; + etag: string; + pageInfo: { + resultsPerPage: number; + totalResults: number; + }; +} + +export default class YoutubeConnection extends Connection { + public readonly id = "youtube"; + public readonly authorizeUrl = + "https://accounts.google.com/o/oauth2/v2/auth"; + public readonly tokenUrl = "https://oauth2.googleapis.com/token"; + public readonly userInfoUrl = + "https://www.googleapis.com/youtube/v3/channels?mine=true&part=snippet"; + public readonly scopes = [ + "https://www.googleapis.com/auth/youtube.readonly", + ]; + settings: YoutubeSettings = new YoutubeSettings(); + + init(): void { + this.settings = ConnectionLoader.getConnectionConfig( + this.id, + this.settings, + ) as YoutubeSettings; + } + + getAuthorizationUrl(userId: string): string { + const state = this.createState(userId); + const url = new URL(this.authorizeUrl); + + url.searchParams.append("client_id", this.settings.clientId!); + url.searchParams.append("redirect_uri", this.getRedirectUri()); + url.searchParams.append("response_type", "code"); + url.searchParams.append("scope", this.scopes.join(" ")); + url.searchParams.append("state", state); + return url.toString(); + } + + getTokenUrl(): string { + return this.tokenUrl; + } + + async exchangeCode( + state: string, + code: string, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + this.validateState(state); + + const url = this.getTokenUrl(); + + return wretch(url.toString()) + .headers({ + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }) + .body( + new URLSearchParams({ + grant_type: "authorization_code", + code: code, + client_id: this.settings.clientId!, + client_secret: this.settings.clientSecret!, + redirect_uri: this.getRedirectUri(), + }), + ) + .post() + .json<ConnectedAccountCommonOAuthTokenResponse>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async getUser(token: string): Promise<YouTubeConnectionChannelListResult> { + const url = new URL(this.userInfoUrl); + return wretch(url.toString()) + .headers({ + Authorization: `Bearer ${token}`, + }) + .get() + .json<YouTubeConnectionChannelListResult>() + .catch((e) => { + console.error(e); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async handleCallback( + params: ConnectionCallbackSchema, + ): Promise<ConnectedAccount | null> { + const userId = this.getUserId(params.state); + const tokenData = await this.exchangeCode(params.state, params.code!); + const userInfo = await this.getUser(tokenData.access_token); + + const exists = await this.hasConnection(userId, userInfo.items[0].id); + + if (exists) return null; + + return await this.createConnection({ + token_data: { ...tokenData, fetched_at: Date.now() }, + user_id: userId, + external_id: userInfo.items[0].id, + friend_sync: params.friend_sync, + name: userInfo.items[0].snippet.title, + type: this.id, + }); + } +} diff --git a/src/gateway/opcodes/Identify.ts b/src/gateway/opcodes/Identify.ts index d3b55f0a..de3b3cfe 100644 --- a/src/gateway/opcodes/Identify.ts +++ b/src/gateway/opcodes/Identify.ts @@ -43,6 +43,7 @@ import { ReadyGuildDTO, Guild, UserTokenData, + ConnectedAccount, } from "@spacebar/util"; import { Send } from "../util/Send"; import { CLOSECODES, OPCODES } from "../util/Constants"; @@ -78,52 +79,60 @@ export async function onIdentify(this: WebSocket, data: Payload) { this.user_id = decoded.id; const session_id = this.session_id; - const [user, read_states, members, recipients, session, application] = - await Promise.all([ - User.findOneOrFail({ - where: { id: this.user_id }, - relations: ["relationships", "relationships.to", "settings"], - select: [...PrivateUserProjection, "relationships"], - }), - ReadState.find({ where: { user_id: this.user_id } }), - Member.find({ - where: { id: this.user_id }, - select: MemberPrivateProjection, - relations: [ - "guild", - "guild.channels", - "guild.emojis", - "guild.roles", - "guild.stickers", - "user", - "roles", - ], - }), - Recipient.find({ - where: { user_id: this.user_id, closed: false }, - relations: [ - "channel", - "channel.recipients", - "channel.recipients.user", - ], - // TODO: public user selection - }), - // save the session and delete it when the websocket is closed - Session.create({ - user_id: this.user_id, - session_id: session_id, - // TODO: check if status is only one of: online, dnd, offline, idle - status: identify.presence?.status || "offline", //does the session always start as online? - client_info: { - //TODO read from identity - client: "desktop", - os: identify.properties?.os, - version: 0, - }, - activities: [], - }).save(), - Application.findOne({ where: { id: this.user_id } }), - ]); + const [ + user, + read_states, + members, + recipients, + session, + application, + connected_accounts, + ] = await Promise.all([ + User.findOneOrFail({ + where: { id: this.user_id }, + relations: ["relationships", "relationships.to", "settings"], + select: [...PrivateUserProjection, "relationships"], + }), + ReadState.find({ where: { user_id: this.user_id } }), + Member.find({ + where: { id: this.user_id }, + select: MemberPrivateProjection, + relations: [ + "guild", + "guild.channels", + "guild.emojis", + "guild.roles", + "guild.stickers", + "user", + "roles", + ], + }), + Recipient.find({ + where: { user_id: this.user_id, closed: false }, + relations: [ + "channel", + "channel.recipients", + "channel.recipients.user", + ], + // TODO: public user selection + }), + // save the session and delete it when the websocket is closed + Session.create({ + user_id: this.user_id, + session_id: session_id, + // TODO: check if status is only one of: online, dnd, offline, idle + status: identify.presence?.status || "offline", //does the session always start as online? + client_info: { + //TODO read from identity + client: "desktop", + os: identify.properties?.os, + version: 0, + }, + activities: [], + }).save(), + Application.findOne({ where: { id: this.user_id } }), + ConnectedAccount.find({ where: { user_id: this.user_id } }), + ]); if (!user) return this.close(CLOSECODES.Authentication_failed); if (!user.settings) { @@ -304,7 +313,7 @@ export async function onIdentify(this: WebSocket, data: Payload) { private_channels: channels, session_id: session_id, analytics_token: "", // TODO - connected_accounts: [], // TODO + connected_accounts, consents: { personalization: { consented: false, // TODO 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, |