1
0
mirror of https://github.com/spacebarchat/server.git synced 2024-11-09 20:22:47 +01:00

Merge pull request #1009 from Puyodead1/refactor/dev/connections

Connections Part 1
This commit is contained in:
Madeline 2023-04-02 11:30:31 +10:00 committed by GitHub
commit 86ac90b1e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 6331 additions and 321 deletions

File diff suppressed because it is too large Load Diff

15
package-lock.json generated
View File

@ -46,11 +46,11 @@
"probe-image-size": "^7.2.3",
"proxy-agent": "^5.0.0",
"reflect-metadata": "^0.1.13",
"sqlite3": "^5.1.5",
"ts-node": "^10.9.1",
"tslib": "^2.4.1",
"typeorm": "^0.3.10",
"typescript-json-schema": "^0.50.1",
"wretch": "^2.3.2",
"ws": "^8.9.0"
},
"devDependencies": {
@ -7989,6 +7989,14 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/wretch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/wretch/-/wretch-2.3.2.tgz",
"integrity": "sha512-brN97Z2Mwed+w5z+keYI1u5OwWhPIaW0sJi9CxtKBVxLc3aqP6j1+2FCoIskM7WJq6SUHdxTFx20ox0iDLa0mQ==",
"engines": {
"node": ">=14"
}
},
"node_modules/ws": {
"version": "8.11.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz",
@ -14170,6 +14178,11 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"wretch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/wretch/-/wretch-2.3.2.tgz",
"integrity": "sha512-brN97Z2Mwed+w5z+keYI1u5OwWhPIaW0sJi9CxtKBVxLc3aqP6j1+2FCoIskM7WJq6SUHdxTFx20ox0iDLa0mQ=="
},
"ws": {
"version": "8.11.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz",

View File

@ -105,6 +105,7 @@
"tslib": "^2.4.1",
"typeorm": "^0.3.10",
"typescript-json-schema": "^0.50.1",
"wretch": "^2.3.2",
"ws": "^8.9.0"
},
"_moduleAliases": {

View File

@ -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(

View File

@ -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+)?/;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,5 @@
export class BattleNetSettings {
enabled: boolean = false;
clientId: string | null = null;
clientSecret: string | null = null;
}

View File

@ -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,
});
}
}

View File

@ -0,0 +1,5 @@
export class DiscordSettings {
enabled: boolean = false;
clientId: string | null = null;
clientSecret: string | null = null;
}

View File

@ -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,
});
}
}

View File

@ -0,0 +1,5 @@
export class EpicGamesSettings {
enabled: boolean = false;
clientId: string | null = null;
clientSecret: string | null = null;
}

View File

@ -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,
});
}
}

View File

@ -0,0 +1,5 @@
export class FacebookSettings {
enabled: boolean = false;
clientId: string | null = null;
clientSecret: string | null = null;
}

View File

@ -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,
});
}
}

View File

@ -0,0 +1,5 @@
export class GitHubSettings {
enabled: boolean = false;
clientId: string | null = null;
clientSecret: string | null = null;
}

View File

@ -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,
});
}
}

View File

@ -0,0 +1,5 @@
export class RedditSettings {
enabled: boolean = false;
clientId: string | null = null;
clientSecret: string | null = null;
}

View File

@ -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,
});
}
}

View File

@ -0,0 +1,5 @@
export class SpotifySettings {
enabled: boolean = false;
clientId: string | null = null;
clientSecret: string | null = null;
}

View File

@ -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,
});
}
}

View File

@ -0,0 +1,5 @@
export class TwitchSettings {
enabled: boolean = false;
clientId: string | null = null;
clientSecret: string | null = null;
}

View File

@ -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,
});
}
}

View File

@ -0,0 +1,5 @@
export class TwitterSettings {
enabled: boolean = false;
clientId: string | null = null;
clientSecret: string | null = null;
}

View File

@ -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,
});
}
}

View File

@ -0,0 +1,5 @@
export class XboxSettings {
enabled: boolean = false;
clientId: string | null = null;
clientSecret: string | null = null;
}

View File

@ -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,
});
}
}

View File

@ -0,0 +1,5 @@
export class YoutubeSettings {
enabled: boolean = false;
clientId: string | null = null;
clientSecret: string | null = null;
}

View File

@ -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,
});
}
}

View File

@ -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

View File

@ -19,5 +19,5 @@
export class ApiConfiguration {
defaultVersion: string = "9";
activeVersions: string[] = ["6", "7", "8", "9"];
endpointPublic: string = "/api";
endpointPublic: string | null = null;
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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) || {},
),
});
}
}

View File

@ -0,0 +1,7 @@
import Connection from "./Connection";
import RefreshableConnection from "./RefreshableConnection";
export class ConnectionStore {
public static connections: Map<string, Connection | RefreshableConnection> =
new Map();
}

View File

@ -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;
}
}

View File

@ -0,0 +1,5 @@
export * from "./Connection";
export * from "./ConnectionConfig";
export * from "./ConnectionLoader";
export * from "./ConnectionStore";
export * from "./RefreshableConnection";

View File

@ -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;
}
}

View File

@ -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";

View File

@ -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();
}
}

View File

@ -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;
}

View File

@ -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";

View File

@ -25,3 +25,4 @@ export * from "./dtos/index";
export * from "./schemas";
export * from "./imports";
export * from "./config";
export * from "./connections";

View File

@ -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;
}

View File

@ -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"

View File

@ -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";

View File

@ -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;
}

View File

@ -0,0 +1,7 @@
export interface ConnectionCallbackSchema {
code?: string;
state: string;
insecure: boolean;
friend_sync: boolean;
openid_params?: any; // TODO: types
}

View File

@ -0,0 +1,5 @@
export interface ConnectionUpdateSchema {
visibility?: boolean;
show_activity?: boolean;
metadata_visibility?: boolean;
}

View File

@ -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";

View File

@ -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,