diff --git a/.env.dist b/.env.dist index 038b3bb..1851299 100644 --- a/.env.dist +++ b/.env.dist @@ -8,7 +8,7 @@ FAIRPOST_LOGGER_LEVEL=trace FAIRPOST_LOGGER_CONSOLE=false FAIRPOST_STORAGE_SETTINGS=env FAIRPOST_STORAGE_AUTH=json -FAIRPOST_STORAGE_JSONPATH=var/run/storage.json +FAIRPOST_STORAGE_JSONPATH=var/lib/storage.json FAIRPOST_USER_AGENT=Fairpost 1.0 # feed settings diff --git a/src/platforms/Ayrshare/Ayrshare.ts b/src/platforms/Ayrshare/Ayrshare.ts index 5a89d25..458fb99 100644 --- a/src/platforms/Ayrshare/Ayrshare.ts +++ b/src/platforms/Ayrshare/Ayrshare.ts @@ -1,6 +1,12 @@ import * as fs from "fs"; import * as path from "path"; +import { + ApiResponseError, + handleApiError, + handleJsonResponse, +} from "../../utilities"; + import Folder from "../../models/Folder"; import Logger from "../../services/Logger"; import Platform from "../../models/Platform"; @@ -35,6 +41,21 @@ export default abstract class Ayrshare extends Platform { super(); } + /** @inheritdoc */ + async test() { + const APIKEY = Storage.get("settings", "AYRSHARE_API_KEY"); + return await fetch("https://app.ayrshare.com/api/user", { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${APIKEY}`, + }, + }) + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleAyrshareError(err)) + .catch((err) => handleApiError(err)); + } + /** @inheritdoc */ async preparePost(folder: Folder): Promise { return super.preparePost(folder); @@ -114,8 +135,9 @@ export default abstract class Ayrshare extends Platform { }, }, ) - .then((res) => this.handleApiResponse(res)) - .catch((err) => this.handleApiError(err))) as { + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleAyrshareError(err)) + .catch((err) => handleApiError(err))) as { uploadUrl: string; contentType: string; accessUrl: string; @@ -180,8 +202,9 @@ export default abstract class Ayrshare extends Platform { }, body: body, }) - .then((res) => this.handleApiResponse(res)) - .catch((err) => this.handleApiError(err))) as { + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleAyrshareError(err)) + .catch((err) => handleApiError(err))) as { id: string; status?: string; }; @@ -197,49 +220,43 @@ export default abstract class Ayrshare extends Platform { } /** - * Handle api response - * @param response - the api response - * @returns parsed data from response + * Handle api error + * + * Improve error message and rethrow it. + * @param error - ApiResponseError */ - private async handleApiResponse(response: Response): Promise { - if (!response.ok) { - throw Logger.error( - "Ayrshare.handleApiResponse", - response, - response.status + ":" + response.statusText, - ); - } - const data = await response.json(); - if (data.status === "error") { - let error = response.status + ":"; - data.status.errors.forEach( - (err: { - action: string; - platform: string; - code: number; - message: string; - }) => { - error += - err.action + + private async handleAyrshareError(error: ApiResponseError): Promise { + if (error.responseData) { + if (error.responseData.status === "error") { + error.message += ": "; + if (error.responseData.errors) { + error.responseData.errors.forEach( + (err: { + action: string; + platform: string; + code: number; + message: string; + }) => { + error.message += + err.action + + "(" + + err.code + + "/" + + err.platform + + ") " + + err.message; + }, + ); + } else { + error.message += + error.responseData.action + "(" + - err.code + - "/" + - err.platform + + error.responseData.code + ") " + - err.message; - }, - ); - throw Logger.error("Ayrshare.handleApiResponse", error); + error.responseData.message; + } + } } - Logger.trace("Ayrshare.handleApiResponse", "success"); - return data; - } - - /** - * Handle api error - * @param error - the error thrown from the api - */ - private handleApiError(error: Error) { - throw Logger.error("Ayrshare.handleApiError", error); + throw error; } } diff --git a/src/platforms/Facebook/Facebook.ts b/src/platforms/Facebook/Facebook.ts index e9f4632..c613daf 100644 --- a/src/platforms/Facebook/Facebook.ts +++ b/src/platforms/Facebook/Facebook.ts @@ -42,6 +42,7 @@ export default class Facebook extends Platform { /** @inheritdoc */ async preparePost(folder: Folder): Promise { + Logger.trace("Facebook.preparePost", folder.id); const post = await super.preparePost(folder); if (post && post.files) { // facebook: video post can only contain 1 video @@ -157,7 +158,7 @@ export default class Facebook extends Platform { body.set("published", published ? "true" : "false"); body.set("source", blob, path.basename(file)); - const result = (await this.api.postFormData("%PAGE%/photos", body)) as { + const result = (await this.api.postForm("%PAGE%/photos", body)) as { id: "string"; }; @@ -193,7 +194,7 @@ export default class Facebook extends Platform { body.set("published", Storage.get("settings", "FACEBOOK_PUBLISH_POSTS")); body.set("source", blob, path.basename(file)); - const result = (await this.api.postFormData("%PAGE%/videos", body)) as { + const result = (await this.api.postForm("%PAGE%/videos", body)) as { id: string; }; diff --git a/src/platforms/Facebook/FacebookApi.ts b/src/platforms/Facebook/FacebookApi.ts index d393c40..ed69bc2 100644 --- a/src/platforms/Facebook/FacebookApi.ts +++ b/src/platforms/Facebook/FacebookApi.ts @@ -1,3 +1,9 @@ +import { + ApiResponseError, + handleApiError, + handleJsonResponse, +} from "../../utilities"; + import Logger from "../../services/Logger"; import Storage from "../../services/Storage"; @@ -35,8 +41,9 @@ export default class FacebookApi { "Bearer " + Storage.get("auth", "FACEBOOK_PAGE_ACCESS_TOKEN"), }, }) - .then((res) => this.handleApiResponse(res)) - .catch((err) => this.handleApiError(err)); + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleFacebookError(err)) + .catch((err) => handleApiError(err)); } /** @@ -63,12 +70,13 @@ export default class FacebookApi { Accept: "application/json", "Content-Type": "application/json", Authorization: - "Bearer " + Storage.get("settings", "FACEBOOK_PAGE_ACCESS_TOKEN"), + "Bearer " + Storage.get("auth", "FACEBOOK_PAGE_ACCESS_TOKEN"), }, body: JSON.stringify(body), }) - .then((res) => this.handleApiResponse(res)) - .catch((err) => this.handleApiError(err)); + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleFacebookError(err)) + .catch((err) => handleApiError(err)); } /** @@ -77,7 +85,7 @@ export default class FacebookApi { * @param body - body as object */ - public async postFormData(endpoint: string, body: FormData): Promise { + public async postForm(endpoint: string, body: FormData): Promise { endpoint = endpoint.replace( "%PAGE%", Storage.get("settings", "FACEBOOK_PAGE_ID"), @@ -92,50 +100,35 @@ export default class FacebookApi { headers: { Accept: "application/json", Authorization: - "Bearer " + Storage.get("settings", "FACEBOOK_PAGE_ACCESS_TOKEN"), + "Bearer " + Storage.get("auth", "FACEBOOK_PAGE_ACCESS_TOKEN"), }, body: body, }) - .then((res) => this.handleApiResponse(res)) - .catch((err) => this.handleApiError(err)); - } - - /** - * Handle api response - * @param response - api response from fetch - * @returns parsed object from response - */ - private async handleApiResponse(response: Response): Promise { - if (!response.ok) { - throw Logger.error( - "Facebook.handleApiResponse", - response, - response.status + ":" + response.statusText, - ); - } - const data = await response.json(); - if (data.error) { - const error = - response.status + - ":" + - data.error.type + - "(" + - data.error.code + - "/" + - data.error.error_subcode + - ") " + - data.error.message; - throw Logger.error("Facebook.handleApiResponse", error); - } - Logger.trace("Facebook.handleApiResponse", "success"); - return data; + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleFacebookError(err)) + .catch((err) => handleApiError(err)); } /** * Handle api error - * @param error - the error returned from fetch + * + * Improve error message and rethrow it. + * @param error - ApiResponseError */ - private handleApiError(error: Error): never { - throw Logger.error("Facebook.handleApiError", error); + private async handleFacebookError(error: ApiResponseError): Promise { + if (error.responseData) { + if (error.responseData.error) { + error.message += + ": " + + error.responseData.error.type + + " (" + + error.responseData.error.code + + "/" + + (error.responseData.error.error_subcode || "0") + + "): " + + error.responseData.error.message; + } + } + throw error; } } diff --git a/src/platforms/Facebook/FacebookAuth.ts b/src/platforms/Facebook/FacebookAuth.ts index c88b712..d03341c 100644 --- a/src/platforms/Facebook/FacebookAuth.ts +++ b/src/platforms/Facebook/FacebookAuth.ts @@ -1,6 +1,13 @@ +import { + ApiResponseError, + handleApiError, + handleJsonResponse, +} from "../../utilities"; + import Logger from "../../services/Logger"; import OAuth2Service from "../../services/OAuth2Service"; import Storage from "../../services/Storage"; +import { strict as assert } from "assert"; export default class FacebookAuth { GRAPH_API_VERSION: string = "v18.0"; @@ -74,19 +81,21 @@ export default class FacebookAuth { ): Promise { const redirectUri = OAuth2Service.getCallbackUrl(); - const result = await this.get("oauth/access_token", { + const tokens = (await this.get("oauth/access_token", { client_id: clientId, client_secret: clientSecret, code: code, redirect_uri: redirectUri, - }); + })) as TokenResponse; - if (!result["access_token"]) { - const msg = "Remote response did not return a access_token"; - throw Logger.error(msg, result); + if (!isTokenResponse(tokens)) { + throw Logger.error( + "FacebookAuth.exchangeCode: response is not a TokenResponse", + tokens, + ); } - return result["access_token"]; + return tokens["access_token"]; } /** @@ -155,17 +164,18 @@ export default class FacebookAuth { client_secret: appSecret, fb_exchange_token: userAccessToken, }; - const data = (await this.get("oauth/access_token", query)) as { - access_token: string; - }; - if (!data["access_token"]) { + const tokens = (await this.get( + "oauth/access_token", + query, + )) as TokenResponse; + + if (!isTokenResponse(tokens)) { throw Logger.error( - "No llUserAccessToken access_token in response.", - data, + "FacebookAuth.getLLUserAccessToken: response is not a TokenResponse", + tokens, ); } - - return data["access_token"]; + return tokens["access_token"]; } /** @@ -210,46 +220,44 @@ export default class FacebookAuth { Accept: "application/json", }, }) - .then((res) => this.handleApiResponse(res)) - .catch((err) => this.handleApiError(err)); + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleFacebookError(err)) + .catch((err) => handleApiError(err)); } /** - * Handle api response - * @param response - api response from fetch - * @returns parsed object from response + * Handle api error + * + * Improve error message and rethrow it. + * @param error - ApiResponseError */ - private async handleApiResponse(response: Response): Promise { - if (!response.ok) { - throw Logger.error( - "FacebookAuth.handleApiResponse", - response.status + ":" + response.statusText, - response, - ); - } - const data = await response.json(); - if (data.error) { - const error = - response.status + - ":" + - data.error.type + - "(" + - data.error.code + - "/" + - data.error.error_subcode + - ") " + - data.error.message; - throw Logger.error("FacebookAuth.handleApiResponse", error); + private async handleFacebookError(error: ApiResponseError): Promise { + if (error.responseData) { + if (error.responseData.error) { + error.message += + ": " + + error.responseData.error.type + + " (" + + error.responseData.error.code + + "/" + + (error.responseData.error.error_subcode || "0") + + "): " + + error.responseData.error.message; + } } - Logger.trace("FacebookAuth.handleApiResponse", "success"); - return data; + throw error; } +} - /** - * Handle api error - * @param error - the error returned from fetch - */ - private handleApiError(error: Error): never { - throw Logger.error("FacebookAuth.handleApiError", error); +interface TokenResponse { + access_token: string; +} + +function isTokenResponse(tokens: TokenResponse) { + try { + assert("access_token" in tokens); + } catch (e) { + return false; } + return true; } diff --git a/src/platforms/Instagram/Instagram.ts b/src/platforms/Instagram/Instagram.ts index 42e9ffa..3175f46 100644 --- a/src/platforms/Instagram/Instagram.ts +++ b/src/platforms/Instagram/Instagram.ts @@ -41,6 +41,7 @@ export default class Instagram extends Platform { /** @inheritdoc */ async preparePost(folder: Folder): Promise { + Logger.trace("Instagram.preparePost", folder.id); const post = await super.preparePost(folder); if (post && post.files) { // instagram: 1 video for reel diff --git a/src/platforms/Instagram/InstagramApi.ts b/src/platforms/Instagram/InstagramApi.ts index 7cb3c30..9ff2ebc 100644 --- a/src/platforms/Instagram/InstagramApi.ts +++ b/src/platforms/Instagram/InstagramApi.ts @@ -1,3 +1,9 @@ +import { + ApiResponseError, + handleApiError, + handleJsonResponse, +} from "../../utilities"; + import Logger from "../../services/Logger"; import Storage from "../../services/Storage"; @@ -44,8 +50,9 @@ export default class InstagramApi { Accept: "application/json", }, }) - .then((res) => this.handleApiResponse(res)) - .catch((err) => this.handleApiError(err)); + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleInstagramError(err)) + .catch((err) => handleApiError(err)); } /** @@ -81,8 +88,9 @@ export default class InstagramApi { }, body: JSON.stringify(body), }) - .then((res) => this.handleApiResponse(res)) - .catch((err) => this.handleApiError(err)); + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleInstagramError(err)) + .catch((err) => handleApiError(err)); } /** @@ -111,46 +119,35 @@ export default class InstagramApi { headers: { Accept: "application/json", Authorization: - "Bearer " + Storage.get("settings", "INSTAGRAM_PAGE_ACCESS_TOKEN"), + "Bearer " + Storage.get("auth", "INSTAGRAM_PAGE_ACCESS_TOKEN"), }, body: body, }) - .then((res) => this.handleApiResponse(res)) - .catch((err) => this.handleApiError(err)); - } - - /** - * Handle api response - * @param response - the api response from fetch - * @returns the parsed response - */ - private async handleApiResponse(response: Response): Promise { - if (!response.ok) { - throw Logger.error("Ayrshare.handleApiResponse", response); - } - const data = await response.json(); - if (data.error) { - const error = - response.status + - ":" + - data.error.type + - "(" + - data.error.code + - "/" + - data.error.error_subcode + - ") " + - data.error.message; - throw Logger.error("Facebook.handleApiResponse", error); - } - Logger.trace("Facebook.handleApiResponse", "success"); - return data; + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleInstagramError(err)) + .catch((err) => handleApiError(err)); } /** * Handle api error - * @param error - the api error returned from fetch + * + * Improve error message and rethrow it. + * @param error - ApiResponseError */ - private handleApiError(error: Error): never { - throw Logger.error("Facebook.handleApiError", error); + private async handleInstagramError(error: ApiResponseError): Promise { + if (error.responseData) { + if (error.responseData.error) { + error.message += + ": " + + error.responseData.error.type + + " (" + + error.responseData.error.code + + "/" + + (error.responseData.error.error_subcode || "0") + + "): " + + error.responseData.error.message; + } + } + throw error; } } diff --git a/src/platforms/LinkedIn/LinkedIn.ts b/src/platforms/LinkedIn/LinkedIn.ts index f8c3249..1c22b08 100644 --- a/src/platforms/LinkedIn/LinkedIn.ts +++ b/src/platforms/LinkedIn/LinkedIn.ts @@ -2,6 +2,8 @@ import * as fs from "fs"; //import * as path from "path"; import * as sharp from "sharp"; +import { handleApiError, handleEmptyResponse } from "../../utilities"; + import Folder from "../../models/Folder"; import LinkedInApi from "./LinkedInApi"; import LinkedInAuth from "./LinkedInAuth"; @@ -52,6 +54,7 @@ export default class LinkedIn extends Platform { } async preparePost(folder: Folder): Promise { + Logger.trace("LinkedIn.preparePost", folder.id); const post = await super.preparePost(folder); if (post) { // linkedin: prefer video, max 1 video @@ -132,8 +135,10 @@ export default class LinkedIn extends Platform { } } - if (response.headers["x-restli-id"]) { + if (response.headers?.["x-restli-id"]) { response.id = response.headers["x-restli-id"]; + } else if (response.headers?.["x-linkedin-id"]) { + response.id = response.headers["x-linkedin-id"]; } post.results.push({ @@ -176,6 +181,7 @@ export default class LinkedIn extends Platform { } private async publishText(content: string) { + Logger.trace("LinkedIn.publishText"); const body = { author: this.POST_AUTHOR, commentary: content, @@ -184,11 +190,14 @@ export default class LinkedIn extends Platform { lifecycleState: "PUBLISHED", isReshareDisabledByAuthor: this.POST_NORESHARE, }; - return await this.api.postJson("posts", body); + return await this.api.postJson("posts", body, true); } private async publishImage(title: string, content: string, image: string) { + Logger.trace("LinkedIn.publishImage"); const leash = await this.getImageLeash(); await this.uploadImage(leash.value.uploadUrl, image); + // TODO: save headers[etag] .. + // https://learn.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/videos-api?view=li-lms-2023-10&tabs=http#sample-response-4 const body = { author: this.POST_AUTHOR, commentary: content, @@ -203,14 +212,17 @@ export default class LinkedIn extends Platform { lifecycleState: "PUBLISHED", isReshareDisabledByAuthor: this.POST_NORESHARE, }; - return await this.api.postJson("posts", body); + return await this.api.postJson("posts", body, true); } private async publishImages(content: string, images: string[]) { + Logger.trace("LinkedIn.publishImages"); const imageIds = []; for (const image of images) { const leash = await this.getImageLeash(); await this.uploadImage(leash.value.uploadUrl, image); + // TODO: save headers[etag] .. + // https://learn.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/videos-api?view=li-lms-2023-10&tabs=http#sample-response-4 imageIds.push(leash.value.image); } @@ -232,13 +244,16 @@ export default class LinkedIn extends Platform { }, }, }; - return await this.api.postJson("posts", body); + return await this.api.postJson("posts", body, true); } // untested private async publishVideo(title: string, content: string, video: string) { + Logger.trace("LinkedIn.publishVideo"); const leash = await this.getVideoLeash(video); await this.uploadVideo(leash.value.uploadInstructions[0].uploadUrl, video); + // TODO: save headers[etag] .. + // https://learn.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/videos-api?view=li-lms-2023-10&tabs=http#sample-response-4 const body = { author: this.POST_AUTHOR, commentary: content, @@ -253,7 +268,7 @@ export default class LinkedIn extends Platform { lifecycleState: "PUBLISHED", isReshareDisabledByAuthor: this.POST_NORESHARE, }; - return await this.api.postJson("posts", body); + return await this.api.postJson("posts", body, true); } private async getImageLeash(): Promise<{ @@ -263,6 +278,7 @@ export default class LinkedIn extends Platform { image: string; }; }> { + Logger.trace("LinkedIn.getImageLeash"); const response = (await this.api.postJson( "images?action=initializeUpload", { @@ -284,6 +300,7 @@ export default class LinkedIn extends Platform { } private async uploadImage(leashUrl: string, file: string) { + Logger.trace("LinkedIn.uploadImage"); const rawData = fs.readFileSync(file); Logger.trace("PUT", leashUrl); const accessToken = Storage.get("auth", "LINKEDIN_ACCESS_TOKEN"); @@ -293,7 +310,10 @@ export default class LinkedIn extends Platform { Authorization: "Bearer " + accessToken, }, body: rawData, - }).then((res) => this.api.handleApiResponse(res)); + }) + .then((res) => handleEmptyResponse(res)) + .catch((err) => this.api.handleLinkedInError(err)) + .catch((err) => handleApiError(err)); } // untested @@ -309,9 +329,10 @@ export default class LinkedIn extends Platform { uploadToken: string; }; }> { + Logger.trace("LinkedIn.getVideoLeash"); const stats = fs.statSync(file); const response = (await this.api.postJson( - "images?videos=initializeUpload", + "videos?action=initializeUpload", { initializeUploadRequest: { owner: this.POST_AUTHOR, @@ -340,6 +361,7 @@ export default class LinkedIn extends Platform { // untested private async uploadVideo(leashUrl: string, file: string) { + Logger.trace("LinkedIn.uploadVideo"); const rawData = fs.readFileSync(file); Logger.trace("PUT", leashUrl); return await fetch(leashUrl, { @@ -348,6 +370,9 @@ export default class LinkedIn extends Platform { "Content-Type": "application/octet-stream", }, body: rawData, - }).then((res) => this.api.handleApiResponse(res)); + }) + .then((res) => handleEmptyResponse(res)) + .catch((err) => this.api.handleLinkedInError(err)) + .catch((err) => handleApiError(err)); } } diff --git a/src/platforms/LinkedIn/LinkedInApi.ts b/src/platforms/LinkedIn/LinkedInApi.ts index df0f2ba..9b33b4e 100644 --- a/src/platforms/LinkedIn/LinkedInApi.ts +++ b/src/platforms/LinkedIn/LinkedInApi.ts @@ -1,3 +1,10 @@ +import { + ApiResponseError, + handleApiError, + handleEmptyResponse, + handleJsonResponse, +} from "../../utilities"; + import Logger from "../../services/Logger"; import Storage from "../../services/Storage"; @@ -36,8 +43,9 @@ export default class LinkedInApi { "User-Agent": Storage.get("settings", "USER_AGENT"), }, }) - .then((res) => this.handleApiResponse(res)) - .catch((err) => this.handleApiError(err)); + .then((res) => handleJsonResponse(res, true)) + .catch((err) => this.handleLinkedInError(err)) + .catch((err) => handleApiError(err)); } /** @@ -46,7 +54,11 @@ export default class LinkedInApi { * @param body - body as object */ - public async postJson(endpoint: string, body = {}): Promise { + public async postJson( + endpoint: string, + body = {}, + expectEmptyResponse = false, + ): Promise { const url = new URL("https://api.linkedin.com"); const [pathname, search] = endpoint.split("?"); @@ -66,56 +78,37 @@ export default class LinkedInApi { Authorization: "Bearer " + accessToken, }, body: JSON.stringify(body), - }).then((res) => this.handleApiResponse(res)); - //.catch((err) => this.handleApiError(err)); + }) + .then((res) => + expectEmptyResponse + ? handleEmptyResponse(res, true) + : handleJsonResponse(res, true), + ) + .catch((err) => this.handleLinkedInError(err)) + .catch((err) => handleApiError(err)); } - /* - * Handle api response + /** + * Handle api error * + * Improve error message and rethrow it. + * @param error - ApiResponseError */ - public async handleApiResponse(response: Response): Promise { - const text = await response.text(); - let data = {} as { [key: string]: unknown }; - try { - data = JSON.parse(text); - } catch (err) { - data["text"] = text; - } - if (!response.ok) { - Logger.warn("Linkedin.handleApiResponse", response); - Logger.warn(response.headers); - const linkedInErrorResponse = - response.headers["x-linkedin-error-response"]; - - const error = - response.status + - ":" + - response.statusText + + public async handleLinkedInError(error: ApiResponseError): Promise { + if (error.responseData) { + error.message += " (" + - data.status + + error.responseData.status + "/" + - data.serviceErrorCode + + error.responseData.serviceErrorCode + ") " + - data.message + - " - " + - linkedInErrorResponse; - - throw Logger.error(error); + error.responseData.message; } - data["headers"] = {}; - for (const [name, value] of response.headers) { - data["headers"][name] = value; + if (error.response?.headers?.["x-linkedin-error-response"]) { + error.message += + " - " + error.response?.headers["x-linkedin-error-response"]; } - Logger.trace("Linkedin.handleApiResponse", "success"); - return data; - } - /* - * Handle api error - * - */ - public handleApiError(error: Error): Promise { - throw Logger.error("Linkedin.handleApiError", error); + throw error; } } diff --git a/src/platforms/LinkedIn/LinkedInAuth.ts b/src/platforms/LinkedIn/LinkedInAuth.ts index 884c471..6db3da8 100644 --- a/src/platforms/LinkedIn/LinkedInAuth.ts +++ b/src/platforms/LinkedIn/LinkedInAuth.ts @@ -1,3 +1,9 @@ +import { + ApiResponseError, + handleApiError, + handleJsonResponse, +} from "../../utilities"; + import Logger from "../../services/Logger"; import OAuth2Service from "../../services/OAuth2Service"; import Storage from "../../services/Storage"; @@ -148,39 +154,22 @@ export default class LinkedInAuth { "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams(body), - }).then((res) => this.handleApiResponse(res)); + }) + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleLinkedInError(err)) + .catch((err) => handleApiError(err)); } /** - * Handle api response - * @param response - api response from fetch - * @returns parsed object from response + * Handle api error + * + * Improve error message and rethrow it. + * @param error - ApiResponseError */ - private async handleApiResponse(response: Response): Promise { - if (!response.ok) { - Logger.warn("LinkedInAuth.handleApiResponse", "not ok"); - throw Logger.error( - "LinkedInAuth.handleApiResponse", - response.url + ":" + response.status + ", " + response.statusText, - await response.text(), - ); - } - const data = await response.json(); - if (data.error) { - const error = - response.status + - ":" + - data.error.type + - "(" + - data.error.code + - "/" + - data.error.error_subcode + - ") " + - data.error.message; - throw Logger.error("LinkedInAuth.handleApiResponse", error); - } - Logger.trace("LinkedInAuth.handleApiResponse", "success"); - return data; + public async handleLinkedInError(error: ApiResponseError): Promise { + // it appears the linkedin oauth error + // is standard - http code 4xx, carrying a message + throw error; } } diff --git a/src/platforms/Reddit/Reddit.ts b/src/platforms/Reddit/Reddit.ts index 4e23a61..14b0281 100644 --- a/src/platforms/Reddit/Reddit.ts +++ b/src/platforms/Reddit/Reddit.ts @@ -52,6 +52,7 @@ export default class Reddit extends Platform { } async preparePost(folder: Folder): Promise { + Logger.trace("Reddit.preparePost", folder.id); const post = await super.preparePost(folder); if (post) { // reddit: max 1 image or video @@ -232,7 +233,7 @@ export default class Reddit extends Platform { form.append("filepath", filename); form.append("mimetype", mimetype); - const lease = (await this.api.postFormData("media/asset.json", form)) as { + const lease = (await this.api.postForm("media/asset.json", form)) as { args: { action: string; fields: { diff --git a/src/platforms/Reddit/RedditApi.ts b/src/platforms/Reddit/RedditApi.ts index 8b1c767..fec99e3 100644 --- a/src/platforms/Reddit/RedditApi.ts +++ b/src/platforms/Reddit/RedditApi.ts @@ -1,3 +1,9 @@ +import { + ApiResponseError, + handleApiError, + handleJsonResponse, +} from "../../utilities"; + import Logger from "../../services/Logger"; import Storage from "../../services/Storage"; @@ -33,8 +39,9 @@ export default class RedditApi { "User-Agent": Storage.get("settings", "USER_AGENT"), }, }) - .then((res) => this.handleApiResponse(res)) - .catch((err) => this.handleApiError(err)); + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleRedditError(err)) + .catch((err) => handleApiError(err)); } /** @@ -64,8 +71,9 @@ export default class RedditApi { }, body: new URLSearchParams(body), }) - .then((res) => this.handleApiResponse(res)) - .catch((err) => this.handleApiError(err)); + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleRedditError(err)) + .catch((err) => handleApiError(err)); } /** @@ -74,7 +82,7 @@ export default class RedditApi { * @param body - body as object */ - public async postFormData(endpoint: string, body: FormData): Promise { + public async postForm(endpoint: string, body: FormData): Promise { const url = new URL("https://oauth.reddit.com"); //url.pathname = "api/" + this.API_VERSION + "/" + endpoint; url.pathname = "api/" + endpoint; @@ -91,42 +99,32 @@ export default class RedditApi { }, body: body, }) - .then((res) => this.handleApiResponse(res)) - .catch((err) => this.handleApiError(err)); - } - - /** - * Handle api response - * @param response - api response from fetch - * @returns parsed object from response - */ - private async handleApiResponse(response: Response): Promise { - if (!response.ok) { - throw Logger.error( - "Reddit.handleApiResponse", - "not ok", - response.status + ":" + response.statusText, - ); - } - const data = await response.json(); - if (data.json?.errors?.length) { - const error = - response.status + - ":" + - data.json.errors[0] + - "-" + - data.json.errors.slice(1).join(); - throw Logger.error("Reddit.handleApiResponse", error); - } - Logger.trace("Reddit.handleApiResponse", "success"); - return data; + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleRedditError(err)) + .catch((err) => handleApiError(err)); } /** * Handle api error - * @param error - the error returned from fetch + * + * Improve error message and rethrow it. + * @param error - ApiResponseError */ - private handleApiError(error: Error): never { - throw Logger.error("Reddit.handleApiError", error); + private async handleRedditError(error: ApiResponseError): Promise { + if (error.responseData) { + if (error.responseData.json?.errors?.length) { + error.message += + ":" + + error.responseData.json.errors[0] + + "-" + + error.responseData.json.errors.slice(1).join(); + } + } else { + if (error instanceof SyntaxError) { + // response.json() Unexpected token < in JSON + error.message += "- perhaps refresh your tokens"; + } + } + throw error; } } diff --git a/src/platforms/Reddit/RedditAuth.ts b/src/platforms/Reddit/RedditAuth.ts index 0398f2e..27ae137 100644 --- a/src/platforms/Reddit/RedditAuth.ts +++ b/src/platforms/Reddit/RedditAuth.ts @@ -1,3 +1,9 @@ +import { + ApiResponseError, + handleApiError, + handleJsonResponse, +} from "../../utilities"; + import Logger from "../../services/Logger"; import OAuth2Service from "../../services/OAuth2Service"; import Storage from "../../services/Storage"; @@ -149,47 +155,21 @@ export default class RedditAuth { }, body: new URLSearchParams(body), }) - .then((res) => this.handleApiResponse(res)) - .catch((err) => this.handleApiError(err)); - } - - /** - * Handle api response - * @param response - api response from fetch - * @returns parsed object from response - */ - private async handleApiResponse(response: Response): Promise { - if (!response.ok) { - throw Logger.error( - "RedditAuth.handleApiResponse", - "not ok", - response.status + ":" + response.statusText, - ); - } - const data = await response.json(); - if (data.error) { - const error = - response.status + - ":" + - data.error.type + - "(" + - data.error.code + - "/" + - data.error.error_subcode + - ") " + - data.error.message; - throw Logger.error("RedditAuth.handleApiResponse", error); - } - Logger.trace("RedditAuth.handleApiResponse", "success"); - return data; + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleRedditError(err)) + .catch((err) => handleApiError(err)); } /** * Handle api error - * @param error - the error returned from fetch + * + * Improve error message and rethrow it. + * @param error - ApiResponseError */ - private handleApiError(error: Error): never { - throw Logger.error("RedditAuth.handleApiError", error); + private async handleRedditError(error: ApiResponseError): Promise { + // it appears the reddit oauth error + // is standard - http code 4xx, carrying a message + throw error; } } diff --git a/src/platforms/Twitter/Twitter.ts b/src/platforms/Twitter/Twitter.ts index 0840035..abe5afc 100644 --- a/src/platforms/Twitter/Twitter.ts +++ b/src/platforms/Twitter/Twitter.ts @@ -52,6 +52,7 @@ export default class Twitter extends Platform { } async preparePost(folder: Folder): Promise { + Logger.trace("Twitter.preparePost", folder.id); const post = await super.preparePost(folder); if (post) { // twitter: no video diff --git a/src/services/Storage.ts b/src/services/Storage.ts index 6390366..9ec8bc2 100644 --- a/src/services/Storage.ts +++ b/src/services/Storage.ts @@ -108,7 +108,7 @@ class Storage { private loadJson() { const jsonFile = - process.env.FAIRPOST_STORAGE_JSONPATH || "var/run/storage.json"; + process.env.FAIRPOST_STORAGE_JSONPATH || "var/lib/storage.json"; if (fs.existsSync(jsonFile)) { const jsonData = JSON.parse(fs.readFileSync(jsonFile, "utf8")); if (jsonData) { @@ -121,7 +121,7 @@ class Storage { private saveJson() { const jsonFile = - process.env.FAIRPOST_STORAGE_JSONPATH || "var/run/storage.json"; + process.env.FAIRPOST_STORAGE_JSONPATH || "var/lib/storage.json"; if (!fs.existsSync(jsonFile)) { fs.mkdirSync(path.dirname(jsonFile), { recursive: true }); } diff --git a/src/utilities.ts b/src/utilities.ts new file mode 100644 index 0000000..d40571a --- /dev/null +++ b/src/utilities.ts @@ -0,0 +1,151 @@ +import Logger from "./services/Logger"; + +export class ApiResponseError extends Error { + response: Response; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + responseData: any; + responseText: string; + constructor(response: Response, data?: object | string) { + super("ApiResponseError: " + response.status + " " + response.statusText); + this.response = response; + if (data && typeof data === "object") { + this.responseData = data; + } + if (data && typeof data === "string") { + this.responseText = data; + } + } +} + +export async function handleApiResponse(response: Response): Promise { + return await handleBlobResponse(response); +} + +export async function handleEmptyResponse( + response: Response, + includeHeaders = false, +): Promise { + const data = {}; + if (includeHeaders) { + data["headers"] = {}; + for (const [name, value] of response.headers) { + data["headers"][name] = value; + } + } + if (!response.ok) { + // network error in the 3xx–5xx range + throw new ApiResponseError(response, data); + } + return data; +} + +export async function handleJsonResponse( + response: Response, + includeHeaders = false, +): Promise { + const data = await response.json(); // may throw a syntaxerror + if (includeHeaders) { + data["headers"] = {}; + for (const [name, value] of response.headers) { + data["headers"][name] = value; + } + } + if (!response.ok) { + // network error in the 3xx–5xx range + throw new ApiResponseError(response, data); + } + return data; +} + +export async function handleTextResponse(response: Response): Promise { + const data = await response.text(); + if (!response.ok) { + // network error in the 3xx–5xx range + throw new ApiResponseError(response, data); + } + return data; +} + +export async function handleBlobResponse(response: Response): Promise { + if (!response.ok) { + // network error in the 3xx–5xx range + throw new ApiResponseError(response); + } + return await response.blob(); +} + +export async function handleArrayBufferResponse( + response: Response, +): Promise { + if (!response.ok) { + // network error in the 3xx–5xx range + throw new ApiResponseError(response); + } + return await response.arrayBuffer(); +} + +export async function handleFormResponse( + response: Response, + includeHeaders = false, +): Promise { + const data = Object.fromEntries(await response.formData()) as object; + if (includeHeaders) { + data["headers"] = {}; + for (const [name, value] of response.headers) { + data["headers"][name] = value; + } + } + if (!response.ok) { + // network error in the 3xx–5xx range + throw new ApiResponseError(response, data); + } + return data; +} + +export async function handleApiError(error: ApiResponseError): Promise { + let errorMessage = error.message; + + const errorDetails = {} as { [key: string]: string | number | object }; + + // details added by ApiResponseError + if (error.response) { + errorDetails["status"] = error.response.status; + errorDetails["statusText"] = error.response.statusText; + errorDetails["url"] = error.response.url; + } + if (error.responseData) { + errorDetails["data"] = JSON.stringify(error.responseData); + } + if (error.responseText) { + errorDetails["text"] = error.responseText; + } + + // errors thrown by fetch + // https://github.com/node-fetch/node-fetch/blob/main/docs/ERROR-HANDLING.md + if (error.name === "AbortError") { + errorDetails["name"] = "AbortError"; + errorMessage += ": The request was Aborted"; + } + + if (error instanceof SyntaxError) { + // response.json() Unexpected token < in JSON + errorDetails["name"] = "SyntaxError"; + errorMessage += ": There was a SyntaxError in the response"; + } + + if (error.name === "FetchError") { + // codes added by node + errorDetails["name"] = "FetchError"; + if ("type" in error) { + errorDetails["type"] = error.type as number; + } + if ("code" in error) { + errorDetails["code"] = error.code as number; + } + if ("errno" in error) { + errorDetails["errno"] = error.errno as number; + } + } + + throw Logger.error(errorMessage, error.response?.url, errorDetails); +}