diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..cc5c9cb8d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +version: '3.9' +services: + mongo: + container_name: mongo + environment: + - MONGO_INITDB_ROOT_USERNAME + - MONGO_INITDB_ROOT_PASSWORD + image: mongo:7.0.11 + network_mode: bridge + ports: + - '27018:27017' + restart: always + volumes: + - mongo-data:/data/db +volumes: + mongo-data: + external: true \ No newline at end of file diff --git a/indiekit.config.js b/indiekit.config.js index 608eefe43..fb92d0c9b 100644 --- a/indiekit.config.js +++ b/indiekit.config.js @@ -18,6 +18,7 @@ const config = { plugins: [ "@indiekit-test/frontend", "@indiekit/endpoint-json-feed", + "@indiekit/endpoint-linkedin", "@indiekit/post-type-audio", "@indiekit/post-type-event", "@indiekit/post-type-jam", @@ -27,6 +28,7 @@ const config = { "@indiekit/preset-eleventy", "@indiekit/store-github", "@indiekit/syndicator-internet-archive", + "@indiekit/syndicator-linkedin", "@indiekit/syndicator-mastodon", ], publication: { @@ -60,6 +62,11 @@ const config = { }, }, }, + "@indiekit/endpoint-linkedin": { + callbackUrl: process.env.LINKEDIN_CALLBACK_URL, + clientId: "78fnklq6mank6p", + clientSecret: process.env.LINKEDIN_CLIENT_SECRET, + }, "@indiekit/endpoint-media": { imageProcessing: { resize: { @@ -83,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 a37e9f9ae..8bbcfa337 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2267,6 +2267,57 @@ "node": ">=18.0.0" } }, + "node_modules/@hapi/boom": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-10.0.1.tgz", + "integrity": "sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/boom/node_modules/@hapi/hoek": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.4.tgz", + "integrity": "sha512-PnsP5d4q7289pS2T2EgGz147BFJ2Jpb4yrEdkpz2IhgEUzos1S7HTl7ezWh1yfYzYlj89KzLdCRkqsP6SIryeQ==" + }, + "node_modules/@hapi/bourne": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-3.0.0.tgz", + "integrity": "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==" + }, + "node_modules/@hapi/hoek": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-10.0.1.tgz", + "integrity": "sha512-CvlW7jmOhWzuqOqiJQ3rQVLMcREh0eel4IBnxDx2FAcK8g7qoJRQK4L1CPBASoCY6y8e6zuCy3f2g+HWdkzcMw==" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@hapi/topo/node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==" + }, + "node_modules/@hapi/wreck": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/@hapi/wreck/-/wreck-18.1.0.tgz", + "integrity": "sha512-0z6ZRCmFEfV/MQqkQomJ7sl/hyxvcZM7LtuVqN3vdAO4vM9eBbowl0kaqQj9EJJQab+3Uuh1GxbGIBFy4NfJ4w==", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/bourne": "^3.0.0", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/wreck/node_modules/@hapi/hoek": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.4.tgz", + "integrity": "sha512-PnsP5d4q7289pS2T2EgGz147BFJ2Jpb4yrEdkpz2IhgEUzos1S7HTl7ezWh1yfYzYlj89KzLdCRkqsP6SIryeQ==" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -2844,6 +2895,10 @@ "resolved": "packages/endpoint-json-feed", "link": true }, + "node_modules/@indiekit/endpoint-linkedin": { + "resolved": "packages/endpoint-linkedin", + "link": true + }, "node_modules/@indiekit/endpoint-media": { "resolved": "packages/endpoint-media", "link": true @@ -2968,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 @@ -4864,6 +4923,29 @@ "shiki": "1.5.2" } }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/address/node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==" + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" + }, "node_modules/@sigstore/bundle": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-1.1.0.tgz", @@ -12439,6 +12521,23 @@ "jiti": "bin/jiti.js" } }, + "node_modules/joi": { + "version": "17.13.1", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.1.tgz", + "integrity": "sha512-vaBlIKCyo4FCUtCm7Eu4QZd/q02bWcxfUO6YSXAZOWF6gzcLBeba8kwotUdYJjDLW8Cz8RywsSOqiNJZW0mNvg==", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, + "node_modules/joi/node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==" + }, "node_modules/js-levenshtein-esm": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/js-levenshtein-esm/-/js-levenshtein-esm-1.2.0.tgz", @@ -13356,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", @@ -18266,6 +18378,38 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/simple-oauth2": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/simple-oauth2/-/simple-oauth2-5.0.0.tgz", + "integrity": "sha512-8291lo/z5ZdpmiOFzOs1kF3cxn22bMj5FFH+DNUppLJrpoIlM1QnFiE7KpshHu3J3i21TVcx4yW+gXYjdCKDLQ==", + "dependencies": { + "@hapi/hoek": "^10.0.1", + "@hapi/wreck": "^18.0.0", + "debug": "^4.3.4", + "joi": "^17.6.4" + } + }, + "node_modules/simple-oauth2/node_modules/debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/simple-oauth2/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, "node_modules/simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", @@ -20684,6 +20828,18 @@ "node": ">=20" } }, + "packages/endpoint-linkedin": { + "name": "@indiekit/endpoint-linkedin", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "express": "^4.17.1", + "simple-oauth2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, "packages/endpoint-media": { "name": "@indiekit/endpoint-media", "version": "1.0.0-beta.16", @@ -21156,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/package.json b/package.json index f989485f1..b172e8664 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "postinstall": "husky", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", - "dev": "node --watch-path=./ packages/indiekit/bin/cli.js serve", + "dev": "node --watch-path=./ packages/indiekit/bin/cli.js serve --port 3333", "start": "indiekit serve", "lint:prettier": "prettier . --check", "lint:prettier:fix": "prettier . --write", diff --git a/packages/endpoint-linkedin/README.md b/packages/endpoint-linkedin/README.md new file mode 100644 index 000000000..b1fec62b7 --- /dev/null +++ b/packages/endpoint-linkedin/README.md @@ -0,0 +1,20 @@ +# @indiekit/endpoint-linkedin + +LinkedIn OAuth 2.0 endpoints for Indiekit. + +## Installation + +`npm i @indiekit/endpoint-linkedin` + +## Usage + +Add `@indiekit/endpoint-linkedin` to your list of plug-ins, specifying options as required: + +```js +{ + "plugins": ["@indiekit/endpoint-linkedin"], + "@indiekit/endpoint-linkedin": { + "mountPath": "/linkedin/authorize", + }, +} +``` diff --git a/packages/endpoint-linkedin/assets/icon.svg b/packages/endpoint-linkedin/assets/icon.svg new file mode 100644 index 000000000..28aeff820 --- /dev/null +++ b/packages/endpoint-linkedin/assets/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/endpoint-linkedin/index.js b/packages/endpoint-linkedin/index.js new file mode 100644 index 000000000..02d23283d --- /dev/null +++ b/packages/endpoint-linkedin/index.js @@ -0,0 +1,211 @@ +import makeDebug from "debug"; +import express from "express"; +import { AuthorizationCode } from "simple-oauth2"; + +const debug = makeDebug(`indiekit:endpoint-linkedin:index`); + +const DEFAULTS = { + callbackUrl: undefined, + // Client ID and Client Secret of the LinkedIn OAuth app you are using for + // publishing on your LinkedIn account. + // This LinkedIn OAuth app could be the official Indiekit app or a custom one. + // TODO: + // 1. create a LinkedIn page for Indiekit + // 2. create a Linkedin OAuth app + // 3. associate the Linkedin OAuth app to the LinkedIn page + // 4. submit the Linkedin OAuth app for verification + clientId: undefined, + clientSecret: undefined, + mountPath: "/oauth/linkedin", + // OAuth 2.0 scopes. When making requests to the LinkedIn APIs, scopes must be + // URL-encoded and space-delimited + // https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow?tabs=HTTPS1 + scopes: ["openid", "profile", "w_member_social"], +}; + +const AUTH = { + authorizeHost: "https://www.linkedin.com", + authorizePath: "/oauth/v2/authorization", + tokenHost: "https://www.linkedin.com", + tokenPath: "/oauth/v2/accessToken", +}; + +const persistTokens = async ({ access_token, id_token }) => { + // store the tokens somewhere, maybe in an environment variable, in a database, etc + process.env.LINKEDIN_ACCESS_TOKEN = access_token; + debug( + `LinkedIn access token persisted in environment variable LINKEDIN_ACCESS_TOKEN`, + ); + process.env.LINKEDIN_ID_TOKEN = id_token; + debug( + `LinkedIn ID token persisted in environment variable LINKEDIN_ID_TOKEN`, + ); +}; + +const ROUTER_OPTIONS = { caseSensitive: true, mergeParams: true }; +debug(`instantiate Express router with these options %O`, ROUTER_OPTIONS); +const router = express.Router(ROUTER_OPTIONS); + +export default class LinkedInEndpoint { + /** + * @param {object} [options] - Plug-in options + * @param {string} [options.callbackUrl] - URL that LinkedIn will redirect to after the user grants or denies permission + * @param {string} [options.clientId] - LinkedIn OAuth app Client ID + * @param {string} [options.clientSecret] - LinkedIn OAuth app Client Secret + * @param {string} [options.mountPath] - Path where to mount the routes defined in this plugin + * @param {Array.} [options.scopes] - OAuth 2.0 scopes + */ + constructor(options = {}) { + this.name = "LinkedIn endpoint"; + this.options = { ...DEFAULTS, ...options }; + debug( + `${this.name} configuration (defaults + user-provided options) %O`, + this.options, + ); + + this.mountPath = this.options.mountPath; + + if (!this.options.clientId) { + throw new Error(`LinkedIn OAuth app clientId not set`); + } + if (!this.options.clientSecret) { + throw new Error(`LinkedIn OAuth app clientSecret not set`); + } + if (!this.options.callbackUrl) { + throw new Error(`LinkedIn OAuth app callbackUrl not set`); + } + + // https://github.com/lelylan/simple-oauth2/blob/master/example/linkedin.js + this.oauthClient = new AuthorizationCode({ + client: { + id: this.options.clientId, + secret: this.options.clientSecret, + }, + options: { + authorizationMethod: "body", + }, + auth: AUTH, + }); + + const state = "random123"; // TODO: how to generate this? + + const qs = [ + `response_type=code`, + `client_id=${this.options.clientId}`, + `redirect_uri=${encodeURIComponent(this.options.callbackUrl)}`, + `scope=${encodeURIComponent(this.options.scopes.join(" "))}`, + `state=${state}`, + ].join("&"); + this.authorizationUri = `${AUTH.authorizeHost}${AUTH.authorizePath}?${qs}`; + + // This doesn't work. It doesn't encode the OAuth scopes correctly. + // this.authorizationUri = this.oauthClient.authorizeURL({ + // redirect_uri, + // scope, + // state, + // }); + } + + get routes() { + router.get(`/`, async (request, response) => { + debug(`GET ${this.mountPath}${request.path}`); + const linkOAuthFlowPath = `${this.mountPath}/start`; + + const viewName = `authorize-linkedin-app`; + debug(`render view ${viewName}`); + + return response.render(viewName, { + back: { + href: "/", + }, + linkOAuthFlow: { + href: linkOAuthFlowPath, + text: "Start OAuth 2.0 Authorization Code Flow (3-legged OAuth)", + }, + links: [ + { + href: "https://www.linkedin.com/developers/tools/oauth", + text: "LinkedIn OAuth 2.0 tools", + }, + { + href: "https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api", + text: "LinkedIn Posts API", + }, + ], + title: "Authorize Indiekit to post on LinkedIn", + }); + }); + + router.get("/start", (request, response) => { + debug(`GET ${this.mountPath}${request.path}`); + + debug(`redirect to ${this.authorizationUri}`); + 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? + + const parameters = { + code, + redirect_uri: this.options.callbackUrl, + scope: encodeURIComponent(this.options.scopes.join(" ")), + }; + + try { + debug( + `exchange the authorization code received from LinkedIn for an access token that has these OAuth 2.0 scopes: ${this.options.scopes.join(", ")}`, + ); + // The checkJs property in jsconfig.json is set to true, so it type-checks + // all JS files in this project. Here it is complaining that `code` might + // not be a string. But it is, so we ignore the error. + // @ts-ignore + const accessToken = await this.oauthClient.getToken(parameters); + // @ts-ignore + await persistTokens(accessToken.token); + // TODO: redirect to success page + debug(`TODO: redirect to success page`); + return response.status(200).json(accessToken); + } catch (error) { + // TODO: display error page + debug("could not obtain access token from LinkedIn %O", error); + return response.status(401).json({ message: "Authentication failed" }); + } + }); + + return router; + } + + // use routesPublic for unprotected routes + // get routesPublic() { + // return router; + // } + + init(Indiekit) { + debug(`${this.name} init`); + Indiekit.addEndpoint(this); + } +} diff --git a/packages/endpoint-linkedin/package.json b/packages/endpoint-linkedin/package.json new file mode 100644 index 000000000..cd5e09c36 --- /dev/null +++ b/packages/endpoint-linkedin/package.json @@ -0,0 +1,45 @@ +{ + "name": "@indiekit/endpoint-linkedin", + "version": "0.1.0", + "description": "LinkedIn OAuth 2.0 endpoints for Indiekit.", + "keywords": [ + "indiekit", + "indiekit-plugin", + "indieweb", + "linkedin", + "OAauth" + ], + "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", + "locales", + "views", + "index.js" + ], + "bugs": { + "url": "https://github.com/getindiekit/indiekit/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/getindiekit/indiekit.git", + "directory": "packages/endpoint-linkedin" + }, + "dependencies": { + "express": "^4.17.1", + "simple-oauth2": "^5.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/endpoint-linkedin/views/authorize-linkedin-app.njk b/packages/endpoint-linkedin/views/authorize-linkedin-app.njk new file mode 100644 index 000000000..ca6e4ce78 --- /dev/null +++ b/packages/endpoint-linkedin/views/authorize-linkedin-app.njk @@ -0,0 +1,15 @@ +{% extends "document.njk" %} + +{% block content %} +Authorize Indiekit to post on LinkedIn on your behalf + +{{ linkOAuthFlow.text }} + +Useful links + + {% for link in links %} + {{ link.text }} + {% endfor %} + + +{% endblock %} diff --git a/packages/endpoint-micropub/lib/controllers/action.js b/packages/endpoint-micropub/lib/controllers/action.js index 7db5e5c4c..83a2e3b8d 100644 --- a/packages/endpoint-micropub/lib/controllers/action.js +++ b/packages/endpoint-micropub/lib/controllers/action.js @@ -1,3 +1,4 @@ +import makeDebug from "debug"; import { IndiekitError } from "@indiekit/error"; import { formEncodedToJf2, mf2ToJf2 } from "../jf2.js"; import { postContent } from "../post-content.js"; @@ -5,6 +6,8 @@ import { postData } from "../post-data.js"; import { checkScope } from "../scope.js"; import { uploadMedia } from "../media.js"; +const debug = makeDebug(`indiekit:endpoint-micropub:controllers:action`); + /** * Perform requested post action * @type {import("express").RequestHandler} @@ -41,6 +44,7 @@ export const actionController = async (request, response, next) => { let content; switch (action) { case "create": { + debug(`create and normalise JF2 data %O`, body); // Create and normalise JF2 data jf2 = request.is("json") ? await mf2ToJf2(body, publication.enrichPostData) @@ -57,6 +61,7 @@ export const actionController = async (request, response, next) => { } case "update": { + debug(`update URL ${url} with body %O`, body); // Check for update operations if (!(body.replace || body.add || body.remove)) { throw IndiekitError.badRequest( @@ -94,12 +99,14 @@ export const actionController = async (request, response, next) => { } case "delete": { + debug(`delete URL ${url}`); data = await postData.delete(application, publication, url); content = await postContent.delete(publication, data); break; } case "undelete": { + debug(`undelete URL ${url}`); data = await postData.undelete( application, publication, diff --git a/packages/endpoint-micropub/lib/post-data.js b/packages/endpoint-micropub/lib/post-data.js index 5f390d26d..9fd89b2e4 100644 --- a/packages/endpoint-micropub/lib/post-data.js +++ b/packages/endpoint-micropub/lib/post-data.js @@ -75,15 +75,15 @@ export const postData = { * @returns {Promise} Post data */ async read(application, url) { - debug(`read ${url}`); - const { posts } = application; const query = { "properties.url": url }; + debug(`try finding MongoDB document that matches this query %O`, query); const postData = await posts.findOne(query); if (!postData) { throw IndiekitError.notFound(url); } + debug(`found MongoDB document that matches this query %O`, query); return postData; }, @@ -177,8 +177,6 @@ export const postData = { * @returns {Promise} Post data */ async delete(application, publication, url) { - debug(`delete ${url}`); - const { posts, timeZone } = application; const { postTypes } = publication; diff --git a/packages/endpoint-micropub/lib/update.js b/packages/endpoint-micropub/lib/update.js index 5bdcf31d8..506c07ac6 100644 --- a/packages/endpoint-micropub/lib/update.js +++ b/packages/endpoint-micropub/lib/update.js @@ -1,6 +1,9 @@ +import makeDebug from "debug"; import _ from "lodash"; import { mf2ToJf2 } from "./jf2.js"; +const debug = makeDebug("indiekit:endpoint-micropub:update"); + /** * Add properties to object * @param {object} object - Object to update @@ -8,6 +11,7 @@ import { mf2ToJf2 } from "./jf2.js"; * @returns {object|undefined} Updated object */ export const addProperties = (object, additions) => { + debug(`addProperties %O`, { object, additions }); for (const key in additions) { if (Object.prototype.hasOwnProperty.call(additions, key)) { const newValue = additions[key]; @@ -45,6 +49,7 @@ export const addProperties = (object, additions) => { * @returns {Promise} Updated object (JF2) */ export const replaceEntries = async (object, replacements) => { + debug(`replaceEntries %O`, { object, replacements }); for await (const [key, value] of Object.entries(replacements)) { if (!Array.isArray(value)) { throw new TypeError("Replacement value should be an array"); @@ -82,6 +87,7 @@ export const replaceEntries = async (object, replacements) => { * @returns {object} Updated object */ export const deleteEntries = (object, deletions) => { + debug(`replaceEntries %O`, { object, deletions }); for (const key in deletions) { if (Object.prototype.hasOwnProperty.call(deletions, key)) { const valuesToDelete = deletions[key]; @@ -120,6 +126,7 @@ export const deleteEntries = (object, deletions) => { * @returns {object} Updated object */ export const deleteProperties = (object, deletions) => { + debug(`deleteProperties %O`, { object, deletions }); for (const key of deletions) { delete object[key]; } diff --git a/packages/endpoint-posts/lib/controllers/post.js b/packages/endpoint-posts/lib/controllers/post.js index 16d8b143b..5cce4e40a 100644 --- a/packages/endpoint-posts/lib/controllers/post.js +++ b/packages/endpoint-posts/lib/controllers/post.js @@ -1,6 +1,9 @@ import path from "node:path"; +import makeDebug from "debug"; import { checkScope } from "@indiekit/endpoint-micropub/lib/scope.js"; +const debug = makeDebug(`indiekit:endpoint-posts:controllers:post`); + /** * View published post * @type {import("express").RequestHandler} @@ -11,6 +14,7 @@ export const postController = async (request, response) => { const postEditable = draftMode ? postStatus === "draft" : true; + debug(`render view post`); response.render("post", { title: postName, parent: { diff --git a/packages/endpoint-posts/lib/controllers/posts.js b/packages/endpoint-posts/lib/controllers/posts.js index eecb20665..56999be56 100644 --- a/packages/endpoint-posts/lib/controllers/posts.js +++ b/packages/endpoint-posts/lib/controllers/posts.js @@ -1,10 +1,13 @@ import path from "node:path"; +import makeDebug from "debug"; import { checkScope } from "@indiekit/endpoint-micropub/lib/scope.js"; import { mf2tojf2 } from "@paulrobertlloyd/mf2tojf2"; import { endpoint } from "../endpoint.js"; import { statusTypes } from "../status-types.js"; import { getPostStatusBadges, getPostName, getPhotoUrl } from "../utils.js"; +const debug = makeDebug(`indiekit:endpoint-posts:controllers:posts`); + /** * List published posts * @type {import("express").RequestHandler} @@ -63,6 +66,7 @@ export const postsController = async (request, response, next) => { }; } + debug(`render view posts`); response.render("posts", { title: response.locals.__("posts.posts.title"), actions: [ diff --git a/packages/endpoint-syndicate/lib/controllers/syndicate.js b/packages/endpoint-syndicate/lib/controllers/syndicate.js index 3f7e3c86f..2d56c44aa 100644 --- a/packages/endpoint-syndicate/lib/controllers/syndicate.js +++ b/packages/endpoint-syndicate/lib/controllers/syndicate.js @@ -1,7 +1,10 @@ +import makeDebug from "debug"; import { IndiekitError } from "@indiekit/error"; import { findBearerToken } from "../token.js"; import { getPostData, syndicateToTargets } from "../utils.js"; +const debug = makeDebug(`indiekit:endpoint-syndicate:controllers:syndicate`); + export const syndicateController = { async post(request, response, next) { try { @@ -45,13 +48,14 @@ export const syndicateController = { }); } - // Syndicate to targets const { failedTargets, syndicatedUrls } = await syndicateToTargets( publication, postData.properties, ); - // Update post with syndicated URL(s) and remaining syndication target(s) + debug( + `update post with syndicated URL(s) and remaining syndication target(s)`, + ); const micropubResponse = await fetch(application.micropubEndpoint, { method: "POST", headers: { diff --git a/packages/endpoint-syndicate/lib/token.js b/packages/endpoint-syndicate/lib/token.js index b33245cc1..8d4a95034 100644 --- a/packages/endpoint-syndicate/lib/token.js +++ b/packages/endpoint-syndicate/lib/token.js @@ -1,8 +1,12 @@ import process from "node:process"; import { IndiekitError } from "@indiekit/error"; +import makeDebug from "debug"; import jwt from "jsonwebtoken"; +const debug = makeDebug(`indiekit:endpoint-syndicate:token`); + export const findBearerToken = (request) => { + debug(`find bearer token`); if (request.headers?.["x-webhook-signature"]) { const signature = request.headers["x-webhook-signature"]; const verifiedToken = verifyToken(signature); @@ -30,17 +34,15 @@ export const findBearerToken = (request) => { * @param {string} url - Publication URL * @returns {string} Signed JSON Web Token */ -export const signToken = (verifiedToken, url) => - jwt.sign( - { - me: url, - scope: "update", - }, - process.env.SECRET, - { - expiresIn: "10m", - }, - ); +export const signToken = (verifiedToken, url) => { + const expiresIn = "10m"; + const scope = "update"; + debug(`sign token %O`, { scope, expiresIn }); + + return jwt.sign({ me: url, scope }, process.env.SECRET, { + expiresIn, + }); +}; /** * Verify that token provided by signature was issued by Netlify @@ -48,8 +50,12 @@ export const signToken = (verifiedToken, url) => * @returns {object} JSON Web Token * @see {@link https://docs.netlify.com/site-deploys/notifications/#payload-signature} */ -export const verifyToken = (signature) => - jwt.verify(signature, process.env.WEBHOOK_SECRET, { +export const verifyToken = (signature) => { + const issuer = ["netlify"]; + debug(`verify token %O`, { issuer }); + + return jwt.verify(signature, process.env.WEBHOOK_SECRET, { algorithms: ["HS256"], - issuer: ["netlify"], + issuer, }); +}; diff --git a/packages/endpoint-syndicate/lib/utils.js b/packages/endpoint-syndicate/lib/utils.js index 997fab276..6261695cb 100644 --- a/packages/endpoint-syndicate/lib/utils.js +++ b/packages/endpoint-syndicate/lib/utils.js @@ -1,3 +1,7 @@ +import makeDebug from "debug"; + +const debug = makeDebug(`indiekit:endpoint-syndicate:utils`); + /** * Get post data * @param {object} application - Application configuration @@ -5,15 +9,37 @@ * @returns {Promise} Post data for given URL else recently published post */ export const getPostData = async (application, url) => { + // It would be better to pass just the MongoDB posts collection and the URL as + // arguments and avoid passing the entire Indiekit application object. const { posts } = application; let postData = {}; if (url) { - // Get item in database which matching URL + debug( + `URL provided. Trying to find document matching this query in MongoDB posts collection`, + { + "properties.url": url, + }, + ); postData = await posts.findOne({ "properties.url": url, }); + debug(`found MongoDB document %O`, postData); } else { + debug( + `URL not provided. Trying to find first document matching this query in MongoDB posts collection`, + { + "properties.mp-syndicate-to": { + $exists: true, + }, + "properties.syndication": { + $exists: false, + }, + "properties.post-status": { + $ne: "draft", + }, + }, + ); // Get published posts awaiting syndication and return first item const items = await posts .find({ @@ -30,7 +56,9 @@ export const getPostData = async (application, url) => { .sort({ "properties.published": -1 }) .limit(1) .toArray(); - postData = items[0]; + + debug(`found ${items.length} MongoDB documents`); + postData = items[0]; // TODO: index out of bounds when there are no items } return postData; @@ -56,11 +84,15 @@ export const hasSyndicationUrl = (syndicatedUrls, syndicateTo) => { * @returns {object|undefined} Publication syndication target */ export const getSyndicationTarget = (syndicationTargets, syndicateTo) => { + // debug(`getSyndicationTarget %O`, { syndicationTargets, syndicateTo }); return syndicationTargets.find((target) => { + // target is an instance of an Indiekit syndicator + debug(`getSyndicationTarget target %O`, target); if (!target?.info?.uid) { return; } + debug(`syndication target ${target.name}`); const targetOrigin = new URL(target.info.uid).origin; const syndicateToOrigin = new URL(syndicateTo).origin; return targetOrigin === syndicateToOrigin; @@ -77,6 +109,8 @@ export const syndicateToTargets = async (publication, properties) => { const { syndicationTargets } = publication; const syndicateTo = properties["mp-syndicate-to"]; const syndicateToUrls = Array.isArray ? syndicateTo : [syndicateTo]; + debug(`mp-syndicate-to: ${syndicateToUrls.join(", ")}`); + const syndicatedUrls = properties.syndication || []; const failedTargets = []; @@ -86,13 +120,16 @@ export const syndicateToTargets = async (publication, properties) => { if (target && !alreadySyndicated) { try { + debug(`try syndicating using ${target.name}`); const syndicatedUrl = await target.syndicate(properties, publication); // Add syndicated URL to list of syndicated URLs syndicatedUrls.push(syndicatedUrl); + debug(`syndicated URL ${syndicatedUrl}`); } catch (error) { // Add failed syndication target to list of failed targets failedTargets.push(target.info.uid); + debug(`failed to syndicate target %O`, target); console.error(error.message); } } diff --git a/packages/indiekit/index.js b/packages/indiekit/index.js index f852ffd77..3d16cbe28 100644 --- a/packages/indiekit/index.js +++ b/packages/indiekit/index.js @@ -1,6 +1,7 @@ #!/usr/bin/env node import process from "node:process"; import Keyv from "keyv"; +import makeDebug from "debug"; import { expressConfig } from "./config/express.js"; import { getCategories } from "./lib/categories.js"; import { getIndiekitConfig } from "./lib/config.js"; @@ -11,6 +12,8 @@ import { getPostTemplate } from "./lib/post-template.js"; import { getPostTypes } from "./lib/post-types.js"; import { getMediaStore, getStore } from "./lib/store.js"; +const debug = makeDebug(`indiekit:index`); + export const Indiekit = class { /** * @private @@ -53,14 +56,17 @@ export const Indiekit = class { } addPreset(preset) { + debug(`add preset %O`, { id: preset.id, name: preset.name }); this.publication.preset = preset; } addStore(store) { + debug(`add store %O`, { id: store.id, name: store.name }); this.application.stores.push(store); } addSyndicator(syndicator) { + debug(`add syndicator %O`, { id: syndicator.id, name: syndicator.name }); syndicator = Array.isArray(syndicator) ? syndicator : [syndicator]; this.publication.syndicationTargets = [ ...this.publication.syndicationTargets, diff --git a/packages/indiekit/lib/controllers/plugin.js b/packages/indiekit/lib/controllers/plugin.js index f2a7da55f..6fccbcc69 100644 --- a/packages/indiekit/lib/controllers/plugin.js +++ b/packages/indiekit/lib/controllers/plugin.js @@ -1,6 +1,9 @@ import path from "node:path"; +import makeDebug from "debug"; import { getPackageData } from "../utils.js"; +const debug = makeDebug(`indiekit:controllers:plugin`); + export const list = (request, response) => { const { application } = response.app.locals; @@ -18,6 +21,7 @@ export const list = (request, response) => { return plugin; }); + debug(`render view plugins/list (${plugins.length} plugins)`); response.render("plugins/list", { parent: { href: "/status/", @@ -31,12 +35,14 @@ export const list = (request, response) => { export const view = (request, response) => { const { application } = response.app.locals; const { pluginId } = request.params; + debug(`view request.params %O`, request.params); const plugin = application.installedPlugins.find( (plugin) => plugin.id === pluginId, ); plugin.package = getPackageData(plugin.filePath); + debug(`render view plugins/view (plugin ${plugin.name})`); response.render("plugins/view", { parent: { href: path.dirname(request.path), diff --git a/packages/indiekit/lib/plugins.js b/packages/indiekit/lib/plugins.js index 6dafc7027..457ff702f 100644 --- a/packages/indiekit/lib/plugins.js +++ b/packages/indiekit/lib/plugins.js @@ -1,17 +1,26 @@ import { createRequire } from "node:module"; import path from "node:path"; +import makeDebug from "debug"; const require = createRequire(import.meta.url); +const debug = makeDebug(`indiekit:plugins`); + /** * Add plug-ins to application configuration * @param {object} Indiekit - Indiekit instance * @returns {Promise} Installed plug-ins */ export const getInstalledPlugins = async (Indiekit) => { + debug(`getInstalledPlugins`); const installedPlugins = []; for await (const pluginName of Indiekit.config.plugins) { + debug(`register plugin ${pluginName}`); const { default: IndiekitPlugin } = await import(pluginName); + debug( + `instantiate plugin ${pluginName} using these options %O`, + Indiekit.config[pluginName], + ); const plugin = new IndiekitPlugin(Indiekit.config[pluginName]); // Add plug-in file path @@ -37,6 +46,7 @@ export const getInstalledPlugins = async (Indiekit) => { * @returns {string} Plug-in ID */ export const getInstalledPlugin = (application, pluginName) => { + debug(`getInstalledPlugin ${pluginName}`); return application.installedPlugins.find( (plugin) => plugin.id === getPluginId(pluginName), ); @@ -48,5 +58,6 @@ export const getInstalledPlugin = (application, pluginName) => { * @returns {string} Plug-in ID */ export const getPluginId = (pluginName) => { + // debug(`getPluginId ${pluginName}`); return pluginName.replace("/", "-"); }; diff --git a/packages/indiekit/lib/routes.js b/packages/indiekit/lib/routes.js index 61923c70c..a3b4cb658 100644 --- a/packages/indiekit/lib/routes.js +++ b/packages/indiekit/lib/routes.js @@ -1,4 +1,5 @@ import path from "node:path"; +import makeDebug from "debug"; import express from "express"; import { assetsPath } from "@indiekit/frontend"; import rateLimit from "express-rate-limit"; @@ -12,7 +13,11 @@ import * as sessionController from "./controllers/session.js"; import * as statusController from "./controllers/status.js"; import { IndieAuth } from "./indieauth.js"; +const debug = makeDebug(`indiekit:routes`); + +debug(`instantiate Express router`); const router = express.Router(); + const limit = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 250, @@ -82,10 +87,12 @@ export const routes = (indiekitConfig) => { // Currently used for endpoint-image which requires configuration values // to be passed on to express-sharp middleware if (endpoint.mountPath && endpoint._routes) { + debug(`mount _routes of ${endpoint.name} (rate-limited)`); router.use(endpoint.mountPath, limit, endpoint._routes(indiekitConfig)); } if (endpoint.mountPath && endpoint.routesPublic) { + debug(`mount routesPublic of ${endpoint.name} (rate-limited)`); router.use(endpoint.mountPath, limit, endpoint.routesPublic); } @@ -110,6 +117,7 @@ export const routes = (indiekitConfig) => { // Authenticated endpoints for (const endpoint of application.endpoints) { if (endpoint.mountPath && endpoint.routes) { + debug(`mount routes of ${endpoint.name} (rate-limited, require auth)`); router.use(endpoint.mountPath, limit, endpoint.routes); } } diff --git a/packages/indiekit/views/homepage.njk b/packages/indiekit/views/homepage.njk index cd370e9a5..05fbc7197 100644 --- a/packages/indiekit/views/homepage.njk +++ b/packages/indiekit/views/homepage.njk @@ -16,6 +16,10 @@ text: discovery }) }} + + Authenticate with LinkedIn + + {% for plugin in application.installedPlugins -%} {% include plugin.id + "-widget.njk" ignore missing %} 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"); +}); diff --git a/packages/syndicator-mastodon/index.js b/packages/syndicator-mastodon/index.js index d70b5bf60..56ea31dd0 100644 --- a/packages/syndicator-mastodon/index.js +++ b/packages/syndicator-mastodon/index.js @@ -1,8 +1,11 @@ import path from "node:path"; import process from "node:process"; +import makeDebug from "debug"; import { IndiekitError } from "@indiekit/error"; import { mastodon } from "./lib/mastodon.js"; +const debug = makeDebug(`indiekit-syndicator:mastodon`); + const defaults = { accessToken: process.env.MASTODON_ACCESS_TOKEN, characterLimit: 500, @@ -93,13 +96,19 @@ export default class MastodonSyndicator { } async syndicate(properties, publication) { + const server_url = this.options.url; + const user = this.options.user; + try { - return await mastodon({ + debug(`try syndicating to Mastodon server ${server_url} as user ${user}`); + const url = await mastodon({ accessToken: this.options.accessToken, characterLimit: this.options.characterLimit, includePermalink: this.options.includePermalink, serverUrl: `${this.#url.protocol}//${this.#url.hostname}`, }).post(properties, publication.me); + debug(`syndicated to Mastodon server ${server_url} as user ${user}`); + return url; } catch (error) { throw new IndiekitError(error.message, { cause: error, @@ -110,6 +119,15 @@ export default class MastodonSyndicator { } init(Indiekit) { + const required_configs = ["accessToken", "url", "user"]; + for (const required of required_configs) { + if (!this.options[required]) { + const message = `could not initialize ${this.name}: ${required} not set. See https://npmjs.org/package/@indiekit/syndicator-mastodon for details.`; + debug(message); + console.error(message); + throw new Error(message); + } + } Indiekit.addSyndicator(this); } }
Authorize Indiekit to post on LinkedIn on your behalf