@rmdes/indiekit-endpoint-syndicate 1.0.0-beta.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,29 @@
1
+ import express from "express";
2
+
3
+ import { syndicateController } from "./lib/controllers/syndicate.js";
4
+
5
+ const defaults = { mountPath: "/syndicate" };
6
+ const router = express.Router();
7
+
8
+ export default class SyndicateEndpoint {
9
+ name = "Syndication endpoint";
10
+
11
+ constructor(options = {}) {
12
+ this.options = { ...defaults, ...options };
13
+ this.mountPath = this.options.mountPath;
14
+ }
15
+
16
+ get routesPublic() {
17
+ router.post("/", syndicateController.post);
18
+
19
+ return router;
20
+ }
21
+
22
+ init(Indiekit) {
23
+ Indiekit.addEndpoint(this);
24
+
25
+ // Use private value to register syndication endpoint path
26
+ Indiekit.config.application._syndicationEndpointPath =
27
+ this.options.mountPath;
28
+ }
29
+ }
@@ -0,0 +1,203 @@
1
+ import { IndiekitError } from "@indiekit/error";
2
+
3
+ import { findBearerToken } from "../token.js";
4
+ import {
5
+ getAllPostData,
6
+ getPostData,
7
+ syndicateToTargets,
8
+ } from "../utils.js";
9
+
10
+ /**
11
+ * Delay helper for rate limiting between posts
12
+ * @param {number} ms - Milliseconds to wait
13
+ * @returns {Promise<void>}
14
+ */
15
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
16
+
17
+ /**
18
+ * Syndicate a single post and update it via Micropub
19
+ * @param {object} options - Options
20
+ * @param {object} options.application - Application config
21
+ * @param {object} options.publication - Publication config
22
+ * @param {object} options.postData - Post data from database
23
+ * @param {string} options.bearerToken - Bearer token for Micropub
24
+ * @returns {Promise<object>} Result object
25
+ */
26
+ const syndicatePost = async ({
27
+ application,
28
+ publication,
29
+ postData,
30
+ bearerToken,
31
+ }) => {
32
+ const { failedTargets, syndicatedUrls } = await syndicateToTargets(
33
+ publication,
34
+ postData.properties,
35
+ );
36
+
37
+ // Update post with syndicated URL(s) and remaining syndication target(s)
38
+ const micropubResponse = await fetch(application.micropubEndpoint, {
39
+ method: "POST",
40
+ headers: {
41
+ accept: "application/json",
42
+ authorization: `Bearer ${bearerToken}`,
43
+ "content-type": "application/json",
44
+ },
45
+ body: JSON.stringify({
46
+ action: "update",
47
+ url: postData.properties.url,
48
+ ...(!failedTargets && { delete: ["mp-syndicate-to"] }),
49
+ replace: {
50
+ ...(failedTargets && { "mp-syndicate-to": failedTargets }),
51
+ ...(syndicatedUrls && { syndication: syndicatedUrls }),
52
+ },
53
+ }),
54
+ });
55
+
56
+ if (!micropubResponse.ok) {
57
+ throw await IndiekitError.fromFetch(micropubResponse);
58
+ }
59
+
60
+ /** @type {object} */
61
+ const body = await micropubResponse.json();
62
+
63
+ return {
64
+ url: postData.properties.url,
65
+ body,
66
+ failedTargets,
67
+ syndicatedUrls,
68
+ };
69
+ };
70
+
71
+ export const syndicateController = {
72
+ async post(request, response, next) {
73
+ try {
74
+ const { application, publication } = request.app.locals;
75
+ const bearerToken = findBearerToken(request);
76
+ const sourceUrl =
77
+ request.query.source_url || request.body?.syndication?.source_url;
78
+ const redirectUri =
79
+ request.query.redirect_uri || request.body?.syndication?.redirect_uri;
80
+
81
+ const postsCollection = application?.collections?.get("posts");
82
+ if (!postsCollection) {
83
+ throw IndiekitError.notImplemented(
84
+ response.locals.__("NotImplementedError.database"),
85
+ );
86
+ }
87
+
88
+ // Get syndication targets
89
+ const { syndicationTargets } = publication;
90
+ if (syndicationTargets.length === 0) {
91
+ return response.json({
92
+ success: "OK",
93
+ success_description: "No syndication targets have been configured",
94
+ });
95
+ }
96
+
97
+ // --- Single post mode (when source_url is provided) ---
98
+ if (sourceUrl) {
99
+ const postData = await getPostData(postsCollection, sourceUrl);
100
+
101
+ if (!postData) {
102
+ return response.json({
103
+ success: "OK",
104
+ success_description: `No post record available for ${sourceUrl}`,
105
+ });
106
+ }
107
+
108
+ const result = await syndicatePost({
109
+ application,
110
+ publication,
111
+ postData,
112
+ bearerToken,
113
+ });
114
+
115
+ // Include failed syndication targets in response
116
+ if (result.failedTargets) {
117
+ result.body.success_description +=
118
+ ". The following target(s) did not return a URL: " +
119
+ result.failedTargets.join(" ");
120
+ }
121
+
122
+ if (redirectUri && redirectUri.startsWith("/")) {
123
+ const message = encodeURIComponent(result.body.success_description);
124
+ return response.redirect(`${redirectUri}?success=${message}`);
125
+ }
126
+
127
+ return response.json(result.body);
128
+ }
129
+
130
+ // --- Batch mode (no source_url — process ALL pending posts) ---
131
+ const allPostData = await getAllPostData(postsCollection);
132
+
133
+ if (!allPostData || allPostData.length === 0) {
134
+ return response.json({
135
+ success: "OK",
136
+ success_description: "No posts awaiting syndication",
137
+ });
138
+ }
139
+
140
+ console.log(
141
+ `[syndication] Batch processing ${allPostData.length} post(s)`,
142
+ );
143
+
144
+ const results = [];
145
+ let successCount = 0;
146
+ let failCount = 0;
147
+
148
+ for (const postData of allPostData) {
149
+ try {
150
+ const result = await syndicatePost({
151
+ application,
152
+ publication,
153
+ postData,
154
+ bearerToken,
155
+ });
156
+
157
+ results.push({
158
+ url: result.url,
159
+ success: true,
160
+ syndicatedUrls: result.syndicatedUrls,
161
+ ...(result.failedTargets && {
162
+ failedTargets: result.failedTargets,
163
+ }),
164
+ });
165
+
166
+ successCount++;
167
+ console.log(
168
+ `[syndication] Syndicated: ${result.url} (${result.syndicatedUrls.length} target(s))`,
169
+ );
170
+ } catch (error) {
171
+ results.push({
172
+ url: postData.properties?.url,
173
+ success: false,
174
+ error: error.message,
175
+ });
176
+
177
+ failCount++;
178
+ console.error(
179
+ `[syndication] Failed: ${postData.properties?.url} - ${error.message}`,
180
+ );
181
+ }
182
+
183
+ // Rate limit delay between posts (2 seconds)
184
+ if (allPostData.indexOf(postData) < allPostData.length - 1) {
185
+ await delay(2000);
186
+ }
187
+ }
188
+
189
+ const description =
190
+ `Processed ${allPostData.length} post(s): ${successCount} succeeded, ${failCount} failed`;
191
+
192
+ console.log(`[syndication] ${description}`);
193
+
194
+ return response.json({
195
+ success: "OK",
196
+ success_description: description,
197
+ results,
198
+ });
199
+ } catch (error) {
200
+ next(error);
201
+ }
202
+ },
203
+ };
package/lib/token.js ADDED
@@ -0,0 +1,56 @@
1
+ import process from "node:process";
2
+
3
+ import { IndiekitError } from "@indiekit/error";
4
+ import jwt from "jsonwebtoken";
5
+
6
+ export const findBearerToken = (request) => {
7
+ if (request.headers?.["x-webhook-signature"] && request.body?.url) {
8
+ const signature = request.headers["x-webhook-signature"];
9
+ const verifiedToken = verifyToken(signature);
10
+ const bearerToken = signToken(verifiedToken, request.body.url);
11
+ return bearerToken;
12
+ }
13
+
14
+ if (request.body?.access_token) {
15
+ const bearerToken = request.body.access_token;
16
+ delete request.body.access_token;
17
+ return bearerToken;
18
+ }
19
+
20
+ if (request.query?.token) {
21
+ const bearerToken = request.query.token;
22
+ return bearerToken;
23
+ }
24
+
25
+ throw IndiekitError.invalidRequest("No bearer token provided by request");
26
+ };
27
+
28
+ /**
29
+ * Generate short-lived bearer token with update scope
30
+ * @param {object} verifiedToken - JSON Web Token
31
+ * @param {string} url - Publication URL
32
+ * @returns {string} Signed JSON Web Token
33
+ */
34
+ export const signToken = (verifiedToken, url) =>
35
+ jwt.sign(
36
+ {
37
+ me: url,
38
+ scope: "update",
39
+ },
40
+ process.env.SECRET,
41
+ {
42
+ expiresIn: "10m",
43
+ },
44
+ );
45
+
46
+ /**
47
+ * Verify that token provided by signature was issued by Netlify
48
+ * @param {string} signature - JSON Web Signature
49
+ * @returns {object} JSON Web Token
50
+ * @see {@link https://docs.netlify.com/site-deploys/notifications/#payload-signature}
51
+ */
52
+ export const verifyToken = (signature) =>
53
+ jwt.verify(signature, process.env.WEBHOOK_SECRET, {
54
+ algorithms: ["HS256"],
55
+ issuer: ["netlify"],
56
+ });
package/lib/utils.js ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Get post data for a single post
3
+ * @param {object} postsCollection - Posts database collection
4
+ * @param {string} url - URL of existing post (optional)
5
+ * @returns {Promise<object>} Post data for given URL else most recent pending post
6
+ */
7
+ export const getPostData = async (postsCollection, url) => {
8
+ let postData = {};
9
+
10
+ if (url) {
11
+ // Get item in database with matching URL
12
+ postData = await postsCollection.findOne({
13
+ "properties.url": url,
14
+ });
15
+ } else {
16
+ // Get most recent published post awaiting syndication
17
+ const items = await postsCollection
18
+ .find({
19
+ "properties.mp-syndicate-to": {
20
+ $exists: true,
21
+ },
22
+ // BUG FIX: Removed "properties.syndication": { $exists: false }
23
+ // That filter skipped partially syndicated posts (e.g., posted to
24
+ // Mastodon but not yet to Bluesky). syndicateToTargets() already
25
+ // calls hasSyndicationUrl() to skip completed targets.
26
+ "properties.post-status": {
27
+ $ne: "draft",
28
+ },
29
+ })
30
+ // eslint-disable-next-line unicorn/no-array-sort
31
+ .sort({ "properties.published": -1 })
32
+ .limit(1)
33
+ .toArray();
34
+ postData = items[0];
35
+ }
36
+
37
+ return postData;
38
+ };
39
+
40
+ /**
41
+ * Get ALL posts awaiting syndication (batch mode)
42
+ * @param {object} postsCollection - Posts database collection
43
+ * @returns {Promise<Array>} Array of post data objects
44
+ */
45
+ export const getAllPostData = async (postsCollection) => {
46
+ const items = await postsCollection
47
+ .find({
48
+ "properties.mp-syndicate-to": {
49
+ $exists: true,
50
+ },
51
+ // No syndication filter — let syndicateToTargets() handle dedup
52
+ "properties.post-status": {
53
+ $ne: "draft",
54
+ },
55
+ })
56
+ // eslint-disable-next-line unicorn/no-array-sort
57
+ .sort({ "properties.published": -1 })
58
+ .toArray();
59
+
60
+ return items;
61
+ };
62
+
63
+ /**
64
+ * Check if target already returned a syndication URL
65
+ * @param {Array} syndicatedUrls - Syndication URLs
66
+ * @param {string} syndicateTo - Syndication target
67
+ * @returns {boolean} Target returned a syndication URL
68
+ */
69
+ export const hasSyndicationUrl = (syndicatedUrls, syndicateTo) => {
70
+ return syndicatedUrls.some((url) => {
71
+ const { origin } = new URL(url);
72
+ return syndicateTo.includes(origin);
73
+ });
74
+ };
75
+
76
+ /**
77
+ * Get syndication target for syndication URL
78
+ * @param {Array} syndicationTargets - Publication syndication targets
79
+ * @param {string} syndicateTo - Syndication URL
80
+ * @returns {object|undefined} Publication syndication target
81
+ */
82
+ export const getSyndicationTarget = (syndicationTargets, syndicateTo) => {
83
+ return syndicationTargets.find((target) => {
84
+ if (!target?.info?.uid) {
85
+ return;
86
+ }
87
+
88
+ const targetOrigin = new URL(target.info.uid).origin;
89
+ const syndicateToOrigin = new URL(syndicateTo).origin;
90
+ return targetOrigin === syndicateToOrigin;
91
+ });
92
+ };
93
+
94
+ /**
95
+ * Syndicate URLs to configured syndication targets
96
+ * @param {object} publication - Publication configuration
97
+ * @param {object} properties - JF2 properties
98
+ * @returns {Promise<object>} Syndication target
99
+ */
100
+ export const syndicateToTargets = async (publication, properties) => {
101
+ const { syndicationTargets } = publication;
102
+ const syndicateTo = properties["mp-syndicate-to"];
103
+ // BUG FIX: Was `Array.isArray` (always truthy, it's a function reference)
104
+ // Now correctly passes syndicateTo as the argument
105
+ const syndicateToUrls = Array.isArray(syndicateTo)
106
+ ? syndicateTo
107
+ : [syndicateTo];
108
+ const syndicatedUrls = properties.syndication || [];
109
+ const failedTargets = [];
110
+
111
+ for (const url of syndicateToUrls) {
112
+ const target = getSyndicationTarget(syndicationTargets, url);
113
+ const alreadySyndicated = hasSyndicationUrl(syndicatedUrls, url);
114
+
115
+ if (target && !alreadySyndicated) {
116
+ try {
117
+ const syndicatedUrl = await target.syndicate(properties, publication);
118
+
119
+ if (syndicatedUrl) {
120
+ // Add syndicated URL to list of syndicated URLs
121
+ syndicatedUrls.push(syndicatedUrl);
122
+ } else {
123
+ // Add failed syndication target to list of failed targets
124
+ failedTargets.push(target.info.uid);
125
+ }
126
+ } catch (error) {
127
+ // Add failed syndication target to list of failed targets
128
+ failedTargets.push(target.info.uid);
129
+ console.error(error.message);
130
+ }
131
+ }
132
+ }
133
+
134
+ return {
135
+ ...(failedTargets.length > 0 && { failedTargets }),
136
+ syndicatedUrls,
137
+ };
138
+ };
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@rmdes/indiekit-endpoint-syndicate",
3
+ "version": "1.0.0-beta.27",
4
+ "description": "Syndication endpoint for Indiekit. Fork of @indiekit/endpoint-syndicate with batch syndication support and bug fixes.",
5
+ "keywords": [
6
+ "indiekit",
7
+ "indiekit-plugin",
8
+ "indieweb",
9
+ "syndication"
10
+ ],
11
+ "homepage": "https://github.com/rmdes/indiekit-endpoint-syndicate",
12
+ "author": {
13
+ "name": "Ricardo Mendes",
14
+ "url": "https://rmendes.net"
15
+ },
16
+ "license": "MIT",
17
+ "engines": {
18
+ "node": ">=20"
19
+ },
20
+ "type": "module",
21
+ "main": "index.js",
22
+ "files": [
23
+ "lib",
24
+ "index.js"
25
+ ],
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/rmdes/indiekit-endpoint-syndicate.git"
29
+ },
30
+ "dependencies": {
31
+ "@indiekit/error": "^1.0.0-beta.25",
32
+ "express": "^5.0.0",
33
+ "jsonwebtoken": "^9.0.0"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
38
+ }