Merge branch 'master' of github.com:spacebarchat/server

This commit is contained in:
Madeline 2023-08-07 16:47:01 +10:00
commit a25a62aa76
No known key found for this signature in database
GPG Key ID: 80D25DA3BCB24281
23 changed files with 205 additions and 122 deletions

View File

@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { checkToken, Config, Rights } from "@spacebar/util"; import { checkToken, Rights } from "@spacebar/util";
import * as Sentry from "@sentry/node"; import * as Sentry from "@sentry/node";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import { HTTPError } from "lambert-server"; import { HTTPError } from "lambert-server";

View File

@ -19,7 +19,6 @@
import { route } from "@spacebar/api"; import { route } from "@spacebar/api";
import { import {
checkToken, checkToken,
Config,
Email, Email,
FieldErrors, FieldErrors,
generateToken, generateToken,

View File

@ -22,7 +22,7 @@ const router = Router();
router.post("/", route({}), async (req: Request, res: Response) => { router.post("/", route({}), async (req: Request, res: Response) => {
// TODO: // TODO:
const { connection_name, connection_id } = req.params; // const { connection_name, connection_id } = req.params;
res.sendStatus(204); res.sendStatus(204);
}); });

View File

@ -23,9 +23,9 @@ import {
ConnectionStore, ConnectionStore,
DiscordApiErrors, DiscordApiErrors,
FieldErrors, FieldErrors,
RefreshableConnection,
} from "@spacebar/util"; } from "@spacebar/util";
import { Request, Response, Router } from "express"; import { Request, Response, Router } from "express";
import RefreshableConnection from "../../../../../../../util/connections/RefreshableConnection";
const router = Router(); const router = Router();
// TODO: this route is only used for spotify, twitch, and youtube. (battlenet seems to be able to PUT, maybe others also) // TODO: this route is only used for spotify, twitch, and youtube. (battlenet seems to be able to PUT, maybe others also)

View File

@ -19,12 +19,12 @@
import { import {
ConnectedAccount, ConnectedAccount,
ConnectedAccountCommonOAuthTokenResponse, ConnectedAccountCommonOAuthTokenResponse,
Connection,
ConnectionCallbackSchema, ConnectionCallbackSchema,
ConnectionLoader, ConnectionLoader,
DiscordApiErrors, DiscordApiErrors,
} from "@spacebar/util"; } from "@spacebar/util";
import wretch from "wretch"; import wretch from "wretch";
import Connection from "../../util/connections/Connection";
import { BattleNetSettings } from "./BattleNetSettings"; import { BattleNetSettings } from "./BattleNetSettings";
interface BattleNetConnectionUser { interface BattleNetConnectionUser {
@ -33,10 +33,10 @@ interface BattleNetConnectionUser {
battletag: string; battletag: string;
} }
interface BattleNetErrorResponse { // interface BattleNetErrorResponse {
error: string; // error: string;
error_description: string; // error_description: string;
} // }
export default class BattleNetConnection extends Connection { export default class BattleNetConnection extends Connection {
public readonly id = "battlenet"; public readonly id = "battlenet";
@ -47,17 +47,21 @@ export default class BattleNetConnection extends Connection {
settings: BattleNetSettings = new BattleNetSettings(); settings: BattleNetSettings = new BattleNetSettings();
init(): void { init(): void {
this.settings = ConnectionLoader.getConnectionConfig( const settings =
this.id, ConnectionLoader.getConnectionConfig<BattleNetSettings>(
this.settings, this.id,
) as BattleNetSettings; this.settings,
);
if (settings.enabled && (!settings.clientId || !settings.clientSecret))
throw new Error(`Invalid settings for connection ${this.id}`);
} }
getAuthorizationUrl(userId: string): string { getAuthorizationUrl(userId: string): string {
const state = this.createState(userId); const state = this.createState(userId);
const url = new URL(this.authorizeUrl); const url = new URL(this.authorizeUrl);
url.searchParams.append("client_id", this.settings.clientId!); url.searchParams.append("client_id", this.settings.clientId as string);
url.searchParams.append("redirect_uri", this.getRedirectUri()); url.searchParams.append("redirect_uri", this.getRedirectUri());
url.searchParams.append("scope", this.scopes.join(" ")); url.searchParams.append("scope", this.scopes.join(" "));
url.searchParams.append("state", state); url.searchParams.append("state", state);
@ -85,8 +89,8 @@ export default class BattleNetConnection extends Connection {
new URLSearchParams({ new URLSearchParams({
grant_type: "authorization_code", grant_type: "authorization_code",
code: code, code: code,
client_id: this.settings.clientId!, client_id: this.settings.clientId as string,
client_secret: this.settings.clientSecret!, client_secret: this.settings.clientSecret as string,
redirect_uri: this.getRedirectUri(), redirect_uri: this.getRedirectUri(),
}), }),
) )
@ -115,8 +119,11 @@ export default class BattleNetConnection extends Connection {
async handleCallback( async handleCallback(
params: ConnectionCallbackSchema, params: ConnectionCallbackSchema,
): Promise<ConnectedAccount | null> { ): Promise<ConnectedAccount | null> {
const userId = this.getUserId(params.state); const { state, code } = params;
const tokenData = await this.exchangeCode(params.state, params.code!); if (!code) throw new Error("No code provided");
const userId = this.getUserId(state);
const tokenData = await this.exchangeCode(state, code);
const userInfo = await this.getUser(tokenData.access_token); const userInfo = await this.getUser(tokenData.access_token);
const exists = await this.hasConnection(userId, userInfo.id.toString()); const exists = await this.hasConnection(userId, userInfo.id.toString());

View File

@ -19,12 +19,12 @@
import { import {
ConnectedAccount, ConnectedAccount,
ConnectedAccountCommonOAuthTokenResponse, ConnectedAccountCommonOAuthTokenResponse,
Connection,
ConnectionCallbackSchema, ConnectionCallbackSchema,
ConnectionLoader, ConnectionLoader,
DiscordApiErrors, DiscordApiErrors,
} from "@spacebar/util"; } from "@spacebar/util";
import wretch from "wretch"; import wretch from "wretch";
import Connection from "../../util/connections/Connection";
import { DiscordSettings } from "./DiscordSettings"; import { DiscordSettings } from "./DiscordSettings";
interface UserResponse { interface UserResponse {
@ -43,10 +43,13 @@ export default class DiscordConnection extends Connection {
settings: DiscordSettings = new DiscordSettings(); settings: DiscordSettings = new DiscordSettings();
init(): void { init(): void {
this.settings = ConnectionLoader.getConnectionConfig( const settings = ConnectionLoader.getConnectionConfig<DiscordSettings>(
this.id, this.id,
this.settings, this.settings,
) as DiscordSettings; );
if (settings.enabled && (!settings.clientId || !settings.clientSecret))
throw new Error(`Invalid settings for connection ${this.id}`);
} }
getAuthorizationUrl(userId: string): string { getAuthorizationUrl(userId: string): string {
@ -54,7 +57,7 @@ export default class DiscordConnection extends Connection {
const url = new URL(this.authorizeUrl); const url = new URL(this.authorizeUrl);
url.searchParams.append("state", state); url.searchParams.append("state", state);
url.searchParams.append("client_id", this.settings.clientId!); url.searchParams.append("client_id", this.settings.clientId as string);
url.searchParams.append("scope", this.scopes.join(" ")); url.searchParams.append("scope", this.scopes.join(" "));
url.searchParams.append("response_type", "code"); url.searchParams.append("response_type", "code");
// controls whether, on repeated authorizations, the consent screen is shown // controls whether, on repeated authorizations, the consent screen is shown
@ -82,8 +85,8 @@ export default class DiscordConnection extends Connection {
}) })
.body( .body(
new URLSearchParams({ new URLSearchParams({
client_id: this.settings.clientId!, client_id: this.settings.clientId as string,
client_secret: this.settings.clientSecret!, client_secret: this.settings.clientSecret as string,
grant_type: "authorization_code", grant_type: "authorization_code",
code: code, code: code,
redirect_uri: this.getRedirectUri(), redirect_uri: this.getRedirectUri(),
@ -114,8 +117,11 @@ export default class DiscordConnection extends Connection {
async handleCallback( async handleCallback(
params: ConnectionCallbackSchema, params: ConnectionCallbackSchema,
): Promise<ConnectedAccount | null> { ): Promise<ConnectedAccount | null> {
const userId = this.getUserId(params.state); const { state, code } = params;
const tokenData = await this.exchangeCode(params.state, params.code!); if (!code) throw new Error("No code provided");
const userId = this.getUserId(state);
const tokenData = await this.exchangeCode(state, code);
const userInfo = await this.getUser(tokenData.access_token); const userInfo = await this.getUser(tokenData.access_token);
const exists = await this.hasConnection(userId, userInfo.id); const exists = await this.hasConnection(userId, userInfo.id);

View File

@ -19,12 +19,12 @@
import { import {
ConnectedAccount, ConnectedAccount,
ConnectedAccountCommonOAuthTokenResponse, ConnectedAccountCommonOAuthTokenResponse,
Connection,
ConnectionCallbackSchema, ConnectionCallbackSchema,
ConnectionLoader, ConnectionLoader,
DiscordApiErrors, DiscordApiErrors,
} from "@spacebar/util"; } from "@spacebar/util";
import wretch from "wretch"; import wretch from "wretch";
import Connection from "../../util/connections/Connection";
import { EpicGamesSettings } from "./EpicGamesSettings"; import { EpicGamesSettings } from "./EpicGamesSettings";
export interface UserResponse { export interface UserResponse {
@ -53,17 +53,21 @@ export default class EpicGamesConnection extends Connection {
settings: EpicGamesSettings = new EpicGamesSettings(); settings: EpicGamesSettings = new EpicGamesSettings();
init(): void { init(): void {
this.settings = ConnectionLoader.getConnectionConfig( const settings =
this.id, ConnectionLoader.getConnectionConfig<EpicGamesSettings>(
this.settings, this.id,
) as EpicGamesSettings; this.settings,
);
if (settings.enabled && (!settings.clientId || !settings.clientSecret))
throw new Error(`Invalid settings for connection ${this.id}`);
} }
getAuthorizationUrl(userId: string): string { getAuthorizationUrl(userId: string): string {
const state = this.createState(userId); const state = this.createState(userId);
const url = new URL(this.authorizeUrl); const url = new URL(this.authorizeUrl);
url.searchParams.append("client_id", this.settings.clientId!); url.searchParams.append("client_id", this.settings.clientId as string);
url.searchParams.append("redirect_uri", this.getRedirectUri()); url.searchParams.append("redirect_uri", this.getRedirectUri());
url.searchParams.append("response_type", "code"); url.searchParams.append("response_type", "code");
url.searchParams.append("scope", this.scopes.join(" ")); url.searchParams.append("scope", this.scopes.join(" "));
@ -127,8 +131,11 @@ export default class EpicGamesConnection extends Connection {
async handleCallback( async handleCallback(
params: ConnectionCallbackSchema, params: ConnectionCallbackSchema,
): Promise<ConnectedAccount | null> { ): Promise<ConnectedAccount | null> {
const userId = this.getUserId(params.state); const { state, code } = params;
const tokenData = await this.exchangeCode(params.state, params.code!); if (!code) throw new Error("No code provided");
const userId = this.getUserId(state);
const tokenData = await this.exchangeCode(state, code);
const userInfo = await this.getUser(tokenData.access_token); const userInfo = await this.getUser(tokenData.access_token);
const exists = await this.hasConnection(userId, userInfo[0].accountId); const exists = await this.hasConnection(userId, userInfo[0].accountId);

View File

@ -19,12 +19,12 @@
import { import {
ConnectedAccount, ConnectedAccount,
ConnectedAccountCommonOAuthTokenResponse, ConnectedAccountCommonOAuthTokenResponse,
Connection,
ConnectionCallbackSchema, ConnectionCallbackSchema,
ConnectionLoader, ConnectionLoader,
DiscordApiErrors, DiscordApiErrors,
} from "@spacebar/util"; } from "@spacebar/util";
import wretch from "wretch"; import wretch from "wretch";
import Connection from "../../util/connections/Connection";
import { FacebookSettings } from "./FacebookSettings"; import { FacebookSettings } from "./FacebookSettings";
export interface FacebookErrorResponse { export interface FacebookErrorResponse {
@ -52,17 +52,20 @@ export default class FacebookConnection extends Connection {
settings: FacebookSettings = new FacebookSettings(); settings: FacebookSettings = new FacebookSettings();
init(): void { init(): void {
this.settings = ConnectionLoader.getConnectionConfig( const settings = ConnectionLoader.getConnectionConfig<FacebookSettings>(
this.id, this.id,
this.settings, this.settings,
) as FacebookSettings; );
if (settings.enabled && (!settings.clientId || !settings.clientSecret))
throw new Error(`Invalid settings for connection ${this.id}`);
} }
getAuthorizationUrl(userId: string): string { getAuthorizationUrl(userId: string): string {
const state = this.createState(userId); const state = this.createState(userId);
const url = new URL(this.authorizeUrl); const url = new URL(this.authorizeUrl);
url.searchParams.append("client_id", this.settings.clientId!); url.searchParams.append("client_id", this.settings.clientId as string);
url.searchParams.append("redirect_uri", this.getRedirectUri()); url.searchParams.append("redirect_uri", this.getRedirectUri());
url.searchParams.append("state", state); url.searchParams.append("state", state);
url.searchParams.append("response_type", "code"); url.searchParams.append("response_type", "code");
@ -73,8 +76,11 @@ export default class FacebookConnection extends Connection {
getTokenUrl(code: string): string { getTokenUrl(code: string): string {
const url = new URL(this.tokenUrl); const url = new URL(this.tokenUrl);
url.searchParams.append("client_id", this.settings.clientId!); url.searchParams.append("client_id", this.settings.clientId as string);
url.searchParams.append("client_secret", this.settings.clientSecret!); url.searchParams.append(
"client_secret",
this.settings.clientSecret as string,
);
url.searchParams.append("code", code); url.searchParams.append("code", code);
url.searchParams.append("redirect_uri", this.getRedirectUri()); url.searchParams.append("redirect_uri", this.getRedirectUri());
return url.toString(); return url.toString();
@ -118,8 +124,11 @@ export default class FacebookConnection extends Connection {
async handleCallback( async handleCallback(
params: ConnectionCallbackSchema, params: ConnectionCallbackSchema,
): Promise<ConnectedAccount | null> { ): Promise<ConnectedAccount | null> {
const userId = this.getUserId(params.state); const { state, code } = params;
const tokenData = await this.exchangeCode(params.state, params.code!); if (!code) throw new Error("No code provided");
const userId = this.getUserId(state);
const tokenData = await this.exchangeCode(state, code);
const userInfo = await this.getUser(tokenData.access_token); const userInfo = await this.getUser(tokenData.access_token);
const exists = await this.hasConnection(userId, userInfo.id); const exists = await this.hasConnection(userId, userInfo.id);

View File

@ -19,12 +19,12 @@
import { import {
ConnectedAccount, ConnectedAccount,
ConnectedAccountCommonOAuthTokenResponse, ConnectedAccountCommonOAuthTokenResponse,
Connection,
ConnectionCallbackSchema, ConnectionCallbackSchema,
ConnectionLoader, ConnectionLoader,
DiscordApiErrors, DiscordApiErrors,
} from "@spacebar/util"; } from "@spacebar/util";
import wretch from "wretch"; import wretch from "wretch";
import Connection from "../../util/connections/Connection";
import { GitHubSettings } from "./GitHubSettings"; import { GitHubSettings } from "./GitHubSettings";
interface UserResponse { interface UserResponse {
@ -42,17 +42,20 @@ export default class GitHubConnection extends Connection {
settings: GitHubSettings = new GitHubSettings(); settings: GitHubSettings = new GitHubSettings();
init(): void { init(): void {
this.settings = ConnectionLoader.getConnectionConfig( const settings = ConnectionLoader.getConnectionConfig<GitHubSettings>(
this.id, this.id,
this.settings, this.settings,
) as GitHubSettings; );
if (settings.enabled && (!settings.clientId || !settings.clientSecret))
throw new Error(`Invalid settings for connection ${this.id}`);
} }
getAuthorizationUrl(userId: string): string { getAuthorizationUrl(userId: string): string {
const state = this.createState(userId); const state = this.createState(userId);
const url = new URL(this.authorizeUrl); const url = new URL(this.authorizeUrl);
url.searchParams.append("client_id", this.settings.clientId!); url.searchParams.append("client_id", this.settings.clientId as string);
url.searchParams.append("redirect_uri", this.getRedirectUri()); url.searchParams.append("redirect_uri", this.getRedirectUri());
url.searchParams.append("scope", this.scopes.join(" ")); url.searchParams.append("scope", this.scopes.join(" "));
url.searchParams.append("state", state); url.searchParams.append("state", state);
@ -61,8 +64,11 @@ export default class GitHubConnection extends Connection {
getTokenUrl(code: string): string { getTokenUrl(code: string): string {
const url = new URL(this.tokenUrl); const url = new URL(this.tokenUrl);
url.searchParams.append("client_id", this.settings.clientId!); url.searchParams.append("client_id", this.settings.clientId as string);
url.searchParams.append("client_secret", this.settings.clientSecret!); url.searchParams.append(
"client_secret",
this.settings.clientSecret as string,
);
url.searchParams.append("code", code); url.searchParams.append("code", code);
return url.toString(); return url.toString();
} }
@ -105,8 +111,11 @@ export default class GitHubConnection extends Connection {
async handleCallback( async handleCallback(
params: ConnectionCallbackSchema, params: ConnectionCallbackSchema,
): Promise<ConnectedAccount | null> { ): Promise<ConnectedAccount | null> {
const userId = this.getUserId(params.state); const { state, code } = params;
const tokenData = await this.exchangeCode(params.state, params.code!); if (!code) throw new Error("No code provided");
const userId = this.getUserId(state);
const tokenData = await this.exchangeCode(state, code);
const userInfo = await this.getUser(tokenData.access_token); const userInfo = await this.getUser(tokenData.access_token);
const exists = await this.hasConnection(userId, userInfo.id.toString()); const exists = await this.hasConnection(userId, userInfo.id.toString());

View File

@ -19,12 +19,12 @@
import { import {
ConnectedAccount, ConnectedAccount,
ConnectedAccountCommonOAuthTokenResponse, ConnectedAccountCommonOAuthTokenResponse,
Connection,
ConnectionCallbackSchema, ConnectionCallbackSchema,
ConnectionLoader, ConnectionLoader,
DiscordApiErrors, DiscordApiErrors,
} from "@spacebar/util"; } from "@spacebar/util";
import wretch from "wretch"; import wretch from "wretch";
import Connection from "../../util/connections/Connection";
import { RedditSettings } from "./RedditSettings"; import { RedditSettings } from "./RedditSettings";
export interface UserResponse { export interface UserResponse {
@ -54,17 +54,20 @@ export default class RedditConnection extends Connection {
settings: RedditSettings = new RedditSettings(); settings: RedditSettings = new RedditSettings();
init(): void { init(): void {
this.settings = ConnectionLoader.getConnectionConfig( const settings = ConnectionLoader.getConnectionConfig<RedditSettings>(
this.id, this.id,
this.settings, this.settings,
) as RedditSettings; );
if (settings.enabled && (!settings.clientId || !settings.clientSecret))
throw new Error(`Invalid settings for connection ${this.id}`);
} }
getAuthorizationUrl(userId: string): string { getAuthorizationUrl(userId: string): string {
const state = this.createState(userId); const state = this.createState(userId);
const url = new URL(this.authorizeUrl); const url = new URL(this.authorizeUrl);
url.searchParams.append("client_id", this.settings.clientId!); url.searchParams.append("client_id", this.settings.clientId as string);
url.searchParams.append("redirect_uri", this.getRedirectUri()); url.searchParams.append("redirect_uri", this.getRedirectUri());
url.searchParams.append("response_type", "code"); url.searchParams.append("response_type", "code");
url.searchParams.append("scope", this.scopes.join(" ")); url.searchParams.append("scope", this.scopes.join(" "));
@ -124,8 +127,11 @@ export default class RedditConnection extends Connection {
async handleCallback( async handleCallback(
params: ConnectionCallbackSchema, params: ConnectionCallbackSchema,
): Promise<ConnectedAccount | null> { ): Promise<ConnectedAccount | null> {
const userId = this.getUserId(params.state); const { state, code } = params;
const tokenData = await this.exchangeCode(params.state, params.code!); if (!code) throw new Error("No code provided");
const userId = this.getUserId(state);
const tokenData = await this.exchangeCode(state, code);
const userInfo = await this.getUser(tokenData.access_token); const userInfo = await this.getUser(tokenData.access_token);
const exists = await this.hasConnection(userId, userInfo.id.toString()); const exists = await this.hasConnection(userId, userInfo.id.toString());

View File

@ -22,9 +22,9 @@ import {
ConnectionCallbackSchema, ConnectionCallbackSchema,
ConnectionLoader, ConnectionLoader,
DiscordApiErrors, DiscordApiErrors,
RefreshableConnection,
} from "@spacebar/util"; } from "@spacebar/util";
import wretch from "wretch"; import wretch from "wretch";
import RefreshableConnection from "../../util/connections/RefreshableConnection";
import { SpotifySettings } from "./SpotifySettings"; import { SpotifySettings } from "./SpotifySettings";
export interface UserResponse { export interface UserResponse {
@ -63,17 +63,20 @@ export default class SpotifyConnection extends RefreshableConnection {
* So to prevent spamming the spotify api we disable the ability to refresh. * So to prevent spamming the spotify api we disable the ability to refresh.
*/ */
this.refreshEnabled = false; this.refreshEnabled = false;
this.settings = ConnectionLoader.getConnectionConfig( const settings = ConnectionLoader.getConnectionConfig<SpotifySettings>(
this.id, this.id,
this.settings, this.settings,
) as SpotifySettings; );
if (settings.enabled && (!settings.clientId || !settings.clientSecret))
throw new Error(`Invalid settings for connection ${this.id}`);
} }
getAuthorizationUrl(userId: string): string { getAuthorizationUrl(userId: string): string {
const state = this.createState(userId); const state = this.createState(userId);
const url = new URL(this.authorizeUrl); const url = new URL(this.authorizeUrl);
url.searchParams.append("client_id", this.settings.clientId!); url.searchParams.append("client_id", this.settings.clientId as string);
url.searchParams.append("redirect_uri", this.getRedirectUri()); url.searchParams.append("redirect_uri", this.getRedirectUri());
url.searchParams.append("response_type", "code"); url.searchParams.append("response_type", "code");
url.searchParams.append("scope", this.scopes.join(" ")); url.searchParams.append("scope", this.scopes.join(" "));
@ -98,7 +101,9 @@ export default class SpotifyConnection extends RefreshableConnection {
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from( Authorization: `Basic ${Buffer.from(
`${this.settings.clientId!}:${this.settings.clientSecret!}`, `${this.settings.clientId as string}:${
this.settings.clientSecret as string
}`,
).toString("base64")}`, ).toString("base64")}`,
}) })
.body( .body(
@ -129,7 +134,9 @@ export default class SpotifyConnection extends RefreshableConnection {
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from( Authorization: `Basic ${Buffer.from(
`${this.settings.clientId!}:${this.settings.clientSecret!}`, `${this.settings.clientId as string}:${
this.settings.clientSecret as string
}`,
).toString("base64")}`, ).toString("base64")}`,
}) })
.body( .body(
@ -169,8 +176,11 @@ export default class SpotifyConnection extends RefreshableConnection {
async handleCallback( async handleCallback(
params: ConnectionCallbackSchema, params: ConnectionCallbackSchema,
): Promise<ConnectedAccount | null> { ): Promise<ConnectedAccount | null> {
const userId = this.getUserId(params.state); const { state, code } = params;
const tokenData = await this.exchangeCode(params.state, params.code!); if (!code) throw new Error("No code provided");
const userId = this.getUserId(state);
const tokenData = await this.exchangeCode(state, code);
const userInfo = await this.getUser(tokenData.access_token); const userInfo = await this.getUser(tokenData.access_token);
const exists = await this.hasConnection(userId, userInfo.id); const exists = await this.hasConnection(userId, userInfo.id);

View File

@ -22,9 +22,9 @@ import {
ConnectionCallbackSchema, ConnectionCallbackSchema,
ConnectionLoader, ConnectionLoader,
DiscordApiErrors, DiscordApiErrors,
RefreshableConnection,
} from "@spacebar/util"; } from "@spacebar/util";
import wretch from "wretch"; import wretch from "wretch";
import RefreshableConnection from "../../util/connections/RefreshableConnection";
import { TwitchSettings } from "./TwitchSettings"; import { TwitchSettings } from "./TwitchSettings";
interface TwitchConnectionUserResponse { interface TwitchConnectionUserResponse {
@ -55,17 +55,20 @@ export default class TwitchConnection extends RefreshableConnection {
settings: TwitchSettings = new TwitchSettings(); settings: TwitchSettings = new TwitchSettings();
init(): void { init(): void {
this.settings = ConnectionLoader.getConnectionConfig( const settings = ConnectionLoader.getConnectionConfig<TwitchSettings>(
this.id, this.id,
this.settings, this.settings,
) as TwitchSettings; );
if (settings.enabled && (!settings.clientId || !settings.clientSecret))
throw new Error(`Invalid settings for connection ${this.id}`);
} }
getAuthorizationUrl(userId: string): string { getAuthorizationUrl(userId: string): string {
const state = this.createState(userId); const state = this.createState(userId);
const url = new URL(this.authorizeUrl); const url = new URL(this.authorizeUrl);
url.searchParams.append("client_id", this.settings.clientId!); url.searchParams.append("client_id", this.settings.clientId as string);
url.searchParams.append("redirect_uri", this.getRedirectUri()); url.searchParams.append("redirect_uri", this.getRedirectUri());
url.searchParams.append("response_type", "code"); url.searchParams.append("response_type", "code");
url.searchParams.append("scope", this.scopes.join(" ")); url.searchParams.append("scope", this.scopes.join(" "));
@ -94,8 +97,8 @@ export default class TwitchConnection extends RefreshableConnection {
new URLSearchParams({ new URLSearchParams({
grant_type: "authorization_code", grant_type: "authorization_code",
code: code, code: code,
client_id: this.settings.clientId!, client_id: this.settings.clientId as string,
client_secret: this.settings.clientSecret!, client_secret: this.settings.clientSecret as string,
redirect_uri: this.getRedirectUri(), redirect_uri: this.getRedirectUri(),
}), }),
) )
@ -124,8 +127,8 @@ export default class TwitchConnection extends RefreshableConnection {
.body( .body(
new URLSearchParams({ new URLSearchParams({
grant_type: "refresh_token", grant_type: "refresh_token",
client_id: this.settings.clientId!, client_id: this.settings.clientId as string,
client_secret: this.settings.clientSecret!, client_secret: this.settings.clientSecret as string,
refresh_token: refresh_token, refresh_token: refresh_token,
}), }),
) )
@ -148,7 +151,7 @@ export default class TwitchConnection extends RefreshableConnection {
return wretch(url.toString()) return wretch(url.toString())
.headers({ .headers({
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
"Client-Id": this.settings.clientId!, "Client-Id": this.settings.clientId as string,
}) })
.get() .get()
.json<TwitchConnectionUserResponse>() .json<TwitchConnectionUserResponse>()
@ -161,8 +164,11 @@ export default class TwitchConnection extends RefreshableConnection {
async handleCallback( async handleCallback(
params: ConnectionCallbackSchema, params: ConnectionCallbackSchema,
): Promise<ConnectedAccount | null> { ): Promise<ConnectedAccount | null> {
const userId = this.getUserId(params.state); const { state, code } = params;
const tokenData = await this.exchangeCode(params.state, params.code!); if (!code) throw new Error("No code provided");
const userId = this.getUserId(state);
const tokenData = await this.exchangeCode(state, code);
const userInfo = await this.getUser(tokenData.access_token); const userInfo = await this.getUser(tokenData.access_token);
const exists = await this.hasConnection(userId, userInfo.data[0].id); const exists = await this.hasConnection(userId, userInfo.data[0].id);

View File

@ -22,9 +22,9 @@ import {
ConnectionCallbackSchema, ConnectionCallbackSchema,
ConnectionLoader, ConnectionLoader,
DiscordApiErrors, DiscordApiErrors,
RefreshableConnection,
} from "@spacebar/util"; } from "@spacebar/util";
import wretch from "wretch"; import wretch from "wretch";
import RefreshableConnection from "../../util/connections/RefreshableConnection";
import { TwitterSettings } from "./TwitterSettings"; import { TwitterSettings } from "./TwitterSettings";
interface TwitterUserResponse { interface TwitterUserResponse {
@ -40,10 +40,10 @@ interface TwitterUserResponse {
}; };
} }
interface TwitterErrorResponse { // interface TwitterErrorResponse {
error: string; // error: string;
error_description: string; // error_description: string;
} // }
export default class TwitterConnection extends RefreshableConnection { export default class TwitterConnection extends RefreshableConnection {
public readonly id = "twitter"; public readonly id = "twitter";
@ -55,17 +55,20 @@ export default class TwitterConnection extends RefreshableConnection {
settings: TwitterSettings = new TwitterSettings(); settings: TwitterSettings = new TwitterSettings();
init(): void { init(): void {
this.settings = ConnectionLoader.getConnectionConfig( const settings = ConnectionLoader.getConnectionConfig<TwitterSettings>(
this.id, this.id,
this.settings, this.settings,
) as TwitterSettings; );
if (settings.enabled && (!settings.clientId || !settings.clientSecret))
throw new Error(`Invalid settings for connection ${this.id}`);
} }
getAuthorizationUrl(userId: string): string { getAuthorizationUrl(userId: string): string {
const state = this.createState(userId); const state = this.createState(userId);
const url = new URL(this.authorizeUrl); const url = new URL(this.authorizeUrl);
url.searchParams.append("client_id", this.settings.clientId!); url.searchParams.append("client_id", this.settings.clientId as string);
url.searchParams.append("redirect_uri", this.getRedirectUri()); url.searchParams.append("redirect_uri", this.getRedirectUri());
url.searchParams.append("response_type", "code"); url.searchParams.append("response_type", "code");
url.searchParams.append("scope", this.scopes.join(" ")); url.searchParams.append("scope", this.scopes.join(" "));
@ -92,14 +95,16 @@ export default class TwitterConnection extends RefreshableConnection {
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from( Authorization: `Basic ${Buffer.from(
`${this.settings.clientId!}:${this.settings.clientSecret!}`, `${this.settings.clientId as string}:${
this.settings.clientSecret as string
}`,
).toString("base64")}`, ).toString("base64")}`,
}) })
.body( .body(
new URLSearchParams({ new URLSearchParams({
grant_type: "authorization_code", grant_type: "authorization_code",
code: code, code: code,
client_id: this.settings.clientId!, client_id: this.settings.clientId as string,
redirect_uri: this.getRedirectUri(), redirect_uri: this.getRedirectUri(),
code_verifier: "challenge", // TODO: properly use PKCE challenge code_verifier: "challenge", // TODO: properly use PKCE challenge
}), }),
@ -126,14 +131,16 @@ export default class TwitterConnection extends RefreshableConnection {
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from( Authorization: `Basic ${Buffer.from(
`${this.settings.clientId!}:${this.settings.clientSecret!}`, `${this.settings.clientId as string}:${
this.settings.clientSecret as string
}`,
).toString("base64")}`, ).toString("base64")}`,
}) })
.body( .body(
new URLSearchParams({ new URLSearchParams({
grant_type: "refresh_token", grant_type: "refresh_token",
refresh_token, refresh_token,
client_id: this.settings.clientId!, client_id: this.settings.clientId as string,
redirect_uri: this.getRedirectUri(), redirect_uri: this.getRedirectUri(),
code_verifier: "challenge", // TODO: properly use PKCE challenge code_verifier: "challenge", // TODO: properly use PKCE challenge
}), }),
@ -163,8 +170,11 @@ export default class TwitterConnection extends RefreshableConnection {
async handleCallback( async handleCallback(
params: ConnectionCallbackSchema, params: ConnectionCallbackSchema,
): Promise<ConnectedAccount | null> { ): Promise<ConnectedAccount | null> {
const userId = this.getUserId(params.state); const { state, code } = params;
const tokenData = await this.exchangeCode(params.state, params.code!); if (!code) throw new Error("No code provided");
const userId = this.getUserId(state);
const tokenData = await this.exchangeCode(state, code);
const userInfo = await this.getUser(tokenData.access_token); const userInfo = await this.getUser(tokenData.access_token);
const exists = await this.hasConnection(userId, userInfo.data.id); const exists = await this.hasConnection(userId, userInfo.data.id);

View File

@ -19,12 +19,12 @@
import { import {
ConnectedAccount, ConnectedAccount,
ConnectedAccountCommonOAuthTokenResponse, ConnectedAccountCommonOAuthTokenResponse,
Connection,
ConnectionCallbackSchema, ConnectionCallbackSchema,
ConnectionLoader, ConnectionLoader,
DiscordApiErrors, DiscordApiErrors,
} from "@spacebar/util"; } from "@spacebar/util";
import wretch from "wretch"; import wretch from "wretch";
import Connection from "../../util/connections/Connection";
import { XboxSettings } from "./XboxSettings"; import { XboxSettings } from "./XboxSettings";
interface XboxUserResponse { interface XboxUserResponse {
@ -44,10 +44,10 @@ interface XboxUserResponse {
}; };
} }
interface XboxErrorResponse { // interface XboxErrorResponse {
error: string; // error: string;
error_description: string; // error_description: string;
} // }
export default class XboxConnection extends Connection { export default class XboxConnection extends Connection {
public readonly id = "xbox"; public readonly id = "xbox";
@ -62,17 +62,20 @@ export default class XboxConnection extends Connection {
settings: XboxSettings = new XboxSettings(); settings: XboxSettings = new XboxSettings();
init(): void { init(): void {
this.settings = ConnectionLoader.getConnectionConfig( const settings = ConnectionLoader.getConnectionConfig<XboxSettings>(
this.id, this.id,
this.settings, this.settings,
) as XboxSettings; );
if (settings.enabled && (!settings.clientId || !settings.clientSecret))
throw new Error(`Invalid settings for connection ${this.id}`);
} }
getAuthorizationUrl(userId: string): string { getAuthorizationUrl(userId: string): string {
const state = this.createState(userId); const state = this.createState(userId);
const url = new URL(this.authorizeUrl); const url = new URL(this.authorizeUrl);
url.searchParams.append("client_id", this.settings.clientId!); url.searchParams.append("client_id", this.settings.clientId as string);
url.searchParams.append("redirect_uri", this.getRedirectUri()); url.searchParams.append("redirect_uri", this.getRedirectUri());
url.searchParams.append("response_type", "code"); url.searchParams.append("response_type", "code");
url.searchParams.append("scope", this.scopes.join(" ")); url.searchParams.append("scope", this.scopes.join(" "));
@ -124,14 +127,16 @@ export default class XboxConnection extends Connection {
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from( Authorization: `Basic ${Buffer.from(
`${this.settings.clientId!}:${this.settings.clientSecret!}`, `${this.settings.clientId as string}:${
this.settings.clientSecret as string
}`,
).toString("base64")}`, ).toString("base64")}`,
}) })
.body( .body(
new URLSearchParams({ new URLSearchParams({
grant_type: "authorization_code", grant_type: "authorization_code",
code: code, code: code,
client_id: this.settings.clientId!, client_id: this.settings.clientId as string,
redirect_uri: this.getRedirectUri(), redirect_uri: this.getRedirectUri(),
scope: this.scopes.join(" "), scope: this.scopes.join(" "),
}), }),
@ -174,8 +179,11 @@ export default class XboxConnection extends Connection {
async handleCallback( async handleCallback(
params: ConnectionCallbackSchema, params: ConnectionCallbackSchema,
): Promise<ConnectedAccount | null> { ): Promise<ConnectedAccount | null> {
const userId = this.getUserId(params.state); const { state, code } = params;
const tokenData = await this.exchangeCode(params.state, params.code!); if (!code) throw new Error("No code provided");
const userId = this.getUserId(state);
const tokenData = await this.exchangeCode(state, code);
const userToken = await this.getUserToken(tokenData.access_token); const userToken = await this.getUserToken(tokenData.access_token);
const userInfo = await this.getUser(userToken); const userInfo = await this.getUser(userToken);

View File

@ -19,12 +19,12 @@
import { import {
ConnectedAccount, ConnectedAccount,
ConnectedAccountCommonOAuthTokenResponse, ConnectedAccountCommonOAuthTokenResponse,
Connection,
ConnectionCallbackSchema, ConnectionCallbackSchema,
ConnectionLoader, ConnectionLoader,
DiscordApiErrors, DiscordApiErrors,
} from "@spacebar/util"; } from "@spacebar/util";
import wretch from "wretch"; import wretch from "wretch";
import Connection from "../../util/connections/Connection";
import { YoutubeSettings } from "./YoutubeSettings"; import { YoutubeSettings } from "./YoutubeSettings";
interface YouTubeConnectionChannelListResult { interface YouTubeConnectionChannelListResult {
@ -62,17 +62,20 @@ export default class YoutubeConnection extends Connection {
settings: YoutubeSettings = new YoutubeSettings(); settings: YoutubeSettings = new YoutubeSettings();
init(): void { init(): void {
this.settings = ConnectionLoader.getConnectionConfig( const settings = ConnectionLoader.getConnectionConfig<YoutubeSettings>(
this.id, this.id,
this.settings, this.settings,
) as YoutubeSettings; );
if (settings.enabled && (!settings.clientId || !settings.clientSecret))
throw new Error(`Invalid settings for connection ${this.id}`);
} }
getAuthorizationUrl(userId: string): string { getAuthorizationUrl(userId: string): string {
const state = this.createState(userId); const state = this.createState(userId);
const url = new URL(this.authorizeUrl); const url = new URL(this.authorizeUrl);
url.searchParams.append("client_id", this.settings.clientId!); url.searchParams.append("client_id", this.settings.clientId as string);
url.searchParams.append("redirect_uri", this.getRedirectUri()); url.searchParams.append("redirect_uri", this.getRedirectUri());
url.searchParams.append("response_type", "code"); url.searchParams.append("response_type", "code");
url.searchParams.append("scope", this.scopes.join(" ")); url.searchParams.append("scope", this.scopes.join(" "));
@ -101,8 +104,8 @@ export default class YoutubeConnection extends Connection {
new URLSearchParams({ new URLSearchParams({
grant_type: "authorization_code", grant_type: "authorization_code",
code: code, code: code,
client_id: this.settings.clientId!, client_id: this.settings.clientId as string,
client_secret: this.settings.clientSecret!, client_secret: this.settings.clientSecret as string,
redirect_uri: this.getRedirectUri(), redirect_uri: this.getRedirectUri(),
}), }),
) )
@ -131,8 +134,11 @@ export default class YoutubeConnection extends Connection {
async handleCallback( async handleCallback(
params: ConnectionCallbackSchema, params: ConnectionCallbackSchema,
): Promise<ConnectedAccount | null> { ): Promise<ConnectedAccount | null> {
const userId = this.getUserId(params.state); const { state, code } = params;
const tokenData = await this.exchangeCode(params.state, params.code!); if (!code) throw new Error("No code provided");
const userId = this.getUserId(state);
const tokenData = await this.exchangeCode(state, code);
const userInfo = await this.getUser(tokenData.access_token); const userInfo = await this.getUser(tokenData.access_token);
const exists = await this.hasConnection(userId, userInfo.items[0].id); const exists = await this.hasConnection(userId, userInfo.items[0].id);

View File

@ -24,7 +24,7 @@ import { Config, DiscordApiErrors } from "../util";
/** /**
* A connection that can be used to connect to an external service. * A connection that can be used to connect to an external service.
*/ */
export default abstract class Connection { export abstract class Connection {
id: string; id: string;
settings: { enabled: boolean }; settings: { enabled: boolean };
states: Map<string, string> = new Map(); states: Map<string, string> = new Map();

View File

@ -16,9 +16,9 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Connection } from "@spacebar/util";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import Connection from "./Connection";
import { ConnectionConfig } from "./ConnectionConfig"; import { ConnectionConfig } from "./ConnectionConfig";
import { ConnectionStore } from "./ConnectionStore"; import { ConnectionStore } from "./ConnectionStore";
@ -48,8 +48,7 @@ export class ConnectionLoader {
}); });
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any public static getConnectionConfig<T>(id: string, defaults?: unknown): T {
public static getConnectionConfig(id: string, defaults?: any): any {
let cfg = ConnectionConfig.get()[id]; let cfg = ConnectionConfig.get()[id];
if (defaults) { if (defaults) {
if (cfg) cfg = Object.assign({}, defaults, cfg); if (cfg) cfg = Object.assign({}, defaults, cfg);
@ -70,8 +69,7 @@ export class ConnectionLoader {
public static async setConnectionConfig( public static async setConnectionConfig(
id: string, id: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any config: Partial<unknown>,
config: Partial<any>,
): Promise<void> { ): Promise<void> {
if (!config) if (!config)
console.warn(`[Connections/WARN] ${id} tried to set config=null!`); console.warn(`[Connections/WARN] ${id} tried to set config=null!`);

View File

@ -16,8 +16,8 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import Connection from "./Connection"; import { Connection } from "./Connection";
import RefreshableConnection from "./RefreshableConnection"; import { RefreshableConnection } from "./RefreshableConnection";
export class ConnectionStore { export class ConnectionStore {
public static connections: Map<string, Connection | RefreshableConnection> = public static connections: Map<string, Connection | RefreshableConnection> =

View File

@ -18,13 +18,14 @@
import { ConnectedAccount } from "../entities"; import { ConnectedAccount } from "../entities";
import { ConnectedAccountCommonOAuthTokenResponse } from "../interfaces"; import { ConnectedAccountCommonOAuthTokenResponse } from "../interfaces";
import Connection from "./Connection"; import { Connection } from "./Connection";
/** /**
* A connection that can refresh its token. * A connection that can refresh its token.
*/ */
export default abstract class RefreshableConnection extends Connection { export abstract class RefreshableConnection extends Connection {
refreshEnabled = true; refreshEnabled = true;
/** /**
* Refreshes the token for a connected account. * Refreshes the token for a connected account.
* @param connectedAccount The connected account to refresh * @param connectedAccount The connected account to refresh

View File

@ -30,7 +30,7 @@ export class ConnectedAccountDTO {
verified?: boolean; verified?: boolean;
visibility?: number; visibility?: number;
integrations?: string[]; integrations?: string[];
metadata_?: any; metadata_?: unknown;
metadata_visibility?: number; metadata_visibility?: number;
two_way_link?: boolean; two_way_link?: boolean;

View File

@ -66,6 +66,7 @@ export class ConnectedAccount extends BaseClass {
integrations?: string[] = []; integrations?: string[] = [];
@Column({ type: "simple-json", name: "metadata", nullable: true }) @Column({ type: "simple-json", name: "metadata", nullable: true })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
metadata_?: any; metadata_?: any;
@Column() @Column()

View File

@ -30,7 +30,7 @@ export interface ConnectedAccountSchema {
verified?: boolean; verified?: boolean;
visibility?: number; visibility?: number;
integrations?: string[]; integrations?: string[];
metadata_?: any; metadata_?: unknown;
metadata_visibility?: number; metadata_visibility?: number;
two_way_link?: boolean; two_way_link?: boolean;
} }

View File

@ -21,5 +21,5 @@ export interface ConnectionCallbackSchema {
state: string; state: string;
insecure: boolean; insecure: boolean;
friend_sync: boolean; friend_sync: boolean;
openid_params?: any; // TODO: types openid_params?: unknown; // TODO: types
} }