diff --git a/assets/schemas.json b/assets/schemas.json
index 1fdfa361..a4215497 100644
--- a/assets/schemas.json
+++ b/assets/schemas.json
@@ -2790,6 +2790,608 @@
},
"$schema": "http://json-schema.org/draft-07/schema#"
},
+ "ConnectionCallbackSchema": {
+ "type": "object",
+ "properties": {
+ "code": {
+ "type": "string"
+ },
+ "state": {
+ "type": "string"
+ },
+ "insecure": {
+ "type": "boolean"
+ },
+ "friend_sync": {
+ "type": "boolean"
+ },
+ "openid_params": {}
+ },
+ "additionalProperties": false,
+ "required": [
+ "friend_sync",
+ "insecure",
+ "state"
+ ],
+ "definitions": {
+ "ChannelPermissionOverwriteType": {
+ "enum": [
+ 0,
+ 1,
+ 2
+ ],
+ "type": "number"
+ },
+ "ChannelModifySchema": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "maxLength": 100,
+ "type": "string"
+ },
+ "type": {
+ "enum": [
+ 0,
+ 1,
+ 10,
+ 11,
+ 12,
+ 13,
+ 14,
+ 15,
+ 2,
+ 255,
+ 3,
+ 33,
+ 34,
+ 35,
+ 4,
+ 5,
+ 6,
+ 64,
+ 7,
+ 8,
+ 9
+ ],
+ "type": "number"
+ },
+ "topic": {
+ "type": "string"
+ },
+ "icon": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "bitrate": {
+ "type": "integer"
+ },
+ "user_limit": {
+ "type": "integer"
+ },
+ "rate_limit_per_user": {
+ "type": "integer"
+ },
+ "position": {
+ "type": "integer"
+ },
+ "permission_overwrites": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "type": {
+ "$ref": "#/definitions/ChannelPermissionOverwriteType"
+ },
+ "allow": {
+ "type": "string"
+ },
+ "deny": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "allow",
+ "deny",
+ "id",
+ "type"
+ ]
+ }
+ },
+ "parent_id": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "nsfw": {
+ "type": "boolean"
+ },
+ "rtc_region": {
+ "type": "string"
+ },
+ "default_auto_archive_duration": {
+ "type": "integer"
+ },
+ "default_reaction_emoji": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "flags": {
+ "type": "integer"
+ },
+ "default_thread_rate_limit_per_user": {
+ "type": "integer"
+ },
+ "video_quality_mode": {
+ "type": "integer"
+ }
+ },
+ "additionalProperties": false
+ },
+ "ActivitySchema": {
+ "type": "object",
+ "properties": {
+ "afk": {
+ "type": "boolean"
+ },
+ "status": {
+ "$ref": "#/definitions/Status"
+ },
+ "activities": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/Activity"
+ }
+ },
+ "since": {
+ "type": "integer"
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "status"
+ ]
+ },
+ "Status": {
+ "enum": [
+ "dnd",
+ "idle",
+ "invisible",
+ "offline",
+ "online"
+ ],
+ "type": "string"
+ },
+ "Activity": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "type": {
+ "$ref": "#/definitions/ActivityType"
+ },
+ "url": {
+ "type": "string"
+ },
+ "created_at": {
+ "type": "integer"
+ },
+ "timestamps": {
+ "type": "object",
+ "properties": {
+ "start": {
+ "type": "integer"
+ },
+ "end": {
+ "type": "integer"
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "end",
+ "start"
+ ]
+ },
+ "application_id": {
+ "type": "string"
+ },
+ "details": {
+ "type": "string"
+ },
+ "state": {
+ "type": "string"
+ },
+ "emoji": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "animated": {
+ "type": "boolean"
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "animated",
+ "name"
+ ]
+ },
+ "party": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "size": {
+ "type": "array",
+ "items": [
+ {
+ "type": "integer"
+ }
+ ],
+ "minItems": 1,
+ "maxItems": 1
+ }
+ },
+ "additionalProperties": false
+ },
+ "assets": {
+ "type": "object",
+ "properties": {
+ "large_image": {
+ "type": "string"
+ },
+ "large_text": {
+ "type": "string"
+ },
+ "small_image": {
+ "type": "string"
+ },
+ "small_text": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "secrets": {
+ "type": "object",
+ "properties": {
+ "join": {
+ "type": "string"
+ },
+ "spectate": {
+ "type": "string"
+ },
+ "match": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "instance": {
+ "type": "boolean"
+ },
+ "flags": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "sync_id": {
+ "type": "string"
+ },
+ "metadata": {
+ "type": "object",
+ "properties": {
+ "context_uri": {
+ "type": "string"
+ },
+ "album_id": {
+ "type": "string"
+ },
+ "artist_ids": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "album_id",
+ "artist_ids"
+ ]
+ },
+ "session_id": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "flags",
+ "name",
+ "session_id",
+ "type"
+ ]
+ },
+ "ActivityType": {
+ "enum": [
+ 0,
+ 1,
+ 2,
+ 4,
+ 5
+ ],
+ "type": "number"
+ },
+ "Record<string,[number,number][]>": {
+ "type": "object",
+ "additionalProperties": false
+ },
+ "Embed": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string"
+ },
+ "type": {
+ "enum": [
+ "article",
+ "gifv",
+ "image",
+ "link",
+ "rich",
+ "video"
+ ],
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "url": {
+ "type": "string"
+ },
+ "timestamp": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "color": {
+ "type": "integer"
+ },
+ "footer": {
+ "type": "object",
+ "properties": {
+ "text": {
+ "type": "string"
+ },
+ "icon_url": {
+ "type": "string"
+ },
+ "proxy_icon_url": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "text"
+ ]
+ },
+ "image": {
+ "$ref": "#/definitions/EmbedImage"
+ },
+ "thumbnail": {
+ "$ref": "#/definitions/EmbedImage"
+ },
+ "video": {
+ "$ref": "#/definitions/EmbedImage"
+ },
+ "provider": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "url": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "author": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "url": {
+ "type": "string"
+ },
+ "icon_url": {
+ "type": "string"
+ },
+ "proxy_icon_url": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "fields": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "value": {
+ "type": "string"
+ },
+ "inline": {
+ "type": "boolean"
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "name",
+ "value"
+ ]
+ }
+ }
+ },
+ "additionalProperties": false
+ },
+ "EmbedImage": {
+ "type": "object",
+ "properties": {
+ "url": {
+ "type": "string"
+ },
+ "proxy_url": {
+ "type": "string"
+ },
+ "height": {
+ "type": "integer"
+ },
+ "width": {
+ "type": "integer"
+ }
+ },
+ "additionalProperties": false
+ },
+ "Partial<ChannelOverride>": {
+ "type": "object",
+ "properties": {
+ "message_notifications": {
+ "type": "integer"
+ },
+ "mute_config": {
+ "$ref": "#/definitions/MuteConfig"
+ },
+ "muted": {
+ "type": "boolean"
+ },
+ "channel_id": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "MuteConfig": {
+ "type": "object",
+ "properties": {
+ "end_time": {
+ "type": "integer"
+ },
+ "selected_time_window": {
+ "type": "integer"
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "end_time",
+ "selected_time_window"
+ ]
+ },
+ "CustomStatus": {
+ "type": "object",
+ "properties": {
+ "emoji_id": {
+ "type": "string"
+ },
+ "emoji_name": {
+ "type": "string"
+ },
+ "expires_at": {
+ "type": "integer"
+ },
+ "text": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "FriendSourceFlags": {
+ "type": "object",
+ "properties": {
+ "all": {
+ "type": "boolean"
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "all"
+ ]
+ },
+ "GuildFolder": {
+ "type": "object",
+ "properties": {
+ "color": {
+ "type": "integer"
+ },
+ "guild_ids": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "id": {
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "color",
+ "guild_ids",
+ "id",
+ "name"
+ ]
+ },
+ "Partial<GenerateWebAuthnCredentialsSchema>": {
+ "type": "object",
+ "properties": {
+ "password": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "Partial<CreateWebAuthnCredentialSchema>": {
+ "type": "object",
+ "properties": {
+ "credential": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "ticket": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "$schema": "http://json-schema.org/draft-07/schema#"
+ },
"DmChannelCreateSchema": {
"type": "object",
"properties": {
diff --git a/src/api/middlewares/Authentication.ts b/src/api/middlewares/Authentication.ts
index 771f0de8..55527984 100644
--- a/src/api/middlewares/Authentication.ts
+++ b/src/api/middlewares/Authentication.ts
@@ -52,6 +52,8 @@ export const NO_AUTHORIZATION_ROUTES = [
"/oauth2/callback",
// Asset delivery
/\/guilds\/\d+\/widget\.(json|png)/,
+ // Connections
+ /\/connections\/\w+\/callback/
];
export const API_PREFIX = /^\/api(\/v\d+)?/;
diff --git a/src/api/routes/connections/#connection_name/#connection_id/refresh.ts b/src/api/routes/connections/#connection_name/#connection_id/refresh.ts
new file mode 100644
index 00000000..cce50436
--- /dev/null
+++ b/src/api/routes/connections/#connection_name/#connection_id/refresh.ts
@@ -0,0 +1,11 @@
+import { route } from "@fosscord/api";
+import { Request, Response, Router } from "express";
+const router = Router();
+
+router.post("/", route({}), async (req: Request, res: Response) => {
+ // TODO:
+ const { connection_name, connection_id } = req.params;
+ res.sendStatus(204);
+});
+
+export default router;
diff --git a/src/api/routes/connections/#connection_name/authorize.ts b/src/api/routes/connections/#connection_name/authorize.ts
new file mode 100644
index 00000000..8e640a69
--- /dev/null
+++ b/src/api/routes/connections/#connection_name/authorize.ts
@@ -0,0 +1,35 @@
+import { Request, Response, Router } from "express";
+import { FieldErrors } from "../../../../util";
+import { ConnectionStore } from "../../../../util/connections";
+import { route } from "../../../util";
+
+const router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const { connection_id: connection_name } = req.params;
+ const connection = ConnectionStore.connections.get(connection_name);
+ if (!connection)
+ throw FieldErrors({
+ provider_id: {
+ code: "BASE_TYPE_CHOICES",
+ message: req.t("common:field.BASE_TYPE_CHOICES", {
+ types: Array.from(ConnectionStore.connections.keys()).join(
+ ", ",
+ ),
+ }),
+ },
+ });
+
+ if (!connection.settings.enabled)
+ throw FieldErrors({
+ provider_id: {
+ message: "This connection has been disabled server-side.",
+ },
+ });
+
+ res.json({
+ url: await connection.getAuthorizationUrl(req.user_id),
+ });
+});
+
+export default router;
diff --git a/src/api/routes/connections/#connection_name/callback.ts b/src/api/routes/connections/#connection_name/callback.ts
new file mode 100644
index 00000000..f158a037
--- /dev/null
+++ b/src/api/routes/connections/#connection_name/callback.ts
@@ -0,0 +1,52 @@
+import { Request, Response, Router } from "express";
+import {
+ ConnectionCallbackSchema,
+ emitEvent,
+ FieldErrors,
+} from "../../../../util";
+import { ConnectionStore } from "../../../../util/connections";
+import { route } from "../../../util";
+
+const router = Router();
+
+router.post(
+ "/",
+ route({ body: "ConnectionCallbackSchema" }),
+ async (req: Request, res: Response) => {
+ const { connection_id: 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 emit = await connection.handleCallback(body);
+
+ // whether we should emit a connections update event, only used when a connection doesnt already exist
+ if (emit)
+ emitEvent({
+ event: "USER_CONNECTIONS_UPDATE",
+ data: {},
+ user_id: userId,
+ });
+ res.sendStatus(204);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/users/@me/connections.ts b/src/api/routes/users/@me/connections.ts
index 74315bfe..a5041be1 100644
--- a/src/api/routes/users/@me/connections.ts
+++ b/src/api/routes/users/@me/connections.ts
@@ -16,14 +16,32 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-import { Request, Response, Router } from "express";
import { route } from "@fosscord/api";
+import { ConnectedAccount, ConnectedAccountDTO } from "@fosscord/util";
+import { Request, Response, Router } from "express";
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",
+ "access_token",
+ "friend_sync",
+ "integrations",
+ ],
+ });
+
+ res.json(connections.map((x) => new ConnectedAccountDTO(x, true)));
});
export default router;
diff --git a/src/connections/BattleNet/BattleNetSettings.ts b/src/connections/BattleNet/BattleNetSettings.ts
new file mode 100644
index 00000000..75e5c3ae
--- /dev/null
+++ b/src/connections/BattleNet/BattleNetSettings.ts
@@ -0,0 +1,5 @@
+export class BattleNetSettings {
+ enabled: boolean = false;
+ clientId: string | null = null;
+ clientSecret: string | null = null;
+}
diff --git a/src/connections/BattleNet/index.ts b/src/connections/BattleNet/index.ts
new file mode 100644
index 00000000..0fd0aa18
--- /dev/null
+++ b/src/connections/BattleNet/index.ts
@@ -0,0 +1,133 @@
+import fetch from "node-fetch";
+import { Config, ConnectionCallbackSchema, DiscordApiErrors } from "../../util";
+import Connection from "../../util/connections/Connection";
+import { ConnectionLoader } from "../../util/connections/ConnectionLoader";
+import { BattleNetSettings } from "./BattleNetSettings";
+
+interface OAuthTokenResponse {
+ access_token: string;
+ token_type: string;
+ scope: string;
+ refresh_token?: string;
+ expires_in?: number;
+}
+
+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!);
+ // TODO: probably shouldn't rely on cdn as this could be different from what we actually want. we should have an api endpoint setting.
+ url.searchParams.append(
+ "redirect_uri",
+ `${
+ Config.get().cdn.endpointPrivate || "http://localhost:3001"
+ }/connections/${this.id}/callback`,
+ );
+ 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<string> {
+ this.validateState(state);
+
+ const url = this.getTokenUrl();
+
+ return fetch(url.toString(), {
+ method: "POST",
+ 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: `${
+ Config.get().cdn.endpointPrivate || "http://localhost:3001"
+ }/connections/${this.id}/callback`,
+ }),
+ })
+ .then((res) => res.json())
+ .then((res: OAuthTokenResponse & BattleNetErrorResponse) => {
+ if (res.error) throw new Error(res.error_description);
+ return res.access_token;
+ })
+ .catch((e) => {
+ console.error(
+ `Error exchanging token for ${this.id} connection: ${e}`,
+ );
+ throw DiscordApiErrors.INVALID_OAUTH_TOKEN;
+ });
+ }
+
+ async getUser(token: string): Promise<BattleNetConnectionUser> {
+ const url = new URL(this.userInfoUrl);
+ return fetch(url.toString(), {
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ })
+ .then((res) => res.json())
+ .then((res: BattleNetConnectionUser & BattleNetErrorResponse) => {
+ if (res.error) throw new Error(res.error_description);
+ return res;
+ });
+ }
+
+ async handleCallback(params: ConnectionCallbackSchema): Promise<boolean> {
+ const userId = this.getUserId(params.state);
+ const token = await this.exchangeCode(params.state, params.code!);
+ const userInfo = await this.getUser(token);
+
+ const exists = await this.hasConnection(userId, userInfo.id.toString());
+
+ if (exists) return false;
+ await this.createConnection({
+ user_id: userId,
+ external_id: userInfo.id,
+ friend_sync: params.friend_sync,
+ name: userInfo.battletag,
+ revoked: false,
+ show_activity: false,
+ type: this.id,
+ verified: true,
+ visibility: 0,
+ integrations: [],
+ });
+ return true;
+ }
+}
diff --git a/src/connections/GitHub/GitHubSettings.ts b/src/connections/GitHub/GitHubSettings.ts
new file mode 100644
index 00000000..1b4070d2
--- /dev/null
+++ b/src/connections/GitHub/GitHubSettings.ts
@@ -0,0 +1,5 @@
+export class GitHubSettings {
+ enabled: boolean = false;
+ clientId: string | null = null;
+ clientSecret: string | null = null;
+}
diff --git a/src/connections/GitHub/index.ts b/src/connections/GitHub/index.ts
new file mode 100644
index 00000000..a96ac68e
--- /dev/null
+++ b/src/connections/GitHub/index.ts
@@ -0,0 +1,114 @@
+import fetch from "node-fetch";
+import { Config, ConnectionCallbackSchema, DiscordApiErrors } from "../../util";
+import Connection from "../../util/connections/Connection";
+import { ConnectionLoader } from "../../util/connections/ConnectionLoader";
+import { GitHubSettings } from "./GitHubSettings";
+
+interface OAuthTokenResponse {
+ access_token: string;
+ token_type: string;
+ scope: string;
+ refresh_token?: string;
+ expires_in?: number;
+}
+
+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!);
+ // TODO: probably shouldn't rely on cdn as this could be different from what we actually want. we should have an api endpoint setting.
+ url.searchParams.append(
+ "redirect_uri",
+ `${
+ Config.get().cdn.endpointPrivate || "http://localhost:3001"
+ }/connections/${this.id}/callback`,
+ );
+ 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<string> {
+ this.validateState(state);
+
+ const url = this.getTokenUrl(code);
+
+ return fetch(url.toString(), {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ },
+ })
+ .then((res) => res.json())
+ .then((res: OAuthTokenResponse) => res.access_token)
+ .catch((e) => {
+ console.error(
+ `Error exchanging token for ${this.id} connection: ${e}`,
+ );
+ throw DiscordApiErrors.INVALID_OAUTH_TOKEN;
+ });
+ }
+
+ async getUser(token: string): Promise<UserResponse> {
+ const url = new URL(this.userInfoUrl);
+ return fetch(url.toString(), {
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ }).then((res) => res.json());
+ }
+
+ async handleCallback(params: ConnectionCallbackSchema): Promise<boolean> {
+ const userId = this.getUserId(params.state);
+ const token = await this.exchangeCode(params.state, params.code!);
+ const userInfo = await this.getUser(token);
+
+ const exists = await this.hasConnection(userId, userInfo.id.toString());
+
+ if (exists) return false;
+ await this.createConnection({
+ user_id: userId,
+ external_id: userInfo.id,
+ friend_sync: params.friend_sync,
+ name: userInfo.name,
+ revoked: false,
+ show_activity: false,
+ type: this.id,
+ verified: true,
+ visibility: 0,
+ integrations: [],
+ });
+ return true;
+ }
+}
diff --git a/src/gateway/opcodes/Identify.ts b/src/gateway/opcodes/Identify.ts
index 1a632b84..d1d15790 100644
--- a/src/gateway/opcodes/Identify.ts
+++ b/src/gateway/opcodes/Identify.ts
@@ -43,6 +43,7 @@ import {
ReadyGuildDTO,
Guild,
UserTokenData,
+ ConnectedAccount,
} from "@fosscord/util";
import { Send } from "../util/Send";
import { CLOSECODES, OPCODES } from "../util/Constants";
@@ -78,7 +79,7 @@ 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] =
+ const [user, read_states, members, recipients, session, application, connected_accounts] =
await Promise.all([
User.findOneOrFail({
where: { id: this.user_id },
@@ -123,6 +124,7 @@ export async function onIdentify(this: WebSocket, data: Payload) {
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);
@@ -304,7 +306,7 @@ export async function onIdentify(this: WebSocket, data: Payload) {
private_channels: channels,
session_id: session_id,
analytics_token: "", // TODO
- connected_accounts: [], // TODO
+ connected_accounts,
consents: {
personalization: {
consented: false, // TODO
diff --git a/src/util/connections/Connection.ts b/src/util/connections/Connection.ts
new file mode 100644
index 00000000..02104d39
--- /dev/null
+++ b/src/util/connections/Connection.ts
@@ -0,0 +1,72 @@
+import crypto from "crypto";
+import { ConnectedAccount } from "../entities";
+import { OrmUtils } from "../imports";
+import { ConnectionCallbackSchema } from "../schemas";
+import { DiscordApiErrors } from "../util";
+
+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;
+
+ /**
+ * Processes the callback
+ * @param args Callback arguments
+ */
+ abstract handleCallback(params: ConnectionCallbackSchema): Promise<boolean>;
+
+ /**
+ * 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);
+ }
+
+ async createConnection(data: any): Promise<void> {
+ const ca = OrmUtils.mergeDeep(new ConnectedAccount(), data);
+ await ca.save();
+ }
+
+ async hasConnection(userId: string, externalId: string): Promise<boolean> {
+ const existing = await ConnectedAccount.findOne({
+ where: {
+ user_id: userId,
+ external_id: externalId,
+ },
+ });
+
+ return !!existing;
+ }
+}
diff --git a/src/util/connections/ConnectionConfig.ts b/src/util/connections/ConnectionConfig.ts
new file mode 100644
index 00000000..9b120c93
--- /dev/null
+++ b/src/util/connections/ConnectionConfig.ts
@@ -0,0 +1,79 @@
+import { ConnectionConfigEntity } from "../entities/ConnectionConfigEntity";
+
+let config: any;
+let pairs: ConnectionConfigEntity[];
+
+export const ConnectionConfig = {
+ init: async function init() {
+ if (config) return config;
+ console.log("[ConnectionConfig] 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);
+ console.debug("config", config); // TODO: if no more issues with sql, remove this or find the reason why it's happening
+
+ return applyConfig(config);
+ },
+};
+
+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(`[ConnectionConfig] WARN: Empty key`);
+ console.log(pair);
+ } else return pair.save();
+ }
+ }
+
+ return apply(val);
+}
+
+function pairsToConfig(pairs: ConnectionConfigEntity[]) {
+ let value: any = {};
+
+ pairs.forEach((p) => {
+ const keys = p.key.split("_");
+ let obj = value;
+ let prev = "";
+ let prevObj = obj;
+ let i = 0;
+
+ for (const key of keys) {
+ if (!isNaN(Number(key)) && !prevObj[prev]?.length)
+ prevObj[prev] = obj = [];
+ if (i++ === keys.length - 1) obj[key] = p.value;
+ else if (!obj[key]) obj[key] = {};
+
+ prev = key;
+ prevObj = obj;
+ obj = obj[key];
+ }
+ });
+
+ return value;
+}
diff --git a/src/util/connections/ConnectionLoader.ts b/src/util/connections/ConnectionLoader.ts
new file mode 100644
index 00000000..d06a6446
--- /dev/null
+++ b/src/util/connections/ConnectionLoader.ts
@@ -0,0 +1,65 @@
+import fs from "fs";
+import path from "path";
+import { OrmUtils } from "../imports";
+import Connection from "./Connection";
+import { ConnectionConfig } from "./ConnectionConfig";
+import { ConnectionStore } from "./ConnectionStore";
+
+const root = "dist/connections";
+let 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) => {
+ let modPath = path.resolve(path.join(root, x));
+ console.log(`Loading connection: ${modPath}`);
+ const mod = new (require(modPath).default)() as Connection;
+ ConnectionStore.connections.set(mod.id, mod);
+
+ mod.init();
+ console.log(`[Connections] Loaded connection '${mod.id}'`);
+ });
+ }
+
+ public static getConnectionConfig(id: string, defaults?: any): any {
+ let cfg = ConnectionConfig.get()[id];
+ if (defaults) {
+ if (cfg) cfg = OrmUtils.mergeDeep(defaults, cfg);
+ else cfg = defaults;
+ this.setConnectionConfig(id, cfg);
+ }
+
+ 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,
+ config: Partial<any>,
+ ): Promise<void> {
+ if (!config)
+ console.log(
+ `[ConnectionConfig/WARN] ${id} tried to set config=null!`,
+ );
+ await ConnectionConfig.set({
+ [id]: OrmUtils.mergeDeep(
+ ConnectionLoader.getConnectionConfig(id) || {},
+ config,
+ ),
+ });
+ }
+}
diff --git a/src/util/connections/ConnectionStore.ts b/src/util/connections/ConnectionStore.ts
new file mode 100644
index 00000000..406e8232
--- /dev/null
+++ b/src/util/connections/ConnectionStore.ts
@@ -0,0 +1,5 @@
+import Connection from "./Connection";
+
+export class ConnectionStore {
+ public static connections: Map<string, Connection> = new Map();
+}
diff --git a/src/util/connections/index.ts b/src/util/connections/index.ts
new file mode 100644
index 00000000..e15d0c8c
--- /dev/null
+++ b/src/util/connections/index.ts
@@ -0,0 +1,4 @@
+export * from "./Connection";
+export * from "./ConnectionConfig";
+export * from "./ConnectionLoader";
+export * from "./ConnectionStore";
diff --git a/src/util/dtos/ConnectedAccountDTO.ts b/src/util/dtos/ConnectedAccountDTO.ts
new file mode 100644
index 00000000..a0287086
--- /dev/null
+++ b/src/util/dtos/ConnectedAccountDTO.ts
@@ -0,0 +1,41 @@
+import { ConnectedAccount } from "../entities";
+
+export class ConnectedAccountDTO {
+ id: string;
+ user_id: string;
+ access_token?: string;
+ friend_sync: boolean;
+ name: string;
+ revoked: boolean;
+ show_activity: boolean;
+ type: string;
+ verified: boolean;
+ visibility: boolean;
+ integrations: string[];
+ metadata_: any;
+ metadata_visibility: boolean;
+ 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.access_token && with_token
+ ? connectedAccount.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;
+ this.integrations = connectedAccount.integrations;
+ this.metadata_ = connectedAccount.metadata_;
+ this.metadata_visibility = connectedAccount.metadata_visibility;
+ this.two_way_link = connectedAccount.two_way_link;
+ }
+}
diff --git a/src/util/dtos/index.ts b/src/util/dtos/index.ts
index cf3863fa..b91889ff 100644
--- a/src/util/dtos/index.ts
+++ b/src/util/dtos/index.ts
@@ -16,6 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
+export * from "./ConnectedAccountDTO";
export * from "./DmChannelDTO";
export * from "./ReadyGuildDTO";
export * from "./UserDTO";
diff --git a/src/util/entities/ConnectedAccount.ts b/src/util/entities/ConnectedAccount.ts
index 9f0ce35e..70923d2c 100644
--- a/src/util/entities/ConnectedAccount.ts
+++ b/src/util/entities/ConnectedAccount.ts
@@ -27,6 +27,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;
@@ -41,16 +44,16 @@ export class ConnectedAccount extends BaseClass {
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: boolean = true;
@Column()
type: string;
@@ -59,5 +62,17 @@ export class ConnectedAccount extends BaseClass {
verified: boolean;
@Column({ select: false })
- visibility: number;
+ visibility: boolean = true;
+
+ @Column({ type: "simple-array" })
+ integrations: string[];
+
+ @Column({ type: "simple-json", name: "metadata" })
+ metadata_: any;
+
+ @Column()
+ metadata_visibility: boolean = true;
+
+ @Column()
+ two_way_link: boolean = false;
}
diff --git a/src/util/entities/ConnectionConfigEntity.ts b/src/util/entities/ConnectionConfigEntity.ts
new file mode 100644
index 00000000..9c212b15
--- /dev/null
+++ b/src/util/entities/ConnectionConfigEntity.ts
@@ -0,0 +1,11 @@
+import { Column, Entity } from "typeorm";
+import { BaseClassWithoutId, PrimaryIdColumn } from "./BaseClass";
+
+@Entity("connection_config")
+export class ConnectionConfigEntity extends BaseClassWithoutId {
+ @PrimaryIdColumn()
+ key: string;
+
+ @Column({ type: "simple-json", nullable: true })
+ value: number | boolean | null | string | Date | undefined;
+}
diff --git a/src/util/entities/index.ts b/src/util/entities/index.ts
index 6dfbd822..ad34f67b 100644
--- a/src/util/entities/index.ts
+++ b/src/util/entities/index.ts
@@ -27,6 +27,7 @@ export * from "./Channel";
export * from "./ClientRelease";
export * from "./Config";
export * from "./ConnectedAccount";
+export * from "./ConnectionConfigEntity";
export * from "./EmbedCache";
export * from "./Emoji";
export * from "./Encryption";
diff --git a/src/util/interfaces/Event.ts b/src/util/interfaces/Event.ts
index c3bfbf9b..6609c9ee 100644
--- a/src/util/interfaces/Event.ts
+++ b/src/util/interfaces/Event.ts
@@ -420,6 +420,10 @@ export interface UserDeleteEvent extends Event {
};
}
+export interface UserConnectionsUpdateEvent extends Event {
+ event: "USER_CONNECTIONS_UPDATE";
+}
+
export interface VoiceStateUpdateEvent extends Event {
event: "VOICE_STATE_UPDATE";
data: VoiceState & {
@@ -561,6 +565,7 @@ export type EventData =
| TypingStartEvent
| UserUpdateEvent
| UserDeleteEvent
+ | UserConnectionsUpdateEvent
| VoiceStateUpdateEvent
| VoiceServerUpdateEvent
| WebhooksUpdateEvent
@@ -612,6 +617,7 @@ export enum EVENTEnum {
TypingStart = "TYPING_START",
UserUpdate = "USER_UPDATE",
UserDelete = "USER_DELETE",
+ UserConnectionsUpdate = "USER_CONNECTIONS_UPDATE",
WebhooksUpdate = "WEBHOOKS_UPDATE",
InteractionCreate = "INTERACTION_CREATE",
VoiceStateUpdate = "VOICE_STATE_UPDATE",
@@ -663,6 +669,7 @@ export type EVENT =
| "TYPING_START"
| "USER_UPDATE"
| "USER_DELETE"
+ | "USER_CONNECTIONS_UPDATE"
| "USER_NOTE_UPDATE"
| "WEBHOOKS_UPDATE"
| "INTERACTION_CREATE"
diff --git a/src/util/schemas/ConnectionCallbackSchema.ts b/src/util/schemas/ConnectionCallbackSchema.ts
new file mode 100644
index 00000000..09ae8a46
--- /dev/null
+++ b/src/util/schemas/ConnectionCallbackSchema.ts
@@ -0,0 +1,7 @@
+export interface ConnectionCallbackSchema {
+ code?: string;
+ state: string;
+ insecure: boolean;
+ friend_sync: boolean;
+ openid_params?: any; // TODO: types
+}
diff --git a/src/util/schemas/index.ts b/src/util/schemas/index.ts
index 44909a3a..2831d42a 100644
--- a/src/util/schemas/index.ts
+++ b/src/util/schemas/index.ts
@@ -30,6 +30,7 @@ export * from "./ChannelModifySchema";
export * from "./ChannelPermissionOverwriteSchema";
export * from "./ChannelReorderSchema";
export * from "./CodesVerificationSchema";
+export * from "./ConnectionCallbackSchema";
export * from "./DmChannelCreateSchema";
export * from "./EmojiCreateSchema";
export * from "./EmojiModifySchema";
|