@vicacha-devs/strapi-plugin-inbox 1.1.0 → 1.1.2

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.
@@ -43,15 +43,122 @@ const DEFAULT_INBOX_SETTINGS = {
43
43
  defaultNotifyEmails: [],
44
44
  outboundWebhookUrl: ""
45
45
  };
46
+ const SUPPORTED_INTL_CALENDARS = Object.freeze([
47
+ { id: "gregory", intlCalendar: "gregory", defaultLabel: "Gregorian" },
48
+ { id: "iso8601", intlCalendar: "iso8601", defaultLabel: "ISO 8601" },
49
+ {
50
+ id: "islamic-umalqura",
51
+ intlCalendar: "islamic-umalqura",
52
+ defaultLabel: "Hijri (Umm al-Qura)"
53
+ },
54
+ {
55
+ id: "islamic-civil",
56
+ intlCalendar: "islamic-civil",
57
+ defaultLabel: "Hijri (Civil)"
58
+ },
59
+ {
60
+ id: "islamic-tbla",
61
+ intlCalendar: "islamic-tbla",
62
+ defaultLabel: "Hijri (Tabular)"
63
+ },
64
+ { id: "hebrew", intlCalendar: "hebrew", defaultLabel: "Hebrew" },
65
+ {
66
+ id: "persian",
67
+ intlCalendar: "persian",
68
+ defaultLabel: "Persian (Solar Hijri)"
69
+ },
70
+ { id: "buddhist", intlCalendar: "buddhist", defaultLabel: "Buddhist" },
71
+ { id: "japanese", intlCalendar: "japanese", defaultLabel: "Japanese" },
72
+ { id: "chinese", intlCalendar: "chinese", defaultLabel: "Chinese Lunar" },
73
+ { id: "indian", intlCalendar: "indian", defaultLabel: "Indian National" },
74
+ { id: "coptic", intlCalendar: "coptic", defaultLabel: "Coptic" },
75
+ { id: "ethiopic", intlCalendar: "ethiopic", defaultLabel: "Ethiopic" }
76
+ ]);
77
+ Object.freeze([
78
+ "none",
79
+ ...SUPPORTED_INTL_CALENDARS.map((item) => item.id)
80
+ ]);
81
+ const BASE_BCP47_REGEX = /^[a-zA-Z]{2,3}(?:-[a-zA-Z0-9]{2,8})*$/;
82
+ function sanitizeCalendarBaseLocale(locale, fallbackLocale = "es") {
83
+ if (typeof locale !== "string" || !locale.trim()) {
84
+ return fallbackLocale;
85
+ }
86
+ const trimmed = locale.trim();
87
+ const uIdx = trimmed.toLowerCase().indexOf("-u-");
88
+ let stripped = uIdx >= 0 ? trimmed.slice(0, uIdx) : trimmed;
89
+ while (stripped.endsWith("-")) {
90
+ stripped = stripped.slice(0, -1);
91
+ }
92
+ if (!stripped || stripped.length > 35 || !BASE_BCP47_REGEX.test(stripped)) {
93
+ return fallbackLocale;
94
+ }
95
+ return stripped;
96
+ }
97
+ function formatSecondaryDate(isoDateString, calendar = "islamic-umalqura", locale = "es") {
98
+ if (!isoDateString || calendar === "none") {
99
+ return { calendar, formatted: "" };
100
+ }
101
+ const date = new Date(isoDateString);
102
+ if (Number.isNaN(date.getTime())) {
103
+ return { calendar, formatted: isoDateString };
104
+ }
105
+ try {
106
+ const baseLocale = sanitizeCalendarBaseLocale(locale);
107
+ const formatter = new Intl.DateTimeFormat(`${baseLocale}-u-ca-${calendar}`, {
108
+ year: "numeric",
109
+ month: "short",
110
+ day: "numeric"
111
+ });
112
+ const parts = formatter.formatToParts(date);
113
+ const year = Number(parts.find((p) => p.type === "year")?.value) || void 0;
114
+ const month = Number(parts.find((p) => p.type === "month")?.value) || void 0;
115
+ const day = Number(parts.find((p) => p.type === "day")?.value) || void 0;
116
+ return {
117
+ calendar,
118
+ formatted: formatter.format(date),
119
+ year,
120
+ month,
121
+ day
122
+ };
123
+ } catch {
124
+ return {
125
+ calendar,
126
+ formatted: date.toISOString().slice(0, 10)
127
+ };
128
+ }
129
+ }
46
130
  function isValidEmail(value) {
47
- if (/\s/.test(value))
131
+ if (typeof value !== "string" || value.length < 3 || value.length > 254) {
132
+ return false;
133
+ }
134
+ if (/[\u0000-\u0020\u007f]/.test(value) || /\s/.test(value))
48
135
  return false;
49
136
  const at = value.indexOf("@");
50
- if (at < 1 || at !== value.lastIndexOf("@"))
137
+ if (at < 1 || at > 64 || at !== value.lastIndexOf("@"))
138
+ return false;
139
+ const local = value.slice(0, at);
140
+ if (local.startsWith(".") || local.endsWith(".") || local.includes("..")) {
51
141
  return false;
142
+ }
52
143
  const domain = value.slice(at + 1);
53
- const dot = domain.indexOf(".");
54
- return dot > 0 && dot < domain.length - 1;
144
+ if (domain.startsWith(".") || domain.endsWith(".") || domain.includes("..")) {
145
+ return false;
146
+ }
147
+ const labels = domain.split(".");
148
+ if (labels.length < 2)
149
+ return false;
150
+ return labels.every((label) => label.length > 0 && label.length <= 63 && !label.startsWith("-") && !label.endsWith("-"));
151
+ }
152
+ const CSV_FORMULA_PREFIX_RE = /^[\s\u0085\u2028\u2029]*[=+\-@|]|^[\t\r\n]/;
153
+ function escapeCsvCell(value) {
154
+ if (value === null || value === void 0)
155
+ return "";
156
+ const raw = typeof value === "object" ? JSON.stringify(value) : String(value);
157
+ const sanitized = raw.length > 0 && CSV_FORMULA_PREFIX_RE.test(raw) ? `'${raw}` : raw;
158
+ if (sanitized.includes('"') || sanitized.includes(",") || sanitized.includes("\n") || sanitized.includes("\r")) {
159
+ return `"${sanitized.replace(/"/g, '""')}"`;
160
+ }
161
+ return sanitized;
55
162
  }
