diff --git a/docs/Reddit.md b/docs/Reddit.md index 9552e4a..97d8aa2 100644 --- a/docs/Reddit.md +++ b/docs/Reddit.md @@ -1,5 +1,10 @@ # Platform: Reddit +Note: Reddit is moving to a new platform - devvit +https://developers.reddit.com/app-registration + +Documentation below is for the legacy api. + ## Set up the platform ### Create a new App in your Reddit account diff --git a/src/mappers/PlatformMapper.ts b/src/mappers/PlatformMapper.ts index 58a9ec3..447026e 100644 --- a/src/mappers/PlatformMapper.ts +++ b/src/mappers/PlatformMapper.ts @@ -30,6 +30,12 @@ export default class PlatformMapper extends AbstractMapper { get: ["managePlatforms"], set: ["managePlatforms"], }, + connected: { + type: "boolean", + label: "Connected", + get: ["managePlatforms"], + set: ["managePlatforms"], + }, // more fields from platform.settings // added in mapper constructor }; @@ -61,6 +67,9 @@ export default class PlatformMapper extends AbstractMapper { case "active": dto[field] = !!this.platform.active; break; + case "connected": + dto[field] = !!this.platform.connected; + break; case "model": case "id": case "user_id": @@ -116,8 +125,10 @@ export default class PlatformMapper extends AbstractMapper { if (fields.includes(field)) { switch (field) { case "active": - if (dto[field]) await this.user.addPlatform(this.platform.id); - else await this.user.removePlatform(this.platform.id); + this.platform.active = !!dto[field]; + break; + case "connected": + this.platform.connected = !!dto[field]; break; default: { switch (this.mapping[field].type) { @@ -154,6 +165,7 @@ export default class PlatformMapper extends AbstractMapper { this.user.log.trace("Ignoring field: " + field); } } + await this.platform.save(); await this.user.data.save(); return true; } diff --git a/src/models/Platform.ts b/src/models/Platform.ts index 51e4d61..71b3f3f 100644 --- a/src/models/Platform.ts +++ b/src/models/Platform.ts @@ -18,6 +18,7 @@ import User from "./User.ts"; export default class Platform { id: PlatformId = PlatformId.UNKNOWN; active: boolean = false; + connected: boolean = false; user: User; cache: { [id: string]: Post } = {}; defaultBody: string = "Fairpost feed"; @@ -75,6 +76,60 @@ export default class Platform { return "No tests implemented for " + this.id; } + /** + * save + * + * Save the platform - this is only 'active' + * and 'connected', as loaded in the User object + */ + async save() { + this.user.log.trace( + "Platform", + `Save ${this.id} (${this.active}, ${this.connected})`, + ); + const activeIds = this.user.data + .get("settings", "FEED_PLATFORMS", "") + .split(","); + if (this.active) { + if (!activeIds.includes(this.id)) { + activeIds.push(this.id); + this.user.data.set("settings", "FEED_PLATFORMS", activeIds.join(",")); + this.user.addPlatform(this); + } + } else { + const index = activeIds.indexOf(this.id); + if (index !== -1) { + activeIds.splice(index, 1); + this.user.data.set("settings", "FEED_PLATFORMS", activeIds.join(",")); + this.user.removePlatform(this); + } + } + const connectedIds = this.user.data + .get("settings", "FEED_CONNECTED", "") + .split(","); + if (this.connected) { + if (!connectedIds.includes(this.id)) { + connectedIds.push(this.id); + this.user.data.set( + "settings", + "FEED_CONNECTED", + connectedIds.join(","), + ); + } + } else { + const index = connectedIds.indexOf(this.id); + if (index !== -1) { + connectedIds.splice(index, 1); + this.user.data.set( + "settings", + "FEED_CONNECTED", + connectedIds.join(","), + ); + } + } + await this.user.data.save(); + } + /** * refresh * diff --git a/src/models/User.ts b/src/models/User.ts index 8e4135e..f1b8ab4 100644 --- a/src/models/User.ts +++ b/src/models/User.ts @@ -1,5 +1,4 @@ import { basename } from "path"; -import * as readline from "node:readline/promises"; import * as platformClasses from "../platforms/index.ts"; import { PlatformId } from "../platforms/index.ts"; @@ -13,7 +12,6 @@ import UserData from "./User/UserData.ts"; import UserFiles from "./User/UserFiles.ts"; import UserLog from "./User/UserLog.ts"; import UserMapper from "../mappers/UserMapper.ts"; -import { FieldMapping } from "../types/index.ts"; /** * User - represents one fairpost user @@ -215,14 +213,20 @@ export default class User { */ private loadPlatforms(): void { this.log.trace("User", "loadPlatforms"); - const platformIds = this.data + const activeIds = this.data .get("settings", "FEED_PLATFORMS", "") .split(","); + const connectedIds = this.data + .get("settings", "FEED_CONNECTED", "") + .split(","); Object.values(platformClasses).forEach((platformClass) => { if (typeof platformClass === "function") { - if (platformIds.includes(platformClass.id())) { + if (activeIds.includes(platformClass.id())) { const platform = new platformClass(this); platform.active = true; + if (connectedIds.includes(platformClass.id())) { + platform.connected = true; + } if (this.platforms === undefined) { this.platforms = {}; } @@ -247,10 +251,22 @@ export default class User { return platform; } + const activeIds = this.data + .get("settings", "FEED_PLATFORMS", "") + .split(","); + const connectedIds = this.data + .get("settings", "FEED_CONNECTED", "") + .split(","); Object.values(platformClasses).forEach((platformClass) => { if (typeof platformClass === "function") { if (platformClass.id() === platformId) { platform = new platformClass(this); + if (activeIds.includes(platform.id)) { + platform.active = true; + } + if (connectedIds.includes(platform.id)) { + platform.connected = true; + } } } }); @@ -276,96 +292,24 @@ export default class User { } /** - * Enable a platform on this user - * @param platformId - * @returns the enabled platform - */ - public async addPlatform(platformId: PlatformId): Promise { - this.log.trace("User", "addPlatform", platformId); - if ( - Object.values(PlatformId).includes(platformId) && - platformId != PlatformId.UNKNOWN - ) { - const platforms = this.data.get("settings", "FEED_PLATFORMS", ""); - const platformIds = platforms ? platforms.split(",") : []; - if (!platformIds.includes(platformId)) { - platformIds.push(platformId); - this.data.set("settings", "FEED_PLATFORMS", platformIds.join(",")); - await this.data.save(); - } - this.loadPlatforms(); - this.log.info(`Platform ${platformId} enabled for user ${this.id}`); - } else { - throw this.log.error("addPlatform: no such platform", platformId); - } - return this.getPlatform(platformId); - } - - /** - * Disable a platform on this user - * @param platformId + * Add one platform on this users platforms[] after it has been set + * active. Does not save. + * @param platform */ - public async removePlatform(platformId: PlatformId): Promise { - this.log.trace("User", "removePlatforms", platformId); - if ( - Object.values(PlatformId).includes(platformId) && - platformId != PlatformId.UNKNOWN - ) { - const platforms = this.data.get("settings", "FEED_PLATFORMS", ""); - const platformIds = platforms ? platforms.split(",") : []; - const index = platformIds.indexOf(platformId); - if (index !== -1) { - platformIds.splice(index, 1); - this.data.set("settings", "FEED_PLATFORMS", platformIds.join(",")); - await this.data.save(); - } - this.loadPlatforms(); - this.log.info(`Platform ${platformId} disabled for user ${this.id}`); - } else { - throw this.log.error("removePlatform: no such platform", platformId); + public addPlatform(platform: Platform) { + if (this.platforms && platform.active) { + this.platforms[platform.id] = platform; } } /** - * @returns all data from the settings store - - public getSettings(): { [key: string]: string } { - return this.data.getStore("settings"); - } + * Remove one platform on this users platforms[] after it has been set + * inactive. Does not save. + * @param platform */ - - public async promptCliFields( - fields: FieldMapping, - ): Promise<{ [key: string]: string }> { - const settings = {} as { [key: string]: string }; - const reader = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - for (const key in fields) { - const current = this.data.get( - "settings", - key, - String(fields[key].default ?? ""), - ); - const value = - (await reader.question(`${fields[key].label} ( ${current} ): `)) || - current; - settings[key] = value; + public removePlatform(platform: Platform) { + if (this.platforms && !platform.active) { + delete this.platforms[platform.id]; } - reader.close(); - return settings; } - - /** - * Update settings with values from payload - * @param payload - key/value object to save under settings store - - public async putSettings(payload: { [key: string]: string }): Promise { - for (const key in payload) { - this.data.set("settings", key, payload[key]); - } - await this.data.save(); - } - */ } diff --git a/src/platforms/Bluesky/Bluesky.ts b/src/platforms/Bluesky/Bluesky.ts index 2fa7090..d7ee572 100644 --- a/src/platforms/Bluesky/Bluesky.ts +++ b/src/platforms/Bluesky/Bluesky.ts @@ -55,14 +55,20 @@ export default class Bluesky extends Platform { async connect(operator: Operator, payload?: object) { if (operator.ui === "cli") { await this.auth.connectCli(); - return await this.test(); + const test = await this.test(); + this.connected = true; + await this.save(); + return test; } if (operator.ui === "api") { if (!payload) { throw this.user.log.error("Bluesky connect requires a payload"); } await this.auth.connectApi(payload); - return await this.test(); + const test = await this.test(); + this.connected = true; + await this.save(); + return test; } throw this.user.log.error( `${this.id} connect: ui ${operator.ui} not supported`, diff --git a/src/platforms/Facebook/Facebook.ts b/src/platforms/Facebook/Facebook.ts index 6238c7d..3e3457a 100644 --- a/src/platforms/Facebook/Facebook.ts +++ b/src/platforms/Facebook/Facebook.ts @@ -1,5 +1,5 @@ import { basename } from "path"; -import { FileGroup, FieldMapping } from "../../types/index.ts"; +import { FileGroup, FieldMapping, OAuthRequest } from "../../types/index.ts"; import Source from "../../models/Source.ts"; @@ -70,14 +70,27 @@ export default class Facebook extends Platform { async connect(operator: Operator, payload?: object) { if (operator.ui === "cli") { await this.auth.connectCli(); - return await this.test(); + const test = await this.test(); + this.connected = true; + await this.save(); + return test; } + if (operator.ui === "api") { - if (!payload) { - throw this.user.log.error("Connect via api requires a payload"); + const oauthPayload = payload as OAuthRequest; + const oauthResponse = await this.auth.connectApi(oauthPayload); + if (oauthResponse.phase !== "finish") { + return oauthResponse; + } + if (oauthResponse.authenticated) { + oauthResponse.results = await this.test(); + this.connected = true; + await this.save(); + return oauthResponse; } - return this.auth.connectApi(payload); + return oauthResponse; } + throw this.user.log.error( `${this.id} connect: ui ${operator.ui} not supported`, ); diff --git a/src/platforms/Facebook/FacebookAuth.ts b/src/platforms/Facebook/FacebookAuth.ts index 7bdf0c3..30577aa 100644 --- a/src/platforms/Facebook/FacebookAuth.ts +++ b/src/platforms/Facebook/FacebookAuth.ts @@ -4,6 +4,7 @@ import { handleJsonResponse, } from "../../utilities.ts"; +import { OAuthRequest, OAuthResponse } from "../../types/index.ts"; import OAuth2Service from "../../services/OAuth2Service.ts"; import User from "../../models/User.ts"; import { strict as assert } from "assert"; @@ -17,20 +18,30 @@ export default class FacebookAuth { this.user = user; } + /** + * Connect Facebook platform via cli + */ async connectCli() { - const code = await this.requestCode( - this.user.data.get("app", "FACEBOOK_APP_ID"), - ); + // phase 1 : get the code + const clientHost = this.user.data.get("app", "OAUTH_HOSTNAME"); + const clientPort = Number(this.user.data.get("app", "OAUTH_PORT")); + const redirectUri = OAuth2Service.getCallbackUrl(clientHost, clientPort); + const state = String(Math.random()).substring(2); + const requestUri = this.getRequestUri(redirectUri, state); + const code = await this.requestCliCode("Facebook", requestUri, state); + // phase 2: exchange the code for tokens + const appId = this.user.data.get("app", "FACEBOOK_APP_ID"); + const appSecret = this.user.data.get("app", "FACEBOOK_APP_SECRET"); const accessToken = await this.exchangeCode( + appId, + appSecret, code, - this.user.data.get("app", "FACEBOOK_APP_ID"), - this.user.data.get("app", "FACEBOOK_APP_SECRET"), + redirectUri, ); - const pageToken = await this.getLLPageToken( - this.user.data.get("app", "FACEBOOK_APP_ID"), - this.user.data.get("app", "FACEBOOK_APP_SECRET"), + appId, + appSecret, this.user.data.get("settings", "FACEBOOK_PAGE_ID"), accessToken, ); @@ -39,24 +50,88 @@ export default class FacebookAuth { await this.user.data.save(); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public async connectApi(payload: object) { - throw this.user.log.error("FacebookAuth:connectApi - not implemented"); + /** + * Connect Facebook platform via api + * + * OAuth basic flow is called in two phases: + * - phase1 has redirect_uri and a state - will return a { url: string } + * - phase2 has a code - will exchange for tokens and return { ready: true } + * @param payload OAuthRequest + * @returns OAuthResponse + */ + async connectApi(payload: OAuthRequest): Promise { + if (!payload || payload.flow !== "basic") { + throw this.user.log.error( + "FacebookAuth.connectApi: Payload flow must be basic", + payload, + ); + } + if (payload.phase === "start") { + if (!payload.redirect_uri) { + throw this.user.log.error( + "FacebookAuth.connectApi: Payload:start missing redirect_uri", + payload, + ); + } + return { + phase: "start", + flow: "basic", + request_uri: this.getRequestUri(payload.redirect_uri, payload.state), + }; + } + if (payload.phase === "finish") { + if (payload.error !== undefined) { + const msg = + payload.error + " - " + (payload.error_description ?? "unknown"); + throw this.user.log.error(msg, payload); + } + if (!payload.code || !payload.redirect_uri) { + throw this.user.log.error( + "FacebookAuth.connectApi: Payload:finish missing code and/or redirect_uri", + payload, + ); + } + const appId = this.user.data.get("app", "FACEBOOK_APP_ID"); + const appSecret = this.user.data.get("app", "FACEBOOK_APP_SECRET"); + const accessToken = await this.exchangeCode( + appId, + appSecret, + payload.code, + payload.redirect_uri, + ); + const pageToken = await this.getLLPageToken( + appId, + appSecret, + this.user.data.get("settings", "FACEBOOK_PAGE_ID"), + accessToken, + ); + this.user.data.set("auth", "FACEBOOK_PAGE_ACCESS_TOKEN", pageToken); + await this.user.data.save(); + + return { + phase: "finish", + flow: "basic", + authenticated: true, + }; + } + throw this.user.log.error("LinkedInAuth.connect: Unknown phase", payload); } - protected async requestCode(clientId: string): Promise { + /** + * Get oauth2 url to request a code + * @param redirectUri + * @param state + * @returns string + */ + protected getRequestUri(redirectUri: string, state?: string): string { this.user.log.trace("FacebookAuth", "requestCode"); - const clientHost = this.user.data.get("app", "OAUTH_HOSTNAME"); - const clientPort = Number(this.user.data.get("app", "OAUTH_PORT")); - const state = String(Math.random()).substring(2); - - // create auth url + const clientId = this.user.data.get("app", "FACEBOOK_APP_ID"); const url = new URL("https://www.facebook.com"); url.pathname = this.GRAPH_API_VERSION + "/dialog/oauth"; const query = { client_id: clientId, - redirect_uri: OAuth2Service.getCallbackUrl(clientHost, clientPort), - state: state, + redirect_uri: redirectUri, + state: state ?? "connect", response_type: "code", scope: [ "pages_manage_engagement", @@ -69,10 +144,27 @@ export default class FacebookAuth { ].join(), }; url.search = new URLSearchParams(query).toString(); + return url.href; + } + /** + * Request remote code using OAuth2Service as a local server + * @param platformName + * @param requestUri + * @param state + * @returns - code + */ + protected async requestCliCode( + platformName: string, + requestUri: string, + state: string, + ): Promise { + this.user.log.trace("FacebookAuth", "requestCliCode"); + const clientHost = this.user.data.get("app", "OAUTH_HOSTNAME"); + const clientPort = Number(this.user.data.get("app", "OAUTH_PORT")); const result = await OAuth2Service.requestRemotePermissions( - "Facebook", - url.href, + platformName, + requestUri, clientHost, clientPort, ); @@ -91,20 +183,25 @@ export default class FacebookAuth { return result["code"] as string; } + /** + * Exchange remote code for tokens + * @param appId + * @param appSecret + * @param code - the code to exchange + * @param redirectUri + * @returns - (short lived) access token + */ protected async exchangeCode( + appId: string, + appSecret: string, code: string, - clientId: string, - clientSecret: string, + redirectUri: string, ): Promise { this.user.log.trace("FacebookAuth", "exchangeCode"); - const clientHost = this.user.data.get("app", "OAUTH_HOSTNAME"); - const clientPort = Number(this.user.data.get("app", "OAUTH_PORT")); - const redirectUri = OAuth2Service.getCallbackUrl(clientHost, clientPort); - const tokens = (await this.get("oauth/access_token", { - client_id: clientId, - client_secret: clientSecret, + client_id: appId, + client_secret: appSecret, code: code, redirect_uri: redirectUri, })) as TokenResponse; diff --git a/src/platforms/Instagram/Instagram.ts b/src/platforms/Instagram/Instagram.ts index e27c439..a2b2d4a 100644 --- a/src/platforms/Instagram/Instagram.ts +++ b/src/platforms/Instagram/Instagram.ts @@ -1,5 +1,5 @@ import { basename } from "path"; -import { FileGroup, FieldMapping } from "../../types/index.ts"; +import { FileGroup, FieldMapping, OAuthRequest } from "../../types/index.ts"; import Source from "../../models/Source.ts"; @@ -77,10 +77,18 @@ export default class Instagram extends Platform { return await this.test(); } if (operator.ui === "api") { - if (!payload) { - throw this.user.log.error("Connect via api requires a payload"); + const oauthPayload = payload as OAuthRequest; + const oauthResponse = await this.auth.connectApi(oauthPayload); + if (oauthResponse.phase !== "finish") { + return oauthResponse; } - return this.auth.connectApi(payload); + if (oauthResponse.authenticated) { + oauthResponse.results = await this.test(); + this.connected = true; + await this.save(); + return oauthResponse; + } + return oauthResponse; } throw this.user.log.error( `${this.id} connect: ui ${operator.ui} not supported`, diff --git a/src/platforms/Instagram/InstagramAuth.ts b/src/platforms/Instagram/InstagramAuth.ts index a9b5ae5..7e12565 100644 --- a/src/platforms/Instagram/InstagramAuth.ts +++ b/src/platforms/Instagram/InstagramAuth.ts @@ -1,4 +1,5 @@ import FacebookAuth from "../Facebook/FacebookAuth.ts"; +import { OAuthRequest, OAuthResponse } from "../../types/index.ts"; import OAuth2Service from "../../services/OAuth2Service.ts"; import User from "../../models/User.ts"; @@ -7,20 +8,31 @@ export default class InstagramAuth extends FacebookAuth { super(user); } - async connect() { - const code = await this.requestCode( - this.user.data.get("app", "INSTAGRAM_APP_ID"), - ); + /** + * Connect Instagram platform via cli + * Inherits most methods from FacebookAuth + */ + async connectCli() { + // phase 1 : get the code + const clientHost = this.user.data.get("app", "OAUTH_HOSTNAME"); + const clientPort = Number(this.user.data.get("app", "OAUTH_PORT")); + const redirectUri = OAuth2Service.getCallbackUrl(clientHost, clientPort); + const state = String(Math.random()).substring(2); + const requestUri = this.getRequestUri(redirectUri, state); + const code = await this.requestCliCode("Instagram", requestUri, state); + // phase 2: exchange the code for tokens + const appId = this.user.data.get("app", "INSTAGRAM_APP_ID"); + const appSecret = this.user.data.get("app", "INSTAGRAM_APP_SECRET"); const accessToken = await this.exchangeCode( + appId, + appSecret, code, - this.user.data.get("app", "INSTAGRAM_APP_ID"), - this.user.data.get("app", "INSTAGRAM_APP_SECRET"), + redirectUri, ); - const pageToken = await this.getLLPageToken( - this.user.data.get("app", "INSTAGRAM_APP_ID"), - this.user.data.get("app", "INSTAGRAM_APP_SECRET"), + appId, + appSecret, this.user.data.get("settings", "INSTAGRAM_PAGE_ID"), accessToken, ); @@ -29,56 +41,71 @@ export default class InstagramAuth extends FacebookAuth { await this.user.data.save(); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public async connectApi(payload: object) { - throw this.user.log.error("InstagramAuth:connectApi - not implemented"); - } - - protected async requestCode(clientId: string): Promise { - const clientHost = this.user.data.get("app", "OAUTH_HOSTNAME"); - const clientPort = Number(this.user.data.get("app", "OAUTH_PORT")); - const state = String(Math.random()).substring(2); - - // create auth url - const url = new URL("https://www.facebook.com"); - url.pathname = this.GRAPH_API_VERSION + "/dialog/oauth"; - const query = { - client_id: clientId, - redirect_uri: OAuth2Service.getCallbackUrl(clientHost, clientPort), - state: state, - response_type: "code", - scope: [ - "pages_manage_engagement", - "pages_manage_posts", - "pages_read_engagement", - //'pages_read_user_engagement', - "publish_video", - "business_management", - "instagram_basic", - "instagram_content_publish", - ].join(), - }; - url.search = new URLSearchParams(query).toString(); - - const result = await OAuth2Service.requestRemotePermissions( - "Instagram", - url.href, - clientHost, - clientPort, - ); - - if (result["error"]) { - const msg = result["error_reason"] + " - " + result["error_description"]; - throw this.user.log.error(msg, result); + /** + * Connect Instagram platform via api + * Inherits most methods from FacebookAuth + * + * OAuth basic flow is called in two phases: + * - phase1 has redirect_uri and a state - will return a { url: string } + * - phase2 has a code - will exchange for tokens and return { ready: true } + * @param payload OAuthRequest + * @returns OAuthResponse + */ + async connectApi(payload: OAuthRequest): Promise { + if (!payload || payload.flow !== "basic") { + throw this.user.log.error( + "FacebookAuth.connectApi: Payload flow must be basic", + payload, + ); } - if (result["state"] !== state) { - const msg = "Response state does not match request state"; - throw this.user.log.error(msg, result); + if (payload.phase === "start") { + if (!payload.redirect_uri) { + throw this.user.log.error( + "FacebookAuth.connectApi: Payload:start missing redirect_uri", + payload, + ); + } + return { + phase: "start", + flow: "basic", + request_uri: this.getRequestUri(payload.redirect_uri, payload.state), + }; } - if (!result["code"]) { - const msg = "Remote response did not return a code"; - throw this.user.log.error(msg, result); + if (payload.phase === "finish") { + if (payload.error !== undefined) { + const msg = + payload.error + " - " + (payload.error_description ?? "unknown"); + throw this.user.log.error(msg, payload); + } + if (!payload.code || !payload.redirect_uri) { + throw this.user.log.error( + "FacebookAuth.connectApi: Payload:finish missing code and/or redirect_uri", + payload, + ); + } + const appId = this.user.data.get("app", "INSTAGRAM_APP_ID"); + const appSecret = this.user.data.get("app", "INSTAGRAM_APP_SECRET"); + const accessToken = await this.exchangeCode( + appId, + appSecret, + payload.code, + payload.redirect_uri, + ); + const pageToken = await this.getLLPageToken( + appId, + appSecret, + this.user.data.get("settings", "INSTAGRAM_PAGE_ID"), + accessToken, + ); + this.user.data.set("auth", "INSTAGRAM_PAGE_ACCESS_TOKEN", pageToken); + await this.user.data.save(); + + return { + phase: "finish", + flow: "basic", + authenticated: true, + }; } - return result["code"] as string; + throw this.user.log.error("InstagramAuth.connect: Unknown phase", payload); } } diff --git a/src/platforms/LinkedIn/LinkedIn.ts b/src/platforms/LinkedIn/LinkedIn.ts index 3ce6bc2..13bab4c 100644 --- a/src/platforms/LinkedIn/LinkedIn.ts +++ b/src/platforms/LinkedIn/LinkedIn.ts @@ -1,4 +1,4 @@ -import { FileGroup, FieldMapping } from "../../types/index.ts"; +import { FileGroup, FieldMapping, OAuthRequest } from "../../types/index.ts"; import Source from "../../models/Source.ts"; import { handleApiError, handleEmptyResponse } from "../../utilities.ts"; @@ -65,19 +65,24 @@ export default class LinkedIn extends Platform { async connect(operator: Operator, payload?: object) { if (operator.ui === "cli") { await this.auth.connectCli(); - return await this.test(); + const test = await this.test(); + this.connected = true; + await this.save(); + return test; } if (operator.ui === "api") { - if (!payload) { - throw this.user.log.error("Connect via api requires a payload"); + const oauthPayload = payload as OAuthRequest; + const oauthResponse = await this.auth.connectApi(oauthPayload); + if (oauthResponse.phase !== "finish") { + return oauthResponse; } - const result = await this.auth.connectApi(payload); - const ready = "ready" in result && result.ready; - if (!ready) return result; - return { - ...result, - test: await this.test(), - }; + if (oauthResponse.authenticated) { + oauthResponse.results = await this.test(); + this.connected = true; + await this.save(); + return oauthResponse; + } + return oauthResponse; } throw this.user.log.error( diff --git a/src/platforms/LinkedIn/LinkedInApi.ts b/src/platforms/LinkedIn/LinkedInApi.ts index 7786b93..3cc13d5 100644 --- a/src/platforms/LinkedIn/LinkedInApi.ts +++ b/src/platforms/LinkedIn/LinkedInApi.ts @@ -12,8 +12,8 @@ import User from "../../models/User.ts"; */ export default class LinkedInApi { - LGC_API_VERSION = "v2"; - API_VERSION = "202307"; + API_VERSION = "202604"; + RESTLI_VERSION = "2.0.0"; user: User; @@ -31,9 +31,8 @@ export default class LinkedInApi { endpoint: string, query: { [key: string]: string } = {}, ): Promise { - // nb this is the legacy format const url = new URL("https://api.linkedin.com"); - url.pathname = this.LGC_API_VERSION + "/" + endpoint; + url.pathname = "rest/" + endpoint; url.search = new URLSearchParams(query).toString(); const accessToken = this.user.data.get("auth", "LINKEDIN_ACCESS_TOKEN"); @@ -46,6 +45,8 @@ export default class LinkedInApi { Connection: "Keep-Alive", Authorization: "Bearer " + accessToken, "User-Agent": this.user.data.get("app", "OAUTH_USERAGENT"), + "Linkedin-Version": this.API_VERSION, + "X-Restli-Protocol-Version": this.RESTLI_VERSION, }, }) .then((res) => handleJsonResponse(res, true)) @@ -80,6 +81,7 @@ export default class LinkedInApi { Accept: "application/json", "Content-Type": "application/json", "Linkedin-Version": this.API_VERSION, + "X-Restli-Protocol-Version": this.RESTLI_VERSION, Authorization: "Bearer " + accessToken, }, body: JSON.stringify(body), diff --git a/src/platforms/LinkedIn/LinkedInAuth.ts b/src/platforms/LinkedIn/LinkedInAuth.ts index 86afe1d..522c3e2 100644 --- a/src/platforms/LinkedIn/LinkedInAuth.ts +++ b/src/platforms/LinkedIn/LinkedInAuth.ts @@ -4,6 +4,7 @@ import { handleJsonResponse, } from "../../utilities.ts"; +import { OAuthRequest, OAuthResponse } from "../../types/index.ts"; import OAuth2Service from "../../services/OAuth2Service.ts"; import User from "../../models/User.ts"; import { strict as assert } from "assert"; @@ -18,54 +19,84 @@ export default class LinkedInAuth { } /** - * Set up LinkedIn platform + * Connect LinkedIn platform via cli */ async connectCli() { + // phase 1 : get the code const clientHost = this.user.data.get("app", "OAUTH_HOSTNAME"); const clientPort = Number(this.user.data.get("app", "OAUTH_PORT")); const redirectUri = OAuth2Service.getCallbackUrl(clientHost, clientPort); - const code = await this.requestCliCode(redirectUri); + const state = String(Math.random()).substring(2); + const requestUri = this.getRequestUri(redirectUri, state); + const code = await this.requestCliCode(requestUri, state); + + // phase 2: exchange the code for tokens const tokens = await this.exchangeCode(code, redirectUri); await this.store(tokens); } - async connectApi(payload: { - state?: string; - redirect_uri?: string; - code?: string; - error?: string; - error_uri?: string; - error_description?: string; - }): Promise<{ url?: string; ready?: boolean }> { - if (payload["error"]) { - const msg = payload["error"] + " - " + payload["error_description"]; - throw this.user.log.error(msg, payload); - } - if (!payload.redirect_uri) { + /** + * Connect LinkedIn platform via api + * + * OAuth basic flow is called in two phases: + * - phase1 has redirect_uri and a state - will return a { url: string } + * - phase2 has a code - will exchange for tokens and return { ready: true } + * @param payload OAuthRequest + * @returns OAuthResponse + */ + async connectApi(payload: OAuthRequest): Promise { + if (!payload || payload.flow !== "basic") { throw this.user.log.error( - "LinkedInAuth.connect: Invalid payload", + "LinkedInAuth.connectApi: Payload flow must be basic", payload, ); } - if (!payload.code) { + if (payload.phase === "start") { + if (!payload.redirect_uri) { + throw this.user.log.error( + "LinkedInAuth.connectApi: Payload:start missing redirect_uri", + payload, + ); + } return { - url: this.getRequestUrl(payload.redirect_uri, payload.state), + phase: "start", + flow: "basic", + request_uri: this.getRequestUri(payload.redirect_uri, payload.state), }; } - const tokens = await this.exchangeCode(payload.code, payload.redirect_uri); - await this.store(tokens); - return { - ready: true, - }; + if (payload.phase === "finish") { + if (payload.error !== undefined) { + const msg = + payload.error + " - " + (payload.error_description ?? "unknown"); + throw this.user.log.error(msg, payload); + } + if (!payload.code || !payload.redirect_uri) { + throw this.user.log.error( + "LinkedInAuth.connectApi: Payload:finish missing code and/or redirect_uri", + payload, + ); + } + const tokens = await this.exchangeCode( + payload.code, + payload.redirect_uri, + ); + await this.store(tokens); + return { + phase: "finish", + flow: "basic", + authenticated: true, + }; + } + throw this.user.log.error("LinkedInAuth.connect: Unknown phase", payload); } /** - * Get oath2 url to request a code + * Get oauth2 url to request a code * @param redirectUri * @param state * @returns - string */ - private getRequestUrl(redirectUri: string, state?: string): string { + private getRequestUri(redirectUri: string, state?: string): string { const clientId = this.user.data.get("app", "LINKEDIN_CLIENT_ID"); const url = new URL("https://www.linkedin.com"); url.pathname = "oauth/" + this.API_VERSION + "/authorization"; @@ -87,18 +118,24 @@ export default class LinkedInAuth { /** * Request remote code using OAuth2Service as a local server - * @param redirectUri + * @param requestUri + * @param state * @returns - code */ - private async requestCliCode(redirectUri: string): Promise { - this.user.log.trace("LinkedInAuth", "requestCode"); - const state = String(Math.random()).substring(2); - const requestUrl = this.getRequestUrl(redirectUri, state); + private async requestCliCode( + requestUri: string, + state: string, + ): Promise { + this.user.log.trace("LinkedInAuth", "requestCliCode"); + + const clientHost = this.user.data.get("app", "OAUTH_HOSTNAME"); + const clientPort = Number(this.user.data.get("app", "OAUTH_PORT")); + const result = await OAuth2Service.requestRemotePermissions( "LinkedIn", - requestUrl, - this.user.data.get("app", "OAUTH_HOSTNAME"), - Number(this.user.data.get("app", "OAUTH_PORT")), + requestUri, + clientHost, + clientPort, ); if (result["error"]) { const msg = result["error_reason"] + " - " + result["error_description"]; diff --git a/src/platforms/Reddit/Reddit.ts b/src/platforms/Reddit/Reddit.ts index e00d007..2c172d1 100644 --- a/src/platforms/Reddit/Reddit.ts +++ b/src/platforms/Reddit/Reddit.ts @@ -1,5 +1,5 @@ import { basename } from "path"; -import { FileGroup, FieldMapping } from "../../types/index.ts"; +import { FileGroup, FieldMapping, OAuthRequest } from "../../types/index.ts"; import Source from "../../models/Source.ts"; @@ -60,13 +60,24 @@ export default class Reddit extends Platform { async connect(operator: Operator, payload?: object) { if (operator.ui === "cli") { await this.auth.connectCli(); - return await this.test(); + const test = await this.test(); + this.connected = true; + await this.save(); + return test; } if (operator.ui === "api") { - if (!payload) { - throw this.user.log.error("Connect via api requires a payload"); + const oauthPayload = payload as OAuthRequest; + const oauthResponse = await this.auth.connectApi(oauthPayload); + if (oauthResponse.phase !== "finish") { + return oauthResponse; } - return this.auth.connectApi(payload); + if (oauthResponse.authenticated) { + oauthResponse.results = await this.test(); + this.connected = true; + await this.save(); + return oauthResponse; + } + return oauthResponse; } throw this.user.log.error( `${this.id} connect: ui ${operator.ui} not supported`, @@ -163,6 +174,10 @@ export default class Reddit extends Platform { async publishPost(post: Post, dryrun: boolean = false): Promise { this.user.log.trace("Reddit.publishPost", post.id, dryrun); + // reddit timeout is 1 day; + // TODO: check timeout + await this.auth.refresh(); + let response = {}; let error = undefined as Error | undefined; @@ -389,7 +404,7 @@ export default class Reddit extends Platform { file: string, ): Promise { const buffer = await this.user.files.readBuffer(file); - const blob = new Blob([buffer]); + const blob = new Blob([buffer]); // [new Uint8Array(buffer)] const filename = basename(file); const form = new FormData(); diff --git a/src/platforms/Reddit/RedditAuth.ts b/src/platforms/Reddit/RedditAuth.ts index 768716c..7902596 100644 --- a/src/platforms/Reddit/RedditAuth.ts +++ b/src/platforms/Reddit/RedditAuth.ts @@ -4,6 +4,7 @@ import { handleJsonResponse, } from "../../utilities.ts"; +import { OAuthRequest, OAuthResponse } from "../../types/index.ts"; import OAuth2Service from "../../services/OAuth2Service.ts"; import User from "../../models/User.ts"; import { strict as assert } from "assert"; @@ -16,36 +17,135 @@ export default class RedditAuth { constructor(user: User) { this.user = user; } + + /** + * Connect LinkedIn platform via cli + */ async connectCli() { - const code = await this.requestCode(); - const tokens = await this.exchangeCode(code); - await this.store(tokens); - } + // phase 1 : get the code + const clientHost = this.user.data.get("app", "OAUTH_HOSTNAME"); + const clientPort = Number(this.user.data.get("app", "OAUTH_PORT")); + const redirectUri = OAuth2Service.getCallbackUrl(clientHost, clientPort); + const state = String(Math.random()).substring(2); + const requestUri = this.getRequestUri(redirectUri, state); + const code = await this.requestCliCode(requestUri, state); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async connectApi(payload: object) { - throw this.user.log.error("RedditAuth:connectApi - not implemented"); + // phase 2: exchange the code for tokens + const tokens = await this.exchangeCode(code, redirectUri); + await this.store(tokens); } /** - * Refresh Reddit Access token + * Connect Reddit platform via api * - * Reddits access token expire in 24 hours. - * Refresh this regularly. + * OAuth basic flow is called in two phases: + * - phase1 has redirect_uri and a state - will return a { url: string } + * - phase2 has a code - will exchange for tokens and return { ready: true } + * @param payload OAuthRequest + * @returns OAuthResponse */ - public async refresh() { - const tokens = (await this.post("access_token", { - grant_type: "refresh_token", - refresh_token: this.user.data.get("auth", "REDDIT_REFRESH_TOKEN"), - })) as TokenResponse; - - if (!isTokenResponse(tokens)) { + async connectApi(payload: OAuthRequest): Promise { + if (!payload || payload.flow !== "basic") { throw this.user.log.error( - "RedditAuth.refresh: response is not a TokenResponse", - tokens, + "RedditAuth.connectApi: Payload flow must be basic", + payload, ); } - await this.store(tokens); + if (payload.phase === "start") { + if (!payload.redirect_uri) { + throw this.user.log.error( + "RedditAuth.connectApi: Payload:start missing redirect_uri", + payload, + ); + } + return { + phase: "start", + flow: "basic", + request_uri: this.getRequestUri(payload.redirect_uri, payload.state), + }; + } + if (payload.phase === "finish") { + if (payload.error !== undefined) { + const msg = + payload.error + " - " + (payload.error_description ?? "unknown"); + throw this.user.log.error(msg, payload); + } + if (!payload.code || !payload.redirect_uri) { + throw this.user.log.error( + "RedditAuth.connectApi: Payload:finish missing code and/or redirect_uri", + payload, + ); + } + const tokens = await this.exchangeCode( + payload.code, + payload.redirect_uri, + ); + await this.store(tokens); + return { + phase: "finish", + flow: "basic", + authenticated: true, + }; + } + throw this.user.log.error("RedditAuth.connect: Unknown phase", payload); + } + + /** + * Get oauth2 url to request a code + * @param redirectUri + * @param state + * @returns - string + */ + private getRequestUri(redirectUri: string, state?: string): string { + const clientId = this.user.data.get("app", "REDDIT_CLIENT_ID"); + const url = new URL("https://www.reddit.com"); + url.pathname = "api/" + this.API_VERSION + "/authorize"; + const query = { + client_id: clientId, + redirect_uri: redirectUri, + state: state ?? "connect", + response_type: "code", + duration: "permanent", + scope: ["identity", "submit"].join(), + }; + url.search = new URLSearchParams(query).toString(); + return url.href; + } + + /** + * Request remote code using OAuth2Service as a local server + * @param requestUri + * @param state + * @returns - code + */ + private async requestCliCode( + requestUri: string, + state: string, + ): Promise { + this.user.log.trace("RedditAuth", "requestCliCode"); + + const clientHost = this.user.data.get("app", "OAUTH_HOSTNAME"); + const clientPort = Number(this.user.data.get("app", "OAUTH_PORT")); + + const result = await OAuth2Service.requestRemotePermissions( + "Reddit", + requestUri, + clientHost, + clientPort, + ); + if (result["error"]) { + const msg = result["error_reason"] + " - " + result["error_description"]; + throw this.user.log.error(msg, result); + } + if (result["state"] !== state) { + const msg = "Response state does not match request state"; + throw this.user.log.error(msg, result); + } + if (!result["code"]) { + const msg = "Remote response did not return a code"; + throw this.user.log.error(msg, result); + } + return result["code"] as string; } /** @@ -96,25 +196,20 @@ export default class RedditAuth { /** * Exchange remote code for tokens * @param code - the code to exchange + * @param redirectUri * @returns - TokenResponse */ - protected async exchangeCode(code: string): Promise { + protected async exchangeCode( + code: string, + redirectUri: string, + ): Promise { this.user.log.trace("RedditAuth", "exchangeCode", code); - const clientHost = this.user.data.get("app", "OAUTH_HOSTNAME"); - const clientPort = Number(this.user.data.get("app", "OAUTH_PORT")); - const redirectUri = OAuth2Service.getCallbackUrl(clientHost, clientPort); const tokens = (await this.post("access_token", { grant_type: "authorization_code", code: code, redirect_uri: redirectUri, - })) as { - access_token: string; - token_type: "bearer"; - expires_in: number; - scope: string; - refresh_token: string; - }; + })) as TokenResponse; if (!isTokenResponse(tokens)) { throw this.user.log.error( @@ -126,6 +221,27 @@ export default class RedditAuth { return tokens; } + /** + * Refresh Reddit Access token + * + * Reddits access token expire in 24 hours. + * Refresh this regularly. + */ + public async refresh() { + const tokens = (await this.post("access_token", { + grant_type: "refresh_token", + refresh_token: this.user.data.get("auth", "REDDIT_REFRESH_TOKEN"), + })) as TokenResponse; + + if (!isTokenResponse(tokens)) { + throw this.user.log.error( + "RedditAuth.refresh: response is not a TokenResponse", + tokens, + ); + } + await this.store(tokens); + } + /** * Save all tokens in auth store * @param tokens - the tokens to store diff --git a/src/platforms/YouTube/YouTube.ts b/src/platforms/YouTube/YouTube.ts index 8b037b2..5b97b19 100644 --- a/src/platforms/YouTube/YouTube.ts +++ b/src/platforms/YouTube/YouTube.ts @@ -1,4 +1,4 @@ -import { FileGroup, FieldMapping } from "../../types/index.ts"; +import { FileGroup, FieldMapping, OAuthRequest } from "../../types/index.ts"; import Source from "../../models/Source.ts"; import Platform from "../../models/Platform.ts"; @@ -65,14 +65,26 @@ export default class YouTube extends Platform { async connect(operator: Operator, payload?: object) { if (operator.ui === "cli") { await this.auth.connectCli(); - return await this.test(); + const test = await this.test(); + this.connected = true; + await this.save(); + return test; } if (operator.ui === "api") { - if (!payload) { - throw this.user.log.error("Connect via api requires a payload"); + const oauthPayload = payload as OAuthRequest; + const oauthResponse = await this.auth.connectApi(oauthPayload); + if (oauthResponse.phase !== "finish") { + return oauthResponse; } - return this.auth.connectApi(payload); + if (oauthResponse.authenticated) { + oauthResponse.results = await this.test(); + this.connected = true; + await this.save(); + return oauthResponse; + } + return oauthResponse; } + throw this.user.log.error( `${this.id} connect: ui ${operator.ui} not supported`, ); @@ -114,6 +126,10 @@ export default class YouTube extends Platform { async publishPost(post: Post, dryrun: boolean = false): Promise { this.user.log.trace("YouTube.publishPost", post.id, dryrun); + // youtube timeout is 1 hour; + // TODO: check timeout + await this.auth.refresh(); + let response = { id: "-99" } as { id?: string; }; diff --git a/src/platforms/YouTube/YouTubeAuth.ts b/src/platforms/YouTube/YouTubeAuth.ts index 648e814..f0adb59 100644 --- a/src/platforms/YouTube/YouTubeAuth.ts +++ b/src/platforms/YouTube/YouTubeAuth.ts @@ -1,5 +1,6 @@ import { Credentials, OAuth2Client } from "google-auth-library"; +import { OAuthRequest, OAuthResponse } from "../../types/index.ts"; import OAuth2Service from "../../services/OAuth2Service.ts"; import User from "../../models/User.ts"; import { strict as assert } from "assert"; @@ -15,90 +16,90 @@ export default class YouTubeAuth { } /** - * Set up YouTube platform + * Connect Youtube platform via cli */ async connectCli() { - const code = await this.requestCode(); - const tokens = await this.exchangeCode(code); - await this.store(tokens); - } + // phase 1 : get the code + const clientHost = this.user.data.get("app", "OAUTH_HOSTNAME"); + const clientPort = Number(this.user.data.get("app", "OAUTH_PORT")); + const redirectUri = OAuth2Service.getCallbackUrl(clientHost, clientPort); + const state = String(Math.random()).substring(2); + const requestUri = this.getRequestUri(redirectUri, state); + const code = await this.requestCliCode(requestUri, state); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async connectApi(payload: object) { - throw this.user.log.error("YouTubeAuth:connectApi - not implemented"); + // phase 2: exchange the code for tokens + const tokens = await this.exchangeCode(code, redirectUri); + await this.store(tokens); } /** - * Refresh YouTube tokens + * Connect LinkedIn platform via api + * + * OAuth basic flow is called in two phases: + * - phase1 has redirect_uri and a state - will return a { url: string } + * - phase2 has a code - will exchange for tokens and return { ready: true } + * @param payload OAuthRequest + * @returns OAuthResponse */ - async refresh() { - this.user.log.trace("YouTubeAuth", "refresh"); - const auth = new OAuth2Client( - this.user.data.get("app", "YOUTUBE_CLIENT_ID"), - this.user.data.get("app", "YOUTUBE_CLIENT_SECRET"), - ); - auth.setCredentials({ - access_token: this.user.data.get("auth", "YOUTUBE_ACCESS_TOKEN"), - refresh_token: this.user.data.get("auth", "YOUTUBE_REFRESH_TOKEN"), - }); - const response = (await auth.refreshAccessToken()) as { - res?: { data: Credentials }; - credentials?: Credentials; - }; - if (response["res"]?.["data"] && isCredentials(response["res"]["data"])) { - await this.store(response["res"]["data"]); - return; - } else if (response.credentials) { - await this.store(response.credentials); - return; + async connectApi(payload: OAuthRequest): Promise { + if (!payload || payload.flow !== "basic") { + throw this.user.log.error( + "YouTubeAuth.connectApi: Payload flow must be basic", + payload, + ); } - throw this.user.log.error( - "YouTubeAuth.refresh", - "not a valid response", - response, - ); - } - - /** - * Get or create a YouTube client - * @returns - youtube_v3.Youtube - */ - public getClient(): youtube_v3.Youtube { - if (this.client) { - return this.client; + if (payload.phase === "start") { + if (!payload.redirect_uri) { + throw this.user.log.error( + "YouTubeAuth.connectApi: Payload:start missing redirect_uri", + payload, + ); + } + return { + phase: "start", + flow: "basic", + request_uri: this.getRequestUri(payload.redirect_uri, payload.state), + }; } - const auth = new OAuth2Client( - this.user.data.get("app", "YOUTUBE_CLIENT_ID"), - this.user.data.get("app", "YOUTUBE_CLIENT_SECRET"), - ); - auth.setCredentials({ - access_token: this.user.data.get("auth", "YOUTUBE_ACCESS_TOKEN"), - refresh_token: this.user.data.get("auth", "YOUTUBE_REFRESH_TOKEN"), - }); - auth.on("tokens", async (creds) => { - this.user.log.trace("YouTubeAuth", "tokens event received"); - await this.store(creds); - }); - this.client = new youtube_v3.Youtube({ auth }); - return this.client; + if (payload.phase === "finish") { + if (payload.error !== undefined) { + const msg = + payload.error + " - " + (payload.error_description ?? "unknown"); + throw this.user.log.error(msg, payload); + } + if (!payload.code || !payload.redirect_uri) { + throw this.user.log.error( + "YouTubeAuth.connectApi: Payload:finish missing code and/or redirect_uri", + payload, + ); + } + const tokens = await this.exchangeCode( + payload.code, + payload.redirect_uri, + ); + await this.store(tokens); + return { + phase: "finish", + flow: "basic", + authenticated: true, + }; + } + throw this.user.log.error("YouTubeAuth.connect: Unknown phase", payload); } /** - * Request remote code using OAuth2Service - * @returns - code + * Get oauth2 url to request a code + * @param redirectUri + * @param state + * @returns string */ - private async requestCode(): Promise { - this.user.log.trace("YouTubeAuth", "requestCode"); - const clientHost = this.user.data.get("app", "OAUTH_HOSTNAME"); - const clientPort = Number(this.user.data.get("app", "OAUTH_PORT")); - const state = String(Math.random()).substring(2); - + private getRequestUri(redirectUri: string, state?: string): string { const auth = new OAuth2Client( this.user.data.get("app", "YOUTUBE_CLIENT_ID"), this.user.data.get("app", "YOUTUBE_CLIENT_SECRET"), - OAuth2Service.getCallbackUrl(clientHost, clientPort), + redirectUri, ); - const url = auth.generateAuthUrl({ + return auth.generateAuthUrl({ access_type: "offline", scope: [ "https://www.googleapis.com/auth/youtube.force-ssl", @@ -107,10 +108,26 @@ export default class YouTubeAuth { ], state: state, }); + } + + /** + * Request remote code using OAuth2Service as a local server + * @param requestUri + * @param state + * @returns - code + */ + private async requestCliCode( + requestUri: string, + state: string, + ): Promise { + this.user.log.trace("YouTubeAuth", "requestCliCode"); + + const clientHost = this.user.data.get("app", "OAUTH_HOSTNAME"); + const clientPort = Number(this.user.data.get("app", "OAUTH_PORT")); const result = await OAuth2Service.requestRemotePermissions( "YouTube", - url, + requestUri, clientHost, clientPort, ); @@ -132,18 +149,19 @@ export default class YouTubeAuth { /** * Exchange remote code for tokens * @param code - the code to exchange + * @param redirectUri * @returns - Credentials */ - private async exchangeCode(code: string): Promise { + private async exchangeCode( + code: string, + redirectUri: string, + ): Promise { this.user.log.trace("YouTubeAuth", "exchangeCode", code); - const clientHost = this.user.data.get("app", "OAUTH_HOSTNAME"); - const clientPort = Number(this.user.data.get("app", "OAUTH_PORT")); - const auth = new OAuth2Client( this.user.data.get("app", "YOUTUBE_CLIENT_ID"), this.user.data.get("app", "YOUTUBE_CLIENT_SECRET"), - OAuth2Service.getCallbackUrl(clientHost, clientPort), + redirectUri, ); const response = await auth.getToken(code); @@ -153,6 +171,62 @@ export default class YouTubeAuth { return response.tokens; } + /** + * Refresh YouTube tokens + */ + async refresh() { + this.user.log.trace("YouTubeAuth", "refresh"); + const auth = new OAuth2Client( + this.user.data.get("app", "YOUTUBE_CLIENT_ID"), + this.user.data.get("app", "YOUTUBE_CLIENT_SECRET"), + ); + auth.setCredentials({ + access_token: this.user.data.get("auth", "YOUTUBE_ACCESS_TOKEN"), + refresh_token: this.user.data.get("auth", "YOUTUBE_REFRESH_TOKEN"), + }); + // perhaps check if refresh is needed soon ? + const response = (await auth.refreshAccessToken()) as { + res?: { data: Credentials }; + credentials?: Credentials; + }; + if (response["res"]?.["data"] && isCredentials(response["res"]["data"])) { + await this.store(response["res"]["data"]); + return; + } else if (response.credentials) { + await this.store(response.credentials); + return; + } + throw this.user.log.error( + "YouTubeAuth.refresh", + "not a valid response", + response, + ); + } + + /** + * Get or create a YouTube client + * @returns - youtube_v3.Youtube + */ + public getClient(): youtube_v3.Youtube { + if (this.client) { + return this.client; + } + const auth = new OAuth2Client( + this.user.data.get("app", "YOUTUBE_CLIENT_ID"), + this.user.data.get("app", "YOUTUBE_CLIENT_SECRET"), + ); + auth.setCredentials({ + access_token: this.user.data.get("auth", "YOUTUBE_ACCESS_TOKEN"), + refresh_token: this.user.data.get("auth", "YOUTUBE_REFRESH_TOKEN"), + }); + auth.on("tokens", async (creds) => { + this.user.log.trace("YouTubeAuth", "tokens event received"); + await this.store(creds); + }); + this.client = new youtube_v3.Youtube({ auth }); + return this.client; + } + /** * Save all tokens in auth store * @param creds - contains the tokens to store diff --git a/src/services/Fairpost.ts b/src/services/Fairpost.ts index 73751a6..7991988 100644 --- a/src/services/Fairpost.ts +++ b/src/services/Fairpost.ts @@ -302,7 +302,11 @@ class Fairpost { "Connect payload must be an object", ); } - const platform = await user.addPlatform(args.platform); + const platform = await user.getPlatform(args.platform); + if (!platform.active) { + platform.active = true; + await platform.save(); + } const result = await platform.connect( operator, args.payload as object, diff --git a/src/types/OAuth.ts b/src/types/OAuth.ts new file mode 100644 index 0000000..5e0aba8 --- /dev/null +++ b/src/types/OAuth.ts @@ -0,0 +1,169 @@ +/** + * The OAuthRequest and OAuthResponse types + * wrap the OAuth process. The server only serves + * as a OAuth broker / token-exchange gateway and has no state here; + * the client connects all the dots. + * + * There are several steps in the oauth process, but + * all steps can now carry an OAuthRequest and will + * receive an OAuthResponse in return. The "phase" + * tells you what step you are in. + * + * I'm not using 'device' mode at all; but I wanted all + * flows in here to get the interfaces correct :-) + * + * In Phase "Start", + * - the client has optionally generated a state + * - it passes the redirect_uri for the third party + * - the response returns a request_uri for the client to follow + * + * In Phase "Polling" (device) + * - the client keeps sending those requests until it + * receives a single "finish" response + * + * In Phase "Finish" + * - the client has optionally checked the state + * - the client has received a code (basic,pkce) or + * a token (implicit), or the server received the tokens (device) + * - the server exchanges the code and request uri for tokens (basic,pkce) + * - the server stores these + * - the server returns success or failure + * + */ + +type OAuthRequestStart = + | { + phase: "start"; + flow: "implicit"; + redirect_uri: string; + state?: string; + } + | { + phase: "start"; + flow: "basic"; + redirect_uri: string; + state?: string; + } + | { + phase: "start"; + flow: "pkce"; + redirect_uri: string; + state?: string; + code_challenge: string; + code_challenge_method: "S256"; + } + | { + phase: "start"; + flow: "device"; + client_id: string; + scope?: string; + }; + +type OAuthResponseStart = + | { + phase: "start"; + flow: "implicit"; + request_uri: string; + } + | { + phase: "start"; + flow: "basic"; + request_uri: string; + } + | { + phase: "start"; + flow: "pkce"; + request_uri: string; + } + | { + phase: "start"; + flow: "device"; + device_code: string; + user_code: string; + verification_uri: string; + expires_in: number; + interval: number; + }; + +type OAuthRequestPolling = { + phase: "polling"; + flow: "device"; + device_code: string; + poll_attempt?: number; + interval_hint_ms?: number; +}; + +type OAuthResponsePolling = { + phase: "polling"; + flow: "device"; + error: "authorization_pending" | "slow_down"; +}; + +type OAuthRequestFinish = + | { + phase: "finish"; + flow: "implicit"; + redirect_uri: string; + access_token: string; + token_type: "Bearer"; + expires_in?: number; + } + | { + phase: "finish"; + flow: "basic"; + redirect_uri: string; + code: string; + error: undefined; // discriminate on error !== undefined + } + | { + phase: "finish"; + flow: "basic"; + error: string; + error_uri?: string; + error_description?: string; + } + | { + phase: "finish"; + flow: "pkce"; + redirect_uri: string; + code: string; + code_verifier: string; + error: undefined; // discriminate on error !== undefined + } + | { + phase: "finish"; + flow: "pkce"; + error: string; + error_uri?: string; + error_description?: string; + }; + +type OAuthResponseFinish = + | { + phase: "finish"; + flow: "implicit" | "basic" | "pkce" | "device"; + authenticated: true; + results?: unknown; + error?: never; + } + | { + phase: "finish"; + flow: "basic" | "pkce"; + authenticated: false; + error?: string; + } + | { + phase: "finish"; + flow: "device"; + authenticated?: never; + error: "access_denied" | "expired_token"; + }; + +export type OAuthRequest = + | OAuthRequestStart + | OAuthRequestPolling + | OAuthRequestFinish; +export type OAuthResponse = + | OAuthResponseStart + | OAuthResponsePolling + | OAuthResponseFinish; diff --git a/src/types/PlatformDto.ts b/src/types/PlatformDto.ts index 3da61b4..97d6312 100644 --- a/src/types/PlatformDto.ts +++ b/src/types/PlatformDto.ts @@ -3,6 +3,7 @@ export default interface PlatformDto { id: string; user_id: string; active?: boolean; + connected?: boolean; // more fields added by platform [key: string]: string | string[] | number | boolean | undefined; } diff --git a/src/types/index.ts b/src/types/index.ts index 71e8bad..55bc5bc 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -14,3 +14,4 @@ export type { default as SourceDto } from "./SourceDto.ts"; export { default as SourceStage } from "./SourceStage.ts"; export type { default as UserDto } from "./UserDto.ts"; export type { default as UserReport } from "./UserReport.ts"; +export type { OAuthRequest, OAuthResponse } from "./OAuth.ts";