import { Config, ConnectedAccount, ConnectedAccountCommonOAuthTokenResponse, ConnectionCallbackSchema, ConnectionLoader, DiscordApiErrors, } from "@fosscord/util"; import fetch from "node-fetch"; 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!); // 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("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 { this.validateState(state); const url = this.getTokenUrl(); return fetch(url.toString(), { method: "POST", 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: `${ Config.get().cdn.endpointPrivate || "http://localhost:3001" }/connections/${this.id}/callback`, }), }) .then((res) => res.json()) .catch((e) => { console.error( `Error exchanging token for ${this.id} connection: ${e}`, ); throw DiscordApiErrors.INVALID_OAUTH_TOKEN; }); } async getUser(token: string): Promise { 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 { 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, }); } }