@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 +29 -0
- package/lib/controllers/syndicate.js +203 -0
- package/lib/token.js +56 -0
- package/lib/utils.js +138 -0
- package/package.json +38 -0
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
|
+
}
|