diff --git a/indiekit.config.js b/indiekit.config.js index b6a7ddf39..fb92d0c9b 100644 --- a/indiekit.config.js +++ b/indiekit.config.js @@ -90,6 +90,10 @@ const config = { accessKey: process.env.INTERNET_ARCHIVE_ACCESS_KEY, secretKey: process.env.INTERNET_ARCHIVE_SECRET_KEY, }, + "@indiekit/syndicator-linkedin": { + checked: true, + authorProfileUrl: process.env.LINKEDIN_AUTHOR_PROFILE_URL, + }, "@indiekit/syndicator-mastodon": { checked: true, url: process.env.MASTODON_URL, diff --git a/package-lock.json b/package-lock.json index 0a5308001..8bbcfa337 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3023,6 +3023,10 @@ "resolved": "packages/syndicator-internet-archive", "link": true }, + "node_modules/@indiekit/syndicator-linkedin": { + "resolved": "packages/syndicator-linkedin", + "link": true + }, "node_modules/@indiekit/syndicator-mastodon": { "resolved": "packages/syndicator-mastodon", "link": true @@ -13451,6 +13455,19 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/linkedin-api-client": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/linkedin-api-client/-/linkedin-api-client-0.3.0.tgz", + "integrity": "sha512-vxfjg0cpWtiMVYmAnjwppPLb5CI26bTPowiY6YZTAi/OjdQH1BWGD3i0gA8I9KcTgYdDHrY083zkHcmfK3jPCw==", + "dependencies": { + "axios": "^1.1.3", + "lodash": "^4.17.21", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", @@ -21295,6 +21312,21 @@ "node": ">=20" } }, + "packages/syndicator-linkedin": { + "name": "@indiekit/syndicator-linkedin", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@indiekit/error": "^1.0.0-beta.15", + "@indiekit/util": "^1.0.0-beta.16", + "brevity": "^0.2.9", + "html-to-text": "^9.0.0", + "linkedin-api-client": "^0.3.0" + }, + "engines": { + "node": ">=20" + } + }, "packages/syndicator-mastodon": { "name": "@indiekit/syndicator-mastodon", "version": "1.0.0-beta.16", diff --git a/packages/endpoint-linkedin/index.js b/packages/endpoint-linkedin/index.js index c6815d12a..02d23283d 100644 --- a/packages/endpoint-linkedin/index.js +++ b/packages/endpoint-linkedin/index.js @@ -143,16 +143,28 @@ export default class LinkedInEndpoint { response.redirect(this.authorizationUri); }); + // If the member chooses to cancel, or the request fails for any reason, + // the client is redirected to your redirect_uri with the following + // additional query parameters appended: error, error_description, state. // https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow?tabs=HTTPS1#member-approves-request router.get("/callback", async (request, response) => { debug(`GET ${this.mountPath}${request.path}`); + // https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow#failed-requests const { code, error, error_description, state } = request.query; if (error) { // TODO: display error page return response.status(400).json({ error, error_description }); } + // Before you use the authorization code, your application should ensure + // that the value returned in the state parameter matches the state value + // from your original authorization code request. This ensures that you + // are dealing with the real member and not a malicious script. + // If the state values do not match, you are likely the victim of a CSRF + // attack and your application should return a 401 Unauthorized error code + // in response. + // https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow#member-approves-request debug(`state received from LinkedIn: ${state}`); // TODO: should we check that `state` is the same as the one we sent in // the initial request? diff --git a/packages/syndicator-linkedin/README.md b/packages/syndicator-linkedin/README.md new file mode 100644 index 000000000..099d123b3 --- /dev/null +++ b/packages/syndicator-linkedin/README.md @@ -0,0 +1,31 @@ +# @indiekit/syndicator-linkedin + +[LinkedIn](https://www.linkedin.com/) syndicator for Indiekit. + +## Installation + +`npm i @indiekit/syndicator-linkedin` + +## Requirements + +todo + +## Usage + +Add `@indiekit/syndicator-linkedin` to your list of plug-ins, specifying options as required: + +```js +{ + "plugins": ["@indiekit/syndicator-linkedin"], + "@indiekit/syndicator-linkedin": { + "accessToken": process.env.LINKEDIN_ACCESS_TOKEN, + "clientId": process.env.LINKEDIN_CLIENT_ID, + "clientSecret": process.env.LINKEDIN_CLIENT_SECRET, + "checked": true + } +} +``` + +## Options + +todo diff --git a/packages/syndicator-linkedin/assets/icon.svg b/packages/syndicator-linkedin/assets/icon.svg new file mode 100644 index 000000000..3d428c234 --- /dev/null +++ b/packages/syndicator-linkedin/assets/icon.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/packages/syndicator-linkedin/index.js b/packages/syndicator-linkedin/index.js new file mode 100644 index 000000000..9eb6ff8db --- /dev/null +++ b/packages/syndicator-linkedin/index.js @@ -0,0 +1,144 @@ +import makeDebug from "debug"; +import { IndiekitError } from "@indiekit/error"; +import { createPost, userInfo } from "./lib/linkedin.js"; + +const debug = makeDebug(`indiekit-syndicator:linkedin`); + +const DEFAULTS = { + // The character limit for a LinkedIn post is 3000 characters. + // https://www.linkedin.com/help/linkedin/answer/a528176 + characterLimit: 3000, + + checked: false, + + // https://learn.microsoft.com/en-us/linkedin/marketing/versioning + // https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api + postsAPIVersion: "202401", +}; + +const retrieveAccessToken = async () => { + // the access token could be stored in an environment variable, in a database, etc + debug( + `retrieve LinkedIn access token from environment variable LINKEDIN_ACCESS_TOKEN`, + ); + + return process.env.LINKEDIN_ACCESS_TOKEN === undefined + ? { + error: new Error(`environment variable LINKEDIN_ACCESS_TOKEN not set`), + } + : { value: process.env.LINKEDIN_ACCESS_TOKEN }; +}; + +export default class LinkedInSyndicator { + /** + * @param {object} [options] - Plug-in options + * @param {string} [options.authorName] - Full name of the author + * @param {string} [options.authorProfileUrl] - LinkedIn profile URL of the author + * @param {number} [options.characterLimit] - LinkedIn post character limit + * @param {boolean} [options.checked] - Check syndicator in UI + * @param {string} [options.postsAPIVersion] - Version of the Linkedin /posts API to use + */ + constructor(options = {}) { + this.name = "LinkedIn syndicator"; + this.options = { ...DEFAULTS, ...options }; + } + + get environment() { + return ["LINKEDIN_ACCESS_TOKEN", "LINKEDIN_AUTHOR_PROFILE_URL"]; + } + + get info() { + const service = { + name: "LinkedIn", + photo: "/assets/@indiekit-syndicator-linkedin/icon.svg", + url: "https://www.linkedin.com/", + }; + + const name = this.options.authorName || "unknown LinkedIn author name"; + const uid = this.options.authorProfileUrl || "https://www.linkedin.com/"; + const url = + this.options.authorProfileUrl || "unknown LinkedIn author profile URL"; + + return { + checked: this.options.checked, + name, + service, + uid, + user: { name, url }, + }; + } + + get prompts() { + return [ + { + type: "text", + name: "postsAPIVersion", + message: "What is the LinkedIn Posts API version you want to use?", + description: "e.g. 202401", + }, + ]; + } + + async syndicate(properties, publication) { + // debug(`syndicate properties %O`, properties); + debug(`syndicate publication %O: `, { + categories: publication.categories, + me: publication.me, + }); + + const { error: tokenError, value: accessToken } = + await retrieveAccessToken(); + + if (tokenError) { + throw new IndiekitError(tokenError.message, { + cause: tokenError, + plugin: this.name, + status: 500, + }); + } + + let authorName; + // LinkedIn URN of the author. See https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/urns + let authorUrn; + try { + const userinfo = await userInfo({ accessToken }); + authorName = userinfo.name; + authorUrn = userinfo.urn; + } catch (error) { + throw new IndiekitError(error.message, { + cause: error, + plugin: this.name, + status: error.statusCode || 500, + }); + } + + // TODO: switch on properties['post-type'] // e.g. article, note + const text = properties.content.text; + + try { + const { url } = await createPost({ + accessToken, + authorName, + authorUrn, + text, + versionString: this.options.postsAPIVersion, + }); + debug(`post created, now online at ${url}`); + return url; + } catch (error) { + // Axios Error + // https://axios-http.com/docs/handling_errors + const status = error.response.status; + const message = `could not create LinkedIn post: ${error.response.statusText}`; + throw new IndiekitError(message, { + cause: error, + plugin: this.name, + status, + }); + } + } + + init(Indiekit) { + Indiekit.addSyndicator(this); + } +} diff --git a/packages/syndicator-linkedin/lib/linkedin.js b/packages/syndicator-linkedin/lib/linkedin.js new file mode 100644 index 000000000..1317a696b --- /dev/null +++ b/packages/syndicator-linkedin/lib/linkedin.js @@ -0,0 +1,81 @@ +import makeDebug from "debug"; +import { AuthClient, RestliClient } from "linkedin-api-client"; + +const debug = makeDebug(`indiekit-syndicator:linkedin`); + +// TODO: introspecting the token could be useful to show the token expiration +// date somewhere in the Indiekit UI (maybe in the syndicator detail page). +export const introspectToken = async ({ + accessToken, + clientId, + clientSecret, +}) => { + // https://github.com/linkedin-developers/linkedin-api-js-client?tab=readme-ov-file#authclient + const client = new AuthClient({ clientId, clientSecret }); + + debug(`try introspecting LinkedIn access token`); + return await client.introspectAccessToken(accessToken); +}; + +export const userInfo = async ({ accessToken }) => { + const client = new RestliClient(); + + // The /v2/userinfo endpoint is unversioned and requires the `openid` OAuth scope + const response = await client.get({ + accessToken, + resourcePath: "/userinfo", + }); + + // https://stackoverflow.com/questions/59249318/how-to-get-linkedin-person-id-for-v2-api + // https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/urns + + const id = response.data.sub; + // debug(`user info %O`, response.data); + + return { id, name: response.data.name, urn: `urn:li:person:${id}` }; +}; + +export const createPost = async ({ + accessToken, + authorName, + authorUrn, + text, + versionString, +}) => { + const client = new RestliClient(); + // client.setDebugParams({ enabled: true }); + + // https://stackoverflow.com/questions/59249318/how-to-get-linkedin-person-id-for-v2-api + // https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/urns + + // Text share or create an article + // https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/share-on-linkedin + // https://github.com/linkedin-developers/linkedin-api-js-client/blob/master/examples/create-posts.ts + debug( + `create post on behalf of author URN ${authorUrn} (${authorName}) using LinkedIn Posts API version ${versionString}`, + ); + const response = await client.create({ + accessToken, + resourcePath: "/posts", + entity: { + author: authorUrn, + commentary: text, + distribution: { + feedDistribution: "MAIN_FEED", + targetEntities: [], + thirdPartyDistributionChannels: [], + }, + lifecycleState: "PUBLISHED", + visibility: "PUBLIC", + }, + versionString, + }); + + // LinkedIn share URNs are different from LinkedIn activity URNs + // https://stackoverflow.com/questions/51857232/what-is-the-distinction-between-share-and-activity-in-linkedin-v2-api + // https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/urns + + return { + url: `https://www.linkedin.com/feed/update/${response.createdEntityId}/`, + }; +}; diff --git a/packages/syndicator-linkedin/package.json b/packages/syndicator-linkedin/package.json new file mode 100644 index 000000000..8e106e9b5 --- /dev/null +++ b/packages/syndicator-linkedin/package.json @@ -0,0 +1,46 @@ +{ + "name": "@indiekit/syndicator-linkedin", + "version": "0.1.0", + "description": "LinkedIn syndicator for Indiekit", + "keywords": [ + "indiekit", + "indiekit-plugin", + "indieweb", + "linkedin", + "syndication" + ], + "homepage": "https://getindiekit.com", + "author": { + "name": "Giacomo Debidda", + "url": "https://giacomodebidda.com" + }, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "type": "module", + "main": "index.js", + "files": [ + "assets", + "lib", + "index.js" + ], + "bugs": { + "url": "https://github.com/getindiekit/indiekit/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/getindiekit/indiekit.git", + "directory": "packages/syndicator-linkedin" + }, + "dependencies": { + "@indiekit/error": "^1.0.0-beta.15", + "@indiekit/util": "^1.0.0-beta.16", + "brevity": "^0.2.9", + "html-to-text": "^9.0.0", + "linkedin-api-client": "^0.3.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/syndicator-linkedin/test/index.js b/packages/syndicator-linkedin/test/index.js new file mode 100644 index 000000000..c4d4fa6d5 --- /dev/null +++ b/packages/syndicator-linkedin/test/index.js @@ -0,0 +1,27 @@ +import { strict as assert } from "node:assert"; +import { describe, it } from "node:test"; +// import { Indiekit } from "@indiekit/indiekit"; +// import { getFixture } from "@indiekit-test/fixtures"; +import { mockAgent } from "@indiekit-test/mock-agent"; +import LinkedInSyndicator from "../index.js"; + +await mockAgent("syndicator-linkedin"); + +describe("syndicator-linkedin", () => { + const linkedin = new LinkedInSyndicator({ + // accessToken: "token", + // user: "username", + }); + + // const properties = JSON.parse( + // getFixture("jf2/article-content-provided-html-text.jf2"), + // ); + + // const publication = { + // me: "https://website.example", + // }; + + it("Gets plug-in environment", () => { + assert.deepEqual(linkedin.environment, ["LINKEDIN_ACCESS_TOKEN"]); + }); +}); diff --git a/packages/syndicator-linkedin/test/unit/linkedin.js b/packages/syndicator-linkedin/test/unit/linkedin.js new file mode 100644 index 000000000..89df513cc --- /dev/null +++ b/packages/syndicator-linkedin/test/unit/linkedin.js @@ -0,0 +1,19 @@ +// import { strict as assert } from "node:assert"; +import { describe, it } from "node:test"; +import { mockAgent } from "@indiekit-test/mock-agent"; +// import { linkedin } from "../../lib/linkedin.js"; + +await mockAgent("syndicator-linkedin"); + +describe("syndicator-linkedin/lib/linkedin", () => { + // let context; + + // beforeEach(() => { + // context = { + // me: "https://website.example", + // options: {}, + // }; + // }); + + it.todo("do something"); +});