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