56
163
  function freezeOptions(items) {
57
164
  return Object.freeze(items.map((item) => Object.freeze({ ...item })));
@@ -83,6 +190,54 @@ freezeOptions([
83
190
  { value: "persian", label: "persian (Solar Hijri)" },
84
191
  { value: "none", label: "none (Gregorian Only)" }
85
192
  ]);
193
+ class SlidingWindowRateLimiter {
194
+ hits = /* @__PURE__ */ new Map();
195
+ maxTrackedKeys;
196
+ defaultWindowMs;
197
+ constructor(options2) {
198
+ this.maxTrackedKeys = options2?.maxTrackedKeys ?? 1e4;
199
+ this.defaultWindowMs = options2?.defaultWindowMs ?? 60 * 60 * 1e3;
200
+ }
201
+ check(ip, bucketKey, limitPerWindow, nowMs = Date.now(), windowMs = this.defaultWindowMs) {
202
+ if (limitPerWindow <= 0) {
203
+ return { allowed: true, remaining: 999, retryAfterSeconds: 0 };
204
+ }
205
+ if (this.hits.size > this.maxTrackedKeys) {
206
+ const oldestKey = this.hits.keys().next().value;
207
+ if (oldestKey !== void 0) {
208
+ this.hits.delete(oldestKey);
209
+ }
210
+ }
211
+ const key = `${bucketKey}::${ip || "unknown"}`;
212
+ const cutoff = nowMs - windowMs;
213
+ const previous = (this.hits.get(key) || []).filter((ts) => ts > cutoff);
214
+ if (previous.length >= limitPerWindow) {
215
+ const oldest = previous[0] ?? nowMs;
216
+ const retryAfterSeconds = Math.max(1, Math.ceil((oldest + windowMs - nowMs) / 1e3));
217
+ this.hits.set(key, previous);
218
+ return {
219
+ allowed: false,
220
+ remaining: 0,
221
+ retryAfterSeconds
222
+ };
223
+ }
224
+ previous.push(nowMs);
225
+ this.hits.set(key, previous);
226
+ return {
227
+ allowed: true,
228
+ remaining: Math.max(0, limitPerWindow - previous.length),
229
+ retryAfterSeconds: 0
230
+ };
231
+ }
232
+ reset() {
233
+ this.hits.clear();
234
+ }
235
+ }
236
+ const FORBIDDEN_OBJECT_KEYS = /* @__PURE__ */ new Set([
237
+ "__proto__",
238
+ "constructor",
239
+ "prototype"
240
+ ]);
86
241
  const LOCALIZED_REQUIRED_FALLBACK = {
87
242
  es: (label) => `${label} es obligatorio`,
88
243
  en: (label) => `${label} is required`,
@@ -180,12 +335,23 @@ function checkNumericBoundRule(rule, limit, rawValue, fieldLabel, locale) {
180
335
  }
181
336
  return null;
182
337
  }
338
+ function hasNestedQuantifier(pattern) {
339
+ return /[+*?}]\s*\)\s*[+*?{]/.test(pattern.replaceAll(/\\./g, ""));
340
+ }
183
341
  function checkLengthOrNumberRule(rule, rawValue, strVal, fieldLabel, locale) {
184
- if (typeof rule.value !== "number") return null;
185
- return checkLengthRule(rule, rule.value, strVal, fieldLabel, locale) ?? checkNumericBoundRule(rule, rule.value, rawValue, fieldLabel, locale);
342
+ const numRuleVal = typeof rule.value === "number" ? rule.value : typeof rule.value === "string" && rule.value.trim() !== "" ? Number(rule.value) : Number.NaN;
343
+ if (!Number.isFinite(numRuleVal)) return null;
344
+ return checkLengthRule(rule, numRuleVal, strVal, fieldLabel, locale) ?? checkNumericBoundRule(rule, numRuleVal, rawValue, fieldLabel, locale);
186
345
  }
