strapi-bulk-publish 1.0.0

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.
@@ -0,0 +1,432 @@
1
+ "use strict";
2
+ const register = (_args) => {
3
+ };
4
+ const bootstrap = async ({ strapi }) => {
5
+ strapi.log.debug("Bulk Publish plugin bootstrapped");
6
+ await strapi.service("admin::permission").actionProvider.registerMany([
7
+ {
8
+ section: "plugins",
9
+ displayName: "Publish",
10
+ uid: "publish",
11
+ pluginName: "bulk-publish"
12
+ },
13
+ {
14
+ section: "plugins",
15
+ displayName: "Settings",
16
+ uid: "settings",
17
+ pluginName: "bulk-publish"
18
+ }
19
+ ]);
20
+ const store = strapi.store({ type: "plugin", name: "bulk-publish" });
21
+ const existingUrl = await store.get({ key: "webhookUrl" });
22
+ if (existingUrl === null || existingUrl === void 0) {
23
+ const configUrl = strapi.plugin("bulk-publish").config("webhookUrl") || "";
24
+ await store.set({ key: "webhookUrl", value: configUrl });
25
+ }
26
+ };
27
+ const config = {
28
+ default: {
29
+ contentType: "",
30
+ titleField: "title",
31
+ webhookUrl: ""
32
+ },
33
+ validator: (config2) => {
34
+ if (!config2.contentType || typeof config2.contentType !== "string") {
35
+ throw new Error(
36
+ 'bulk-publish: contentType is required (e.g. "api::blog-post.blog-post")'
37
+ );
38
+ }
39
+ if (config2.titleField && typeof config2.titleField !== "string") {
40
+ throw new Error("bulk-publish: titleField must be a string");
41
+ }
42
+ if (config2.webhookUrl && typeof config2.webhookUrl !== "string") {
43
+ throw new Error("bulk-publish: webhookUrl must be a string");
44
+ }
45
+ }
46
+ };
47
+ const admin = {
48
+ type: "admin",
49
+ routes: [
50
+ {
51
+ method: "GET",
52
+ path: "/posts",
53
+ handler: "bulk-publish.getPosts",
54
+ config: {
55
+ policies: [
56
+ "admin::isAuthenticatedAdmin",
57
+ {
58
+ name: "admin::hasPermissions",
59
+ config: { actions: ["plugin::bulk-publish.publish"] }
60
+ }
61
+ ],
62
+ description: "List blog posts with unpublished locales"
63
+ }
64
+ },
65
+ {
66
+ method: "POST",
67
+ path: "/publish",
68
+ handler: "bulk-publish.publish",
69
+ config: {
70
+ policies: [
71
+ "admin::isAuthenticatedAdmin",
72
+ {
73
+ name: "admin::hasPermissions",
74
+ config: { actions: ["plugin::bulk-publish.publish"] }
75
+ }
76
+ ],
77
+ description: "Publish selected posts across all locales"
78
+ }
79
+ },
80
+ {
81
+ method: "GET",
82
+ path: "/settings",
83
+ handler: "bulk-publish.getSettings",
84
+ config: {
85
+ policies: [
86
+ "admin::isAuthenticatedAdmin",
87
+ {
88
+ name: "admin::hasPermissions",
89
+ config: { actions: ["plugin::bulk-publish.settings"] }
90
+ }
91
+ ],
92
+ description: "Get webhook settings"
93
+ }
94
+ },
95
+ {
96
+ method: "PUT",
97
+ path: "/settings",
98
+ handler: "bulk-publish.updateSettings",
99
+ config: {
100
+ policies: [
101
+ "admin::isAuthenticatedAdmin",
102
+ {
103
+ name: "admin::hasPermissions",
104
+ config: { actions: ["plugin::bulk-publish.settings"] }
105
+ }
106
+ ],
107
+ description: "Update webhook settings"
108
+ }
109
+ }
110
+ ]
111
+ };
112
+ const routes = {
113
+ admin
114
+ };
115
+ const WEBHOOK_TIMEOUT_MS = 1e4;
116
+ const MAX_LOG_BODY_LENGTH = 500;
117
+ const BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set([
118
+ "localhost",
119
+ "127.0.0.1",
120
+ "::1",
121
+ "0.0.0.0",
122
+ "169.254.169.254"
123
+ ]);
124
+ function isPrivateIP(hostname) {
125
+ const normalized = hostname.replace(/^\[|\]$/g, "");
126
+ if (BLOCKED_HOSTNAMES.has(normalized)) return true;
127
+ if (normalized.endsWith(".local") || normalized.endsWith(".internal")) return true;
128
+ const parts = hostname.split(".").map(Number);
129
+ if (parts.length !== 4 || parts.some(isNaN)) return false;
130
+ if (parts[0] === 10) return true;
131
+ if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
132
+ if (parts[0] === 192 && parts[1] === 168) return true;
133
+ if (parts[0] === 169 && parts[1] === 254) return true;
134
+ if (parts[0] === 127) return true;
135
+ return false;
136
+ }
137
+ function isAllowedWebhookUrl(urlStr) {
138
+ if (!urlStr) return true;
139
+ try {
140
+ const url = new URL(urlStr);
141
+ if (url.protocol !== "https:" && url.protocol !== "http:") return false;
142
+ return !isPrivateIP(url.hostname.toLowerCase());
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+ const webhook = ({ strapi }) => {
148
+ const getStore = () => strapi.store({ type: "plugin", name: "bulk-publish" });
149
+ return {
150
+ async getWebhookUrl() {
151
+ const url = await getStore().get({ key: "webhookUrl" });
152
+ return url || "";
153
+ },
154
+ async setWebhookUrl(url) {
155
+ await getStore().set({ key: "webhookUrl", value: url });
156
+ },
157
+ async trigger(documentIds) {
158
+ const webhookUrl = await this.getWebhookUrl();
159
+ if (!webhookUrl) {
160
+ return { triggered: false, error: "No webhook URL configured" };
161
+ }
162
+ if (!isAllowedWebhookUrl(webhookUrl)) {
163
+ strapi.log.warn("bulk-publish: webhook URL blocked by SSRF protection");
164
+ return { triggered: false, error: "Webhook URL is not allowed" };
165
+ }
166
+ try {
167
+ const response = await fetch(webhookUrl, {
168
+ method: "POST",
169
+ headers: { "Content-Type": "application/json" },
170
+ body: JSON.stringify({
171
+ event: "bulk-publish",
172
+ posts: documentIds,
173
+ publishedAt: (/* @__PURE__ */ new Date()).toISOString()
174
+ }),
175
+ signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS)
176
+ });
177
+ if (!response.ok) {
178
+ const text = await response.text();
179
+ const truncated = text.length > MAX_LOG_BODY_LENGTH ? text.slice(0, MAX_LOG_BODY_LENGTH) + "..." : text;
180
+ strapi.log.warn(`bulk-publish webhook returned ${response.status}: ${truncated}`);
181
+ return { triggered: true, error: `Webhook returned ${response.status}` };
182
+ }
183
+ return { triggered: true };
184
+ } catch (err) {
185
+ const message = err instanceof Error ? err.message : "Webhook call failed";
186
+ strapi.log.error(`bulk-publish: webhook call failed: ${message}`);
187
+ return { triggered: false, error: message };
188
+ }
189
+ }
190
+ };
191
+ };
192
+ const MAX_BATCH_SIZE = 100;
193
+ const bulkPublish = ({ strapi }) => {
194
+ const publishService = () => strapi.plugin("bulk-publish").service("publish");
195
+ const webhookService = () => strapi.plugin("bulk-publish").service("webhook");
196
+ return {
197
+ async getPosts(ctx) {
198
+ const posts = await publishService().getDraftPosts();
199
+ ctx.body = { data: posts };
200
+ },
201
+ async publish(ctx) {
202
+ const { documentIds } = ctx.request.body;
203
+ if (!Array.isArray(documentIds) || documentIds.length === 0) {
204
+ return ctx.badRequest("documentIds must be a non-empty array");
205
+ }
206
+ if (documentIds.length > MAX_BATCH_SIZE) {
207
+ return ctx.badRequest(`Cannot publish more than ${MAX_BATCH_SIZE} documents at once`);
208
+ }
209
+ if (!documentIds.every((id) => typeof id === "string" && id.length > 0)) {
210
+ return ctx.badRequest("Each documentId must be a non-empty string");
211
+ }
212
+ const { published, errors } = await publishService().publishMany(documentIds);
213
+ let webhookResult = { triggered: false, error: "No posts published" };
214
+ if (published.length > 0) {
215
+ webhookResult = await webhookService().trigger(
216
+ published.map((p) => p.documentId)
217
+ );
218
+ }
219
+ ctx.body = {
220
+ data: {
221
+ published,
222
+ errors,
223
+ webhookTriggered: webhookResult.triggered,
224
+ webhookError: webhookResult.error || null
225
+ }
226
+ };
227
+ },
228
+ async getSettings(ctx) {
229
+ const webhookUrl = await webhookService().getWebhookUrl();
230
+ ctx.body = { data: { webhookUrl } };
231
+ },
232
+ async updateSettings(ctx) {
233
+ const { webhookUrl } = ctx.request.body;
234
+ if (typeof webhookUrl !== "string") {
235
+ return ctx.badRequest("webhookUrl must be a string");
236
+ }
237
+ if (webhookUrl && !isAllowedWebhookUrl(webhookUrl)) {
238
+ return ctx.badRequest("webhookUrl must be a valid public HTTP(S) URL");
239
+ }
240
+ await webhookService().setWebhookUrl(webhookUrl);
241
+ ctx.body = { data: { webhookUrl } };
242
+ }
243
+ };
244
+ };
245
+ const controllers = {
246
+ "bulk-publish": bulkPublish
247
+ };
248
+ const publish = ({ strapi }) => {
249
+ const getContentType = () => strapi.plugin("bulk-publish").config("contentType");
250
+ const getTitleField = () => strapi.plugin("bulk-publish").config("titleField") || "title";
251
+ return {
252
+ async getAvailableLocales() {
253
+ const localeService = strapi.plugin("i18n")?.service("locales");
254
+ if (!localeService) {
255
+ return ["en"];
256
+ }
257
+ const locales = await localeService.find();
258
+ return locales.map((l) => l.code);
259
+ },
260
+ async getDefaultLocale() {
261
+ const localeService = strapi.plugin("i18n")?.service("locales");
262
+ if (!localeService) {
263
+ return "en";
264
+ }
265
+ const defaultLocale = await localeService.getDefaultLocale();
266
+ return defaultLocale || "en";
267
+ },
268
+ async getDraftPosts() {
269
+ const allLocales = await this.getAvailableLocales();
270
+ const defaultLocale = await this.getDefaultLocale();
271
+ const contentType = getContentType();
272
+ const titleField = getTitleField();
273
+ const draftsByLocale = /* @__PURE__ */ new Map();
274
+ const publishedByLocale = /* @__PURE__ */ new Map();
275
+ await Promise.all(
276
+ allLocales.map(async (locale) => {
277
+ const [drafts, published] = await Promise.all([
278
+ strapi.documents(contentType).findMany({
279
+ locale,
280
+ status: "draft",
281
+ fields: [titleField, "updatedAt"],
282
+ limit: 1e3
283
+ }),
284
+ strapi.documents(contentType).findMany({
285
+ locale,
286
+ status: "published",
287
+ fields: ["updatedAt"],
288
+ limit: 1e3
289
+ })
290
+ ]);
291
+ const localeMap = /* @__PURE__ */ new Map();
292
+ for (const doc of drafts) {
293
+ localeMap.set(doc.documentId, {
294
+ title: String(doc[titleField] ?? ""),
295
+ updatedAt: doc.updatedAt
296
+ });
297
+ }
298
+ draftsByLocale.set(locale, localeMap);
299
+ const pubMap = /* @__PURE__ */ new Map();
300
+ for (const doc of published) {
301
+ pubMap.set(doc.documentId, { updatedAt: doc.updatedAt });
302
+ }
303
+ publishedByLocale.set(locale, pubMap);
304
+ })
305
+ );
306
+ const allDocIds = /* @__PURE__ */ new Set();
307
+ for (const localeMap of draftsByLocale.values()) {
308
+ for (const docId of localeMap.keys()) {
309
+ allDocIds.add(docId);
310
+ }
311
+ }
312
+ const posts = [];
313
+ for (const docId of allDocIds) {
314
+ let hasUnpublishedLocale = false;
315
+ let latestUpdatedAt = 0;
316
+ let primaryTitle = "";
317
+ const locales = [];
318
+ for (const locale of allLocales) {
319
+ const draft = draftsByLocale.get(locale)?.get(docId);
320
+ const pub = publishedByLocale.get(locale)?.get(docId);
321
+ if (!draft) {
322
+ locales.push({ locale, status: "missing" });
323
+ continue;
324
+ }
325
+ if (locale === defaultLocale) {
326
+ primaryTitle = draft.title;
327
+ }
328
+ const draftTime = new Date(draft.updatedAt).getTime();
329
+ if (draftTime > latestUpdatedAt) {
330
+ latestUpdatedAt = draftTime;
331
+ }
332
+ const needsPublishing = !pub || new Date(draft.updatedAt) > new Date(pub.updatedAt);
333
+ if (needsPublishing) {
334
+ hasUnpublishedLocale = true;
335
+ locales.push({ locale, status: "draft" });
336
+ } else {
337
+ locales.push({ locale, status: "published" });
338
+ }
339
+ }
340
+ if (!hasUnpublishedLocale) continue;
341
+ posts.push({
342
+ documentId: docId,
343
+ title: primaryTitle || "(untitled)",
344
+ updatedAt: new Date(latestUpdatedAt).toISOString(),
345
+ locales
346
+ });
347
+ }
348
+ posts.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
349
+ return posts;
350
+ },
351
+ async publishDocument(documentId, locales) {
352
+ const allLocales = locales ?? await this.getAvailableLocales();
353
+ const defaultLocale = await this.getDefaultLocale();
354
+ const contentType = getContentType();
355
+ const titleField = getTitleField();
356
+ const localesPublished = [];
357
+ const localeErrors = [];
358
+ let title = "";
359
+ for (const locale of allLocales) {
360
+ try {
361
+ const draft = await strapi.documents(contentType).findOne({
362
+ documentId,
363
+ locale,
364
+ status: "draft",
365
+ fields: [titleField, "updatedAt"]
366
+ });
367
+ if (!draft) continue;
368
+ const draftDoc = draft;
369
+ if (locale === defaultLocale && draftDoc[titleField]) {
370
+ title = String(draftDoc[titleField]);
371
+ }
372
+ const published = await strapi.documents(contentType).findOne({
373
+ documentId,
374
+ locale,
375
+ status: "published",
376
+ fields: ["updatedAt"]
377
+ });
378
+ if (published && new Date(draftDoc.updatedAt) <= new Date(published.updatedAt)) {
379
+ continue;
380
+ }
381
+ await strapi.documents(contentType).publish({
382
+ documentId,
383
+ locale
384
+ });
385
+ localesPublished.push(locale);
386
+ } catch (err) {
387
+ const message = err instanceof Error ? err.message : "Unknown error";
388
+ strapi.log.error(
389
+ `bulk-publish: failed to publish ${documentId} locale ${locale}: ${message}`
390
+ );
391
+ localeErrors.push({ locale, error: message });
392
+ }
393
+ }
394
+ if (!title) {
395
+ title = `Document ${documentId}`;
396
+ }
397
+ return { documentId, title, localesPublished, localeErrors };
398
+ },
399
+ async publishMany(documentIds) {
400
+ const allLocales = await this.getAvailableLocales();
401
+ const published = [];
402
+ const errors = [];
403
+ for (const documentId of documentIds) {
404
+ try {
405
+ const result = await this.publishDocument(documentId, allLocales);
406
+ if (result.localesPublished.length > 0) {
407
+ published.push(result);
408
+ } else {
409
+ errors.push({ documentId, error: "No draft locales found to publish" });
410
+ }
411
+ } catch (err) {
412
+ const message = err instanceof Error ? err.message : "Unknown error";
413
+ errors.push({ documentId, error: message });
414
+ }
415
+ }
416
+ return { published, errors };
417
+ }
418
+ };
419
+ };
420
+ const services = {
421
+ publish,
422
+ webhook
423
+ };
424
+ const index = {
425
+ register,
426
+ bootstrap,
427
+ config,
428
+ routes,
429
+ controllers,
430
+ services
431
+ };
432
+ module.exports = index;