187
346
  function checkPatternOrEmailRule(rule, fieldType, strVal, fieldLabel, locale) {
188
347
  if (rule.type === "pattern" && typeof rule.value === "string") {
348
+ if (strVal.length > 4096 || rule.value.length > 512 || hasNestedQuantifier(rule.value)) {
349
+ return resolveRuleMessage(
350
+ rule,
351
+ locale,
352
+ `${fieldLabel} format is invalid`
353
+ );
354
+ }
189
355
  try {
190
356
  const regex = new RegExp(rule.value);
191
357
  if (!regex.test(strVal)) {
@@ -205,6 +371,13 @@ function checkPatternOrEmailRule(rule, fieldType, strVal, fieldLabel, locale) {
205
371
  `${fieldLabel} must be a valid email address`
206
372
  );
207
373
  }
374
+ if (rule.type === "url" && fieldType !== "url" && !isValidHttpUrl(strVal)) {
375
+ return resolveRuleMessage(
376
+ rule,
377
+ locale,
378
+ `${fieldLabel} must be a valid URL`
379
+ );
380
+ }
208
381
  return null;
209
382
  }
210
383
  function evaluateCustomRules(field, rawValue, strVal, fieldLabel, locale, rules) {
@@ -239,7 +412,7 @@ function buildRequiredError(rules, locale, fieldLabel) {
239
412
  return resolveRuleMessage(reqRule, locale, fallbackFn(fieldLabel));
240
413
  }
241
414
  function validateSingleField(field, data, locale) {
242
- const rawValue = data[field.name] ?? field.defaultValue;
415
+ const rawValue = Object.hasOwn(data, field.name) ? data[field.name] : field.defaultValue;
243
416
  const fieldLabel = resolveFieldLabel(field, locale);
244
417
  const rules = field.validation || [];
245
418
  if (field.required && isValueEmpty(rawValue)) {
@@ -272,7 +445,9 @@ function validateFormSubmission(fields, data, locale = "en") {
272
445
  const errors = {};
273
446
  const cleanedData = {};
274
447
  for (const field of fields) {
275
- if (DECORATIVE_FIELD_TYPES.includes(field.type)) continue;
448
+ if (DECORATIVE_FIELD_TYPES.includes(field.type) || FORBIDDEN_OBJECT_KEYS.has(field.name)) {
449
+ continue;
450
+ }
276
451
  const { fieldErrors, cleanedValue } = validateSingleField(
277
452
  field,
278
453
  data,
@@ -291,45 +466,10 @@ function validateFormSubmission(fields, data, locale = "en") {
291
466
  };
292
467
  }
293
468
  function isHoneypotTriggered(payload, honeypotField = "_website_url") {
294
- if (!honeypotField) return false;
469
+ if (!honeypotField || !Object.hasOwn(payload, honeypotField)) return false;
295
470
  const val = payload[honeypotField];
296
471
  return typeof val === "string" && val.trim().length > 0;
297
472
  }
298
- class SlidingWindowRateLimiter {
299
- hits = /* @__PURE__ */ new Map();
300
- check(ip, bucketKey, limitPerHour, nowMs = Date.now()) {
301
- if (limitPerHour <= 0) {
302
- return { allowed: true, remaining: 999, retryAfterSeconds: 0 };
303
- }
304
- const windowMs = 60 * 60 * 1e3;
305
- const key = `${bucketKey}::${ip || "unknown"}`;
306
- const cutoff = nowMs - windowMs;
307
- const previous = (this.hits.get(key) || []).filter((ts) => ts > cutoff);
308
- if (previous.length >= limitPerHour) {
309
- const oldest = previous[0] ?? nowMs;
310
- const retryAfterSeconds = Math.max(
311
- 1,
312
- Math.ceil((oldest + windowMs - nowMs) / 1e3)
313
- );
314
- this.hits.set(key, previous);
315
- return {
316
- allowed: false,
317
- remaining: 0,
318
- retryAfterSeconds
319
- };
320
- }
321
- previous.push(nowMs);
322
- this.hits.set(key, previous);
323
- return {
324
- allowed: true,
325
- remaining: Math.max(0, limitPerHour - previous.length),
326
- retryAfterSeconds: 0
327
- };
328
- }
329
- reset() {
330
- this.hits.clear();
331
- }
332
- }
333
473
  const defaultRateLimiter = new SlidingWindowRateLimiter();
334
474
  function resolveFetchRunner(fetchImpl) {
335
475
  return fetchImpl ?? globalThis.fetch;
@@ -688,16 +828,6 @@ function localizeFormForLocale(form, locale) {
688
828
  }
689
829
  };
690
830
  }
691
- const FORMULA_PREFIXES = ["=", "+", "-", "@", " ", "\r"];
692
- function escapeCsvCell(value) {
693
- if (value === null || value === void 0) return "";
694
- const raw = typeof value === "object" ? JSON.stringify(value) : String(value);
695
- const sanitized = raw.length > 0 && FORMULA_PREFIXES.includes(raw[0]) ? `'${raw}` : raw;
696
- if (sanitized.includes('"') || sanitized.includes(",") || sanitized.includes("\n") || sanitized.includes("\r")) {
697
- return `"${sanitized.replace(/"/g, '""')}"`;
698
- }
699
- return sanitized;
700
- }
701
831
  function collectDataFieldNames(submissions, fields) {
702
832
  const dataFieldNames = [];
703
833
  const seen = /* @__PURE__ */ new Set();
@@ -761,36 +891,7 @@ function submissionsToCsv(submissions, fields = []) {
761
891
  return rows.join("\n");
762
892
  }
763
893
  function convertIsoDateToCalendar(isoDateString, calendar = "islamic-umalqura", locale = "en") {
764
- if (!isoDateString) {
765
- return { calendar, formatted: "" };
766
- }
767
- const date = new Date(isoDateString);
768
- if (Number.isNaN(date.getTime())) {
769
- return { calendar, formatted: isoDateString };
770
- }
771
- try {
772
- const formatter = new Intl.DateTimeFormat(`${locale}-u-ca-${calendar}`, {
773
- year: "numeric",
774
- month: "short",
775
- day: "numeric"
776
- });
777
- const parts = formatter.formatToParts(date);
778
- const year = Number(parts.find((p) => p.type === "year")?.value) || void 0;
779
- const month = Number(parts.find((p) => p.type === "month")?.value) || void 0;
780
- const day = Number(parts.find((p) => p.type === "day")?.value) || void 0;
781
- return {
782
- calendar,
783
- formatted: formatter.format(date),
784
- year,
785
- month,
786
- day
787
- };
788
- } catch {
789
- return {
790
- calendar,
791
- formatted: date.toISOString().slice(0, 10)
792
- };
793
- }
894
+ return formatSecondaryDate(isoDateString, calendar, locale);
794
895
  }
795
896
  let mirroringToFormBuilderCms = false;
796
897
  function generateReferenceCode(now = /* @__PURE__ */ new Date()) {
@@ -1263,8 +1364,14 @@ function extractLifecycleFormId(event) {
1263
1364
  const fromResult = resultForm !== void 0 ? resolveCmsSubmissionFormRef({ form: resultForm }).formId : null;
1264
1365
  return fromResult ?? void 0;
1265
1366
  }
1367
+ const subscribedStrapiInstances = /* @__PURE__ */ new WeakSet();
1368
+ function clearSubscribedStrapiInstance(strapi) {
1369
+ subscribedStrapiInstances.delete(strapi);
1370
+ }
1266
1371
  function subscribeFormBuilderLifecycles(strapi, formService2, submissionService2) {
1267
1372
  if (!strapi.db?.lifecycles?.subscribe) return;
1373
+ if (subscribedStrapiInstances.has(strapi)) return;
1374
+ subscribedStrapiInstances.add(strapi);
1268
1375
  if (strapi.contentTypes?.[FORM_BUILDER_CMS_FORM_UID]) {
1269
1376
  strapi.db.lifecycles.subscribe({
1270
1377
  models: [FORM_BUILDER_CMS_FORM_UID],
@@ -1503,8 +1610,14 @@ function normalizeSubmitPayload(rawBody) {
1503
1610
  return nestedData ? { ...rawBody, ...nestedData } : rawBody;
1504
1611
  }
1505
1612
  function resolveClientIp(ctx) {
1506
- const forwarded = ctx.request.headers?.["x-forwarded-for"]?.split(",")[0]?.trim();
1507
- return forwarded || ctx.request.ip || "127.0.0.1";
1613
+ const directIp = ctx.ip || ctx.request.ip;
1614
+ return directIp && directIp.trim() ? directIp.trim() : "127.0.0.1";
1615
+ }
1616
+ function isAuthenticatedAdminContext(ctx) {
1617
+ const state = ctx.state;
1618
+ return Boolean(
1619
+ state?.user || state?.isAuthenticatedAdmin || state?.route?.info?.type === "admin"
1620
+ );
1508
1621
  }
1509
1622
  const ERROR_STATUS_MAP = {
1510
1623
  NotFoundError: 404,
@@ -1539,11 +1652,12 @@ const createFormHandlers = (strapi) => ({
1539
1652
  },
1540
1653
  async listForms(ctx) {
1541
1654
  const svc = strapi.plugin(PLUGIN_ID).service("formService");
1655
+ const isAdmin = isAuthenticatedAdminContext(ctx);
1542
1656
  const data = await svc.listForms({
1543
1657
  category: ctx.query?.category,
1544
- activeOnly: ctx.query?.activeOnly !== "false",
1658
+ activeOnly: isAdmin ? ctx.query?.activeOnly !== "false" : true,
1545
1659
  locale: ctx.query?.locale,
1546
- publicSafe: ctx.query?.admin !== "true"
1660
+ publicSafe: isAdmin ? ctx.query?.admin !== "true" : true
1547
1661
  });
1548
1662
  ctx.body = { data };
1549
1663
  },
@@ -1585,8 +1699,14 @@ const createFormHandlers = (strapi) => ({
1585
1699
  locale: rawBody.locale || ctx.query?.locale,
1586
1700
  captchaToken: rawBody.captchaToken || payload.captchaToken
1587
1701
  });
1702
+ const {
1703
+ ipAddress: _ipAddress,
1704
+ userAgent: _userAgent,
1705
+ staffNotes: _staffNotes,
1706
+ ...publicData
1707
+ } = data;
1588
1708
  ctx.status = 201;
1589
- ctx.body = { data };
1709
+ ctx.body = { data: publicData };
1590
1710
  } catch (err) {
1591
1711
  const error = err;
1592
1712
  ctx.status = ERROR_STATUS_MAP[error.name] ?? 400;
@@ -1606,6 +1726,7 @@ const controllers = {
1606
1726
  inboxController
1607
1727
  };
1608
1728
  const destroy = ({ strapi }) => {
1729
+ clearSubscribedStrapiInstance(strapi);
1609
1730
  strapi.log.debug("[@vicacha-devs/strapi-plugin-inbox] Destroyed");
1610
1731
  };
1611
1732
  const register = ({ strapi }) => {
@@ -1613,6 +1734,17 @@ const register = ({ strapi }) => {
1613
1734
  "[@vicacha-devs/strapi-plugin-inbox] Registered inbox & form builder plugin"
1614
1735
  );
1615
1736
  };
1737
+ const withPerm = (action) => [
1738
+ "admin::isAuthenticatedAdmin",
1739
+ {
1740
+ name: "admin::hasPermissions",
1741
+ config: { actions: [`plugin::inbox.${action}`] }
1742
+ }
1743
+ ];
1744
+ const FORMS_POLICIES = withPerm("forms.manage");
1745
+ const SUBMISSIONS_POLICIES = withPerm("submissions.manage");
1746
+ const SUBSCRIBERS_POLICIES = withPerm("subscribers.manage");
1747
+ const SETTINGS_POLICIES = withPerm("settings.manage");
1616
1748
  const admin = {
1617
1749
  type: "admin",
1618
1750
  routes: [
@@ -1620,103 +1752,103 @@ const admin = {
1620
1752
  method: "GET",
1621
1753
  path: "/settings",
1622
1754
  handler: "inboxController.getSettings",
1623
- config: { policies: [] }
1755
+ config: { policies: SETTINGS_POLICIES }
1624
1756
  },
1625
1757
  {
1626
1758
  method: "PUT",
1627
1759
  path: "/settings",
1628
1760
  handler: "inboxController.updateSettings",
1629
- config: { policies: [] }
1761
+ config: { policies: SETTINGS_POLICIES }
1630
1762
  },
1631
1763
  {
1632
1764
  method: "GET",
1633
1765
  path: "/templates",
1634
1766
  handler: "inboxController.listTemplates",
1635
- config: { policies: [] }
1767
+ config: { policies: FORMS_POLICIES }
1636
1768
  },
1637
1769
  {
1638
1770
  method: "GET",
1639
1771
  path: "/form-builder-forms",
1640
1772
  handler: "inboxController.listFormBuilderForms",
1641
- config: { policies: [] }
1773
+ config: { policies: FORMS_POLICIES }
1642
1774
  },
1643
1775
  {
1644
1776
  method: "POST",
1645
1777
  path: "/forms/sync-builder",
1646
1778
  handler: "inboxController.syncFormBuilder",
1647
- config: { policies: [] }
1779
+ config: { policies: FORMS_POLICIES }
1648
1780
  },
1649
1781
  {
1650
1782
  method: "GET",
1651
1783
  path: "/forms",
1652
1784
  handler: "inboxController.listForms",
1653
- config: { policies: [] }
1785
+ config: { policies: FORMS_POLICIES }
1654
1786
  },
1655
1787
  {
1656
1788
  method: "POST",
1657
1789
  path: "/forms",
1658
1790
  handler: "inboxController.saveForm",
1659
- config: { policies: [] }
1791
+ config: { policies: FORMS_POLICIES }
1660
1792
  },
1661
1793
  {
1662
1794
  method: "POST",
1663
1795
  path: "/forms/from-template",
1664
1796
  handler: "inboxController.createFromTemplate",
1665
- config: { policies: [] }
1797
+ config: { policies: FORMS_POLICIES }
1666
1798
  },
1667
1799
  {
1668
1800
  method: "GET",
1669
1801
  path: "/submissions",
1670
1802
  handler: "inboxController.listSubmissions",
1671
- config: { policies: [] }
1803
+ config: { policies: SUBMISSIONS_POLICIES }
1672
1804
  },
1673
1805
  {
1674
1806
  method: "PUT",
1675
1807
  path: "/submissions/:documentId/casework",
1676
1808
  handler: "inboxController.updateCasework",
1677
- config: { policies: [] }
1809
+ config: { policies: SUBMISSIONS_POLICIES }
1678
1810
  },
1679
1811
  {
1680
1812
  method: "POST",
1681
1813
  path: "/submissions/:documentId/schedule-booking",
1682
1814
  handler: "inboxController.scheduleBooking",
1683
- config: { policies: [] }
1815
+ config: { policies: SUBMISSIONS_POLICIES }
1684
1816
  },
1685
1817
  {
1686
1818
  method: "POST",
1687
1819
  path: "/submissions/:documentId/issue-certificate",
1688
1820
  handler: "inboxController.issueCertificate",
1689
- config: { policies: [] }
1821
+ config: { policies: SUBMISSIONS_POLICIES }
1690
1822
  },
1691
1823
  {
1692
1824
  method: "GET",
1693
1825
  path: "/submissions/export-csv",
1694
1826
  handler: "inboxController.exportCsv",
1695
- config: { policies: [] }
1827
+ config: { policies: SUBMISSIONS_POLICIES }
1696
1828
  },
1697
1829
  {
1698
1830
  method: "POST",
1699
1831
  path: "/captcha/test",
1700
1832
  handler: "inboxController.testCaptcha",
1701
- config: { policies: [] }
1833
+ config: { policies: SETTINGS_POLICIES }
1702
1834
  },
1703
1835
  {
1704
1836
  method: "GET",
1705
1837
  path: "/subscribers",
1706
1838
  handler: "inboxController.listSubscribers",
1707
- config: { policies: [] }
1839
+ config: { policies: SUBSCRIBERS_POLICIES }
1708
1840
  },
1709
1841
  {
1710
1842
  method: "GET",
1711
1843
  path: "/alerts",
1712
1844
  handler: "inboxController.getActiveAlerts",
1713
- config: { policies: [] }
1845
+ config: { policies: SUBSCRIBERS_POLICIES }
1714
1846
  },
1715
1847
  {
1716
1848
  method: "POST",
1717
1849
  path: "/alerts",
1718
1850
  handler: "inboxController.upsertAlertBanner",
1719
- config: { policies: [] }
1851
+ config: { policies: SUBSCRIBERS_POLICIES }
1720
1852
  }
1721
1853
  ]
1722
1854
  };
@@ -1777,6 +1909,15 @@ const contentApi = {
1777
1909
  auth: false
1778
1910
  }
1779
1911
  },
1912
+ {
1913
+ method: "GET",
1914
+ path: "/public/unsubscribe",
1915
+ handler: "inboxController.unsubscribe",
1916
+ config: {
1917
+ policies: [],
1918
+ auth: false
1919
+ }
1920
+ },
1780
1921
  {
1781
1922
  method: "GET",
1782
1923
  path: "/alerts",
@@ -1795,6 +1936,15 @@ const routes = {
1795
1936
  function getDocuments(strapi, uid) {
1796
1937
  return strapi.documents(uid);
1797
1938
  }
1939
+ function sanitizeSafeCtaUrl(raw) {
1940
+ if (typeof raw !== "string") return "";
1941
+ const trimmed = raw.trim();
1942
+ if (!trimmed) return "";
1943
+ if (trimmed.startsWith("https://") || trimmed.startsWith("http://") || trimmed.startsWith("/") && !trimmed.startsWith("//")) {
1944
+ return trimmed;
1945
+ }
1946
+ return "";
1947
+ }
1798
1948
  function buildAlertBannerPayload(input) {
1799
1949
  return {
1800
1950
  code: input.code,
@@ -1802,7 +1952,7 @@ function buildAlertBannerPayload(input) {
1802
1952
  message: input.message,
1803
1953
  severity: input.severity ?? "info",
1804
1954
  ctaLabel: input.ctaLabel ?? "",
1805
- ctaUrl: input.ctaUrl ?? "",
1955
+ ctaUrl: sanitizeSafeCtaUrl(input.ctaUrl),
1806
1956
  dismissible: input.dismissible ?? true,
1807
1957
  active: input.active ?? true,
1808
1958
  priority: input.priority ?? 10,
@@ -1816,7 +1966,8 @@ const alertService = ({ strapi }) => ({
1816
1966
  const settingsService = strapi.plugin(PLUGIN_ID).service("settings");
1817
1967
  const settings2 = await settingsService.getSettings();
1818
1968
  const locale = params?.locale || settings2.defaultLocale || "es";
1819
- const nowMs = new Date(params?.nowIso || Date.now()).getTime();
1969
+ const parsedNowMs = new Date(params?.nowIso || Date.now()).getTime();
1970
+ const nowMs = Number.isNaN(parsedNowMs) ? Date.now() : parsedNowMs;
1820
1971
  const docs = await getDocuments(strapi, ALERT_BANNER_UID).findMany({
1821
1972
  filters: { active: true },
1822
1973
  limit: 50
@@ -1848,7 +1999,7 @@ const alertService = ({ strapi }) => ({
1848
1999
  title: tr?.title || banner.title,
1849
2000
  message: tr?.message || banner.message,
1850
2001
  ctaLabel: tr?.ctaLabel || banner.ctaLabel,
1851
- ctaUrl: tr?.ctaUrl || banner.ctaUrl,
2002
+ ctaUrl: sanitizeSafeCtaUrl(tr?.ctaUrl || banner.ctaUrl),
1852
2003
  secondaryDateLabel: secondaryDate.formatted
1853
2004
  };
1854
2005
  });
@@ -1862,17 +2013,23 @@ const alertService = ({ strapi }) => ({
1862
2013
  );
1863
2014
  const existing = existingDocs[0] || null;
1864
2015
  const payload = buildAlertBannerPayload(input);
2016
+ let saved;
1865
2017
  if (existing?.documentId) {
1866
2018
  const updated = await getDocuments(strapi, ALERT_BANNER_UID).update({
1867
2019
  documentId: existing.documentId,
1868
2020
  data: payload
1869
2021
  });
1870
- return updated;
2022
+ saved = updated;
2023
+ } else {
2024
+ const created = await getDocuments(strapi, ALERT_BANNER_UID).create({
2025
+ data: payload
2026
+ });
2027
+ saved = created;
1871
2028
  }
1872
- const created = await getDocuments(strapi, ALERT_BANNER_UID).create({
1873
- data: payload
1874
- });
1875
- return created;
2029
+ if (saved.active) {
2030
+ strapi.eventHub?.emit("inbox.alert.published", { entry: saved });
2031
+ }
2032
+ return saved;
1876
2033
  }
1877
2034
  });
1878
2035
  function sanitizePublicForm(form, locale) {
@@ -2671,18 +2828,20 @@ function assertRequiredChecklistVerified(existing, requireVerified) {
2671
2828
  async function tryIssuePluginCertificate(strapi, existing, certInput, fallbackCertNumber) {
2672
2829
  try {
2673
2830
  const certsPlugin = strapi.plugin("certificates");
2674
- const certSvc = certsPlugin?.service("certificateService");
2831
+ const certSvc = certsPlugin?.service("issuance") ?? certsPlugin?.service("certificateService");
2675
2832
  if (!certSvc?.issueCertificate) return fallbackCertNumber;
2833
+ const recipientFullName = certInput?.recipientName || existing.applicantName || "Applicant";
2676
2834
  const issued = await certSvc.issueCertificate({
2677
2835
  templateSlug: certInput?.templateSlug || "official-service-certificate",
2678
- recipientName: certInput?.recipientName || existing.applicantName || "Applicant",
2836
+ recipientName: recipientFullName,
2837
+ recipientFullName,
2679
2838
  recipientEmail: existing.applicantEmail,
2680
2839
  metadata: {
2681
2840
  submissionReference: existing.referenceCode,
2682
2841
  formSlug: existing.formSlug
2683
2842
  }
2684
2843
  });
2685
- return issued?.certificateNumber || fallbackCertNumber;
2844
+ return issued?.serialNumber || issued?.certificateNumber || fallbackCertNumber;
2686
2845
  } catch {
2687
2846
  return fallbackCertNumber;
2688
2847
  }
@@ -2963,7 +3122,8 @@ function pickSubscriberStr(...values) {
2963
3122
  function buildSubscriberPayload(email, input, existing, settings2) {
2964
3123
  const doubleOptIn = settings2.enableDoubleOptIn !== false;
2965
3124
  const nowIso = (/* @__PURE__ */ new Date()).toISOString();
2966
- const status = doubleOptIn ? "pending" : "confirmed";
3125
+ const alreadyConfirmed = existing?.status === "confirmed";
3126
+ const status = alreadyConfirmed || !doubleOptIn ? "confirmed" : "pending";
2967
3127
  return {
2968
3128
  email,
2969
3129
  fullName: pickSubscriberStr(input.fullName, existing?.fullName),
@@ -2971,12 +3131,12 @@ function buildSubscriberPayload(email, input, existing, settings2) {
2971
3131
  locale: pickSubscriberStr(input.locale, settings2.defaultLocale, "es"),
2972
3132
  topics: normalizeSubscriberTopics(input.topics),
2973
3133
  status,
2974
- confirmToken: doubleOptIn ? randomBytes(20).toString("hex") : null,
3134
+ confirmToken: alreadyConfirmed || !doubleOptIn ? null : randomBytes(20).toString("hex"),
2975
3135
  unsubscribeToken: pickSubscriberStr(
2976
3136
  existing?.unsubscribeToken,
2977
3137
  randomBytes(20).toString("hex")
2978
3138
  ),
2979
- confirmedAt: doubleOptIn ? null : nowIso,
3139
+ confirmedAt: alreadyConfirmed ? existing.confirmedAt || nowIso : doubleOptIn ? null : nowIso,
2980
3140
  unsubscribedAt: null,
2981
3141
  sourceFormSlug: pickSubscriberStr(
2982
3142
  input.sourceFormSlug,
@@ -1,4 +1,5 @@
1
1
  import { Core } from '@strapi/strapi';
2
+ export declare function clearSubscribedStrapiInstance(strapi: Core.Strapi): void;
2
3
  declare const bootstrap: ({ strapi }: {
3
4
  strapi: Core.Strapi;
4
5
  }) => Promise<void>;