@vicacha-devs/strapi-plugin-certificates 0.0.0-stage → 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.
- package/LICENSE.md +21 -0
- package/README.md +17 -2
- package/dist/admin/CertificatesPage-CFDIEBN_.js +783 -0
- package/dist/admin/CertificatesPage-DeGmoX7h.cjs +783 -0
- package/dist/admin/SettingsPage-CsWFO1XB.js +447 -0
- package/dist/admin/SettingsPage-D7Ob1gV0.cjs +447 -0
- package/dist/admin/ar-Bv3T0_7C.cjs +258 -0
- package/dist/admin/ar-CDFHNnFd.js +258 -0
- package/dist/admin/de-BjMxn-Lt.js +258 -0
- package/dist/admin/de-DB5eW-yI.cjs +258 -0
- package/dist/admin/en-BI5Z3lPW.js +258 -0
- package/dist/admin/en-CsYV0VDf.cjs +258 -0
- package/dist/admin/es-B7uy9a-2.js +258 -0
- package/dist/admin/es-DEp3j3kD.cjs +258 -0
- package/dist/admin/fr-D7y038Pm.cjs +258 -0
- package/dist/admin/fr-DxbtdEIA.js +258 -0
- package/dist/admin/id-DfW4ARVm.js +258 -0
- package/dist/admin/id-Wuw9jl-Y.cjs +258 -0
- package/dist/admin/index-BQdS3gc3.cjs +707 -0
- package/dist/admin/index-BYO34da4.js +708 -0
- package/dist/admin/index.cjs +5 -0
- package/dist/admin/index.js +5 -0
- package/dist/admin/index.mjs +5 -0
- package/dist/admin/pt-C0QaikKS.js +258 -0
- package/dist/admin/pt-Dj2oc3Ga.cjs +258 -0
- package/dist/admin/src/components/IssueCertificateModal.d.ts +9 -0
- package/dist/admin/src/components/SettingsTabHelpers.d.ts +13 -0
- package/dist/admin/src/index.d.ts +11 -0
- package/dist/admin/src/pages/CertificatesPage.d.ts +3 -0
- package/dist/admin/src/pages/SettingsPage.d.ts +3 -0
- package/dist/admin/src/translations/cmLabels.d.ts +1 -0
- package/dist/admin/src/translations/cmPlaceholders.d.ts +1 -0
- package/dist/admin/tr-BbeagXRY.js +258 -0
- package/dist/admin/tr-DlGl_XMF.cjs +258 -0
- package/dist/admin/ur-Bwn4TrjF.js +258 -0
- package/dist/admin/ur-qIlUtgqb.cjs +258 -0
- package/dist/admin/zh-Cmg5mEvP.js +258 -0
- package/dist/admin/zh-D-3QDsuQ.cjs +258 -0
- package/dist/package.json +3 -0
- package/dist/server/index.cjs +2282 -0
- package/dist/server/index.js +2282 -0
- package/dist/server/index.mjs +2282 -0
- package/dist/server/src/bootstrap.d.ts +5 -0
- package/dist/server/src/content-types/certificate/schema.json.d.ts +165 -0
- package/dist/server/src/content-types/index.d.ts +277 -0
- package/dist/server/src/content-types/template/schema.json.d.ts +118 -0
- package/dist/server/src/controllers/admin.d.ts +31 -0
- package/dist/server/src/controllers/content-api.d.ts +28 -0
- package/dist/server/src/controllers/index.d.ts +30 -0
- package/dist/server/src/index.d.ts +413 -0
- package/dist/server/src/register.d.ts +5 -0
- package/dist/server/src/routes/admin.d.ts +12 -0
- package/dist/server/src/routes/content-api.d.ts +12 -0
- package/dist/server/src/routes/index.d.ts +25 -0
- package/dist/server/src/services/docusign.d.ts +76 -0
- package/dist/server/src/services/index.d.ts +76 -0
- package/dist/server/src/services/issuance.d.ts +54 -0
- package/dist/server/src/services/issuanceHelpers.d.ts +47 -0
- package/dist/server/src/services/settings.d.ts +17 -0
- package/dist/server/src/services/urlHelpers.d.ts +2 -0
- package/dist/shared/calendarSystems.d.ts +3 -0
- package/dist/shared/cryptoEngine.d.ts +27 -0
- package/dist/shared/documentRenderer.d.ts +12 -0
- package/dist/shared/index.cjs +636 -0
- package/dist/shared/index.d.ts +5 -0
- package/dist/shared/index.js +613 -0
- package/dist/shared/index.mjs +613 -0
- package/dist/shared/pluginId.d.ts +2 -0
- package/dist/shared/types.d.ts +194 -0
- package/package.json +112 -3
|
@@ -0,0 +1,2282 @@
|
|
|
1
|
+
import { formatDateInCalendarSystem, extractCalendarParts } from "@vicacha-devs/strapi-plugin-calendar/shared";
|
|
2
|
+
import { createHash, sign, generateKeyPairSync, createHmac, timingSafeEqual, verify, randomBytes } from "node:crypto";
|
|
3
|
+
const PLUGIN_ID = "certificates";
|
|
4
|
+
function formatSecondaryCalendars(date, systems, locale = "en") {
|
|
5
|
+
const result = {};
|
|
6
|
+
const dateStr = date.toISOString().slice(0, 10);
|
|
7
|
+
for (const sys of systems) {
|
|
8
|
+
if (!sys || !sys.enabled || !sys.id) continue;
|
|
9
|
+
const formatted = formatDateInCalendarSystem(dateStr, sys, locale, {
|
|
10
|
+
compact: false
|
|
11
|
+
});
|
|
12
|
+
const compact = formatDateInCalendarSystem(dateStr, sys, locale, {
|
|
13
|
+
compact: true
|
|
14
|
+
});
|
|
15
|
+
const parts = extractCalendarParts(
|
|
16
|
+
date,
|
|
17
|
+
sys.intlCalendar,
|
|
18
|
+
sys.dayOffset ?? 0
|
|
19
|
+
);
|
|
20
|
+
if (formatted) {
|
|
21
|
+
result[sys.id] = {
|
|
22
|
+
calendarId: sys.id,
|
|
23
|
+
label: sys.label || sys.id,
|
|
24
|
+
formatted,
|
|
25
|
+
compact: compact || formatted,
|
|
26
|
+
intlCalendar: sys.intlCalendar,
|
|
27
|
+
year: parts?.year,
|
|
28
|
+
month: parts?.month,
|
|
29
|
+
day: parts?.day,
|
|
30
|
+
isOverridden: false
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
function computePublicKeyFingerprint(publicKeyPem) {
|
|
37
|
+
const normalized = String(publicKeyPem || "").trim();
|
|
38
|
+
if (!normalized) return "";
|
|
39
|
+
const digest = createHash("sha256").update(normalized, "utf8").digest("hex");
|
|
40
|
+
return `SHA256:${digest.slice(0, 32).toUpperCase()}`;
|
|
41
|
+
}
|
|
42
|
+
function generateEd25519KeyPair() {
|
|
43
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519", {
|
|
44
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
45
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" }
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
publicKeyPem: publicKey,
|
|
49
|
+
privateKeyPem: privateKey,
|
|
50
|
+
fingerprint: computePublicKeyFingerprint(publicKey)
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function sanitizeSerialPrefix(prefix) {
|
|
54
|
+
const cleaned = String(prefix || "CERT").toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 6);
|
|
55
|
+
return cleaned || "CERT";
|
|
56
|
+
}
|
|
57
|
+
function generateSerialNumber(prefix = "CERT", year) {
|
|
58
|
+
const safePrefix = sanitizeSerialPrefix(prefix);
|
|
59
|
+
const targetYear = typeof year === "number" && Number.isFinite(year) ? year : (/* @__PURE__ */ new Date()).getUTCFullYear();
|
|
60
|
+
const entropy = randomBytes(4).toString("hex").toUpperCase();
|
|
61
|
+
return `${safePrefix}-${targetYear}-${entropy}`;
|
|
62
|
+
}
|
|
63
|
+
function generateVerificationToken() {
|
|
64
|
+
return randomBytes(24).toString("hex");
|
|
65
|
+
}
|
|
66
|
+
function normalizeNationalId(rawId) {
|
|
67
|
+
return String(rawId || "").toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
68
|
+
}
|
|
69
|
+
function maskNationalId(rawId) {
|
|
70
|
+
const normalized = normalizeNationalId(rawId);
|
|
71
|
+
if (!normalized) return "";
|
|
72
|
+
if (normalized.length <= 4) {
|
|
73
|
+
return `***-${normalized}`;
|
|
74
|
+
}
|
|
75
|
+
const tail = normalized.slice(-4);
|
|
76
|
+
return `***-***-${tail}`;
|
|
77
|
+
}
|
|
78
|
+
function hashNationalId(rawId, serialNumber) {
|
|
79
|
+
const normalized = normalizeNationalId(rawId);
|
|
80
|
+
if (!normalized) return "";
|
|
81
|
+
const cleanSerial = String(serialNumber || "").trim().toUpperCase();
|
|
82
|
+
return createHash("sha256").update(`${cleanSerial}:${normalized}`, "utf8").digest("hex");
|
|
83
|
+
}
|
|
84
|
+
function verifyNationalIdHash(rawId, serialNumber, expectedHash) {
|
|
85
|
+
if (!expectedHash || !rawId) return false;
|
|
86
|
+
const computed = hashNationalId(rawId, serialNumber);
|
|
87
|
+
if (!computed) return false;
|
|
88
|
+
return verifyTokenConstantTime(computed, expectedHash);
|
|
89
|
+
}
|
|
90
|
+
function maskRecipientName(fullName) {
|
|
91
|
+
const parts = String(fullName || "").trim().split(/\s+/).filter(Boolean);
|
|
92
|
+
if (parts.length === 0) return "***";
|
|
93
|
+
if (parts.length === 1) {
|
|
94
|
+
const word = parts[0];
|
|
95
|
+
return word.length <= 2 ? `${word[0]}*` : `${word.slice(0, 2)}***`;
|
|
96
|
+
}
|
|
97
|
+
const first = parts[0];
|
|
98
|
+
const initials = parts.slice(1).map((part) => `${part.charAt(0).toUpperCase()}.`).join(" ");
|
|
99
|
+
return `${first} ${initials}`;
|
|
100
|
+
}
|
|
101
|
+
function cleanStr(val) {
|
|
102
|
+
return String(val ?? "").trim();
|
|
103
|
+
}
|
|
104
|
+
function serializeCanonicalPayload(payload) {
|
|
105
|
+
const signatories = payload.signatories ?? [];
|
|
106
|
+
const witnesses = payload.witnesses ?? [];
|
|
107
|
+
const ordered = {
|
|
108
|
+
version: "1.0",
|
|
109
|
+
serialNumber: cleanStr(payload.serialNumber),
|
|
110
|
+
templateSlug: cleanStr(payload.templateSlug),
|
|
111
|
+
organizationName: cleanStr(payload.organizationName),
|
|
112
|
+
recipientFullName: cleanStr(payload.recipientFullName),
|
|
113
|
+
recipientChosenName: cleanStr(payload.recipientChosenName),
|
|
114
|
+
nationalIdHash: cleanStr(payload.nationalIdHash),
|
|
115
|
+
issuedAt: cleanStr(payload.issuedAt),
|
|
116
|
+
expiresAt: cleanStr(payload.expiresAt),
|
|
117
|
+
signatories: signatories.map((s) => ({
|
|
118
|
+
name: cleanStr(s.name),
|
|
119
|
+
title: cleanStr(s.title)
|
|
120
|
+
})),
|
|
121
|
+
witnesses: witnesses.map((w) => ({
|
|
122
|
+
fullName: cleanStr(w.fullName),
|
|
123
|
+
role: cleanStr(w.role)
|
|
124
|
+
}))
|
|
125
|
+
};
|
|
126
|
+
return JSON.stringify(ordered);
|
|
127
|
+
}
|
|
128
|
+
function signCanonicalPayload(payload, privateKeyPem) {
|
|
129
|
+
const canonicalJson = serializeCanonicalPayload(payload);
|
|
130
|
+
const payloadHash = createHash("sha256").update(canonicalJson, "utf8").digest("hex");
|
|
131
|
+
const dataBuffer = Buffer.from(canonicalJson, "utf8");
|
|
132
|
+
const signature = sign(null, dataBuffer, privateKeyPem).toString("base64url");
|
|
133
|
+
return {
|
|
134
|
+
canonicalJson,
|
|
135
|
+
payloadHash,
|
|
136
|
+
signature
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function verifyCanonicalPayloadSignature(payload, signatureBase64Url, publicKeyPem) {
|
|
140
|
+
try {
|
|
141
|
+
if (!signatureBase64Url || !publicKeyPem) return false;
|
|
142
|
+
const canonicalJson = serializeCanonicalPayload(payload);
|
|
143
|
+
const dataBuffer = Buffer.from(canonicalJson, "utf8");
|
|
144
|
+
const sigBuffer = Buffer.from(signatureBase64Url, "base64url");
|
|
145
|
+
return verify(null, dataBuffer, publicKeyPem, sigBuffer);
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function verifyTokenConstantTime(providedToken, expectedToken) {
|
|
151
|
+
if (!providedToken || !expectedToken) return false;
|
|
152
|
+
const a = Buffer.from(String(providedToken), "utf8");
|
|
153
|
+
const b = Buffer.from(String(expectedToken), "utf8");
|
|
154
|
+
if (a.length !== b.length || a.length === 0) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
return timingSafeEqual(a, b);
|
|
158
|
+
}
|
|
159
|
+
function computeDocuSignWebhookHmac(rawPayload, webhookSecret) {
|
|
160
|
+
return createHmac("sha256", webhookSecret).update(rawPayload, "utf8").digest("base64");
|
|
161
|
+
}
|
|
162
|
+
function verifyDocuSignWebhookHmac(rawPayload, signatureHeaderBase64, webhookSecret) {
|
|
163
|
+
if (!rawPayload || !signatureHeaderBase64 || !webhookSecret) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
const computedBase64 = computeDocuSignWebhookHmac(rawPayload, webhookSecret);
|
|
167
|
+
return verifyTokenConstantTime(
|
|
168
|
+
String(signatureHeaderBase64).trim(),
|
|
169
|
+
computedBase64
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
function computeDeterministicHex(input) {
|
|
173
|
+
const out = [];
|
|
174
|
+
for (let pass = 0; pass < 8; pass++) {
|
|
175
|
+
let h = (2166136261 ^ pass) >>> 0;
|
|
176
|
+
for (let i = 0; i < input.length; i++) {
|
|
177
|
+
h ^= input.charCodeAt(i);
|
|
178
|
+
h = Math.imul(h, 16777619) >>> 0;
|
|
179
|
+
}
|
|
180
|
+
out.push(h.toString(16).padStart(8, "0"));
|
|
181
|
+
}
|
|
182
|
+
return out.join("");
|
|
183
|
+
}
|
|
184
|
+
function escapeHtml(input) {
|
|
185
|
+
return String(input ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
186
|
+
}
|
|
187
|
+
function sanitizeHexColor(input, fallback = "#14532d") {
|
|
188
|
+
const cleaned = String(input || "").trim();
|
|
189
|
+
if (/^#[0-9a-fA-F]{6}$/.test(cleaned)) {
|
|
190
|
+
return cleaned;
|
|
191
|
+
}
|
|
192
|
+
return fallback;
|
|
193
|
+
}
|
|
194
|
+
function interpolateBodyTemplate(templateText, variables) {
|
|
195
|
+
const safeTemplate = escapeHtml(templateText);
|
|
196
|
+
return safeTemplate.replace(
|
|
197
|
+
/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g,
|
|
198
|
+
(_match, key) => {
|
|
199
|
+
const rawVal = variables[key] ?? "";
|
|
200
|
+
return `<strong>${escapeHtml(rawVal)}</strong>`;
|
|
201
|
+
}
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
function renderVerificationMatrixSvg(seedText, accentColor = "#14532d") {
|
|
205
|
+
const safeColor = sanitizeHexColor(accentColor);
|
|
206
|
+
const hashHex = computeDeterministicHex(seedText);
|
|
207
|
+
const size = 9;
|
|
208
|
+
const cells = [];
|
|
209
|
+
for (let row = 0; row < size; row++) {
|
|
210
|
+
for (let col = 0; col < size; col++) {
|
|
211
|
+
const isCornerFinder = row < 3 && col < 3 || row < 3 && col >= size - 3 || row >= size - 3 && col < 3;
|
|
212
|
+
const hexIdx = (row * size + col) % hashHex.length;
|
|
213
|
+
const nibble = parseInt(hashHex[hexIdx], 16);
|
|
214
|
+
const filled = isCornerFinder || nibble % 2 === 0;
|
|
215
|
+
if (filled) {
|
|
216
|
+
const x = 6 + col * 8;
|
|
217
|
+
const y = 6 + row * 8;
|
|
218
|
+
cells.push(
|
|
219
|
+
`<rect x="${x}" y="${y}" width="6.5" height="6.5" rx="1" fill="${safeColor}" />`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return [
|
|
225
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 84 84" width="84" height="84" role="img" aria-label="Cryptographic Verification Matrix">',
|
|
226
|
+
`<rect x="1" y="1" width="82" height="82" rx="6" fill="#ffffff" stroke="${safeColor}" stroke-width="2" />`,
|
|
227
|
+
...cells,
|
|
228
|
+
"</svg>"
|
|
229
|
+
].join("");
|
|
230
|
+
}
|
|
231
|
+
function buildTemplateVariables(certificate, template, issuedDateFormatted, secondaryFormattedList) {
|
|
232
|
+
return {
|
|
233
|
+
recipientFullName: certificate.recipientFullName,
|
|
234
|
+
recipientChosenName: certificate.recipientChosenName || certificate.recipientFullName,
|
|
235
|
+
chosenName: certificate.recipientChosenName || "",
|
|
236
|
+
nationalIdMasked: certificate.nationalIdMasked || "N/A",
|
|
237
|
+
recipientNationality: certificate.recipientNationality || "",
|
|
238
|
+
issuedDateFormatted,
|
|
239
|
+
secondaryDateFormatted: secondaryFormattedList || issuedDateFormatted,
|
|
240
|
+
serialNumber: certificate.serialNumber,
|
|
241
|
+
organizationName: template.organizationName,
|
|
242
|
+
...certificate.customData
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
function renderSignatoriesHtml(signatories) {
|
|
246
|
+
return (signatories || []).map(
|
|
247
|
+
(sig, idx) => `
|
|
248
|
+
<div class="sig-block">
|
|
249
|
+
<div class="sig-line"><span class="esign-anchor">/sig_${idx + 1}/</span></div>
|
|
250
|
+
<div class="sig-name">${escapeHtml(sig.name)}</div>
|
|
251
|
+
<div class="sig-title">${escapeHtml(sig.title)}</div>
|
|
252
|
+
${sig.role ? `<div class="sig-role">${escapeHtml(sig.role)}</div>` : ""}
|
|
253
|
+
</div>`
|
|
254
|
+
).join("");
|
|
255
|
+
}
|
|
256
|
+
function renderWitnessesHtml(witnesses) {
|
|
257
|
+
const list = witnesses || [];
|
|
258
|
+
if (list.length === 0) return "";
|
|
259
|
+
const cards = list.map(
|
|
260
|
+
(w, idx) => `
|
|
261
|
+
<div class="witness-card">
|
|
262
|
+
<div class="sig-line"><span class="esign-anchor">/wit_${idx + 1}/</span></div>
|
|
263
|
+
<div class="witness-name">${escapeHtml(w.fullName)}</div>
|
|
264
|
+
<div class="witness-meta">
|
|
265
|
+
${w.role ? `<span>${escapeHtml(w.role)}</span>` : ""}
|
|
266
|
+
${w.documentIdMasked ? `<span>ID: ${escapeHtml(w.documentIdMasked)}</span>` : ""}
|
|
267
|
+
</div>
|
|
268
|
+
</div>`
|
|
269
|
+
).join("");
|
|
270
|
+
return `
|
|
271
|
+
<div class="witness-section">
|
|
272
|
+
<div class="witness-heading">Official Witnesses</div>
|
|
273
|
+
<div class="witness-grid">${cards}
|
|
274
|
+
</div>
|
|
275
|
+
</div>`;
|
|
276
|
+
}
|
|
277
|
+
function renderOptionalHtmlFragments(certificate, template, secondaryFormattedList) {
|
|
278
|
+
return {
|
|
279
|
+
statusBanner: certificate.status !== "issued" ? `<div class="watermark-alert">STATUS: ${escapeHtml(certificate.status.toUpperCase())}</div>` : "",
|
|
280
|
+
headerHtml: template.headerText ? `<div class="header-Invocation">${escapeHtml(template.headerText)}</div>` : "",
|
|
281
|
+
subheaderHtml: template.organizationSubheader ? `<div class="org-subheader">${escapeHtml(template.organizationSubheader)}</div>` : "",
|
|
282
|
+
chosenNameHtml: certificate.recipientChosenName ? `<div class="recipient-chosen">Chosen Name / Title: ${escapeHtml(certificate.recipientChosenName)}</div>` : "",
|
|
283
|
+
secondaryDateHtml: secondaryFormattedList ? `<span><strong>Secondary Calendar:</strong> ${escapeHtml(secondaryFormattedList)}</span>` : "",
|
|
284
|
+
nationalIdHtml: certificate.nationalIdMasked ? `<span><strong>Document ID:</strong> ${escapeHtml(certificate.nationalIdMasked)}</span>` : "",
|
|
285
|
+
esignLineHtml: certificate.esignEnvelopeId ? `<div>E-Sign Envelope (${escapeHtml(certificate.esignProvider || "docusign")}): ${escapeHtml(certificate.esignEnvelopeId)} [${escapeHtml(certificate.esignEnvelopeStatus || "sent")}]</div>` : ""
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
function renderPrintableCertificateHtml(params) {
|
|
289
|
+
const { certificate, template, verificationUrl } = params;
|
|
290
|
+
const accent = sanitizeHexColor(template.accentColor, "#14532d");
|
|
291
|
+
const issuedDateFormatted = certificate.issuedAt.slice(0, 10);
|
|
292
|
+
const secondaryFormattedList = Object.values(certificate.secondaryDates || {}).map((d) => `${d.label}: ${d.formatted}`).join(" • ");
|
|
293
|
+
const variables = buildTemplateVariables(
|
|
294
|
+
certificate,
|
|
295
|
+
template,
|
|
296
|
+
issuedDateFormatted,
|
|
297
|
+
secondaryFormattedList
|
|
298
|
+
);
|
|
299
|
+
const renderedBody = interpolateBodyTemplate(
|
|
300
|
+
template.bodyTemplate,
|
|
301
|
+
variables
|
|
302
|
+
);
|
|
303
|
+
const matrixSvg = renderVerificationMatrixSvg(
|
|
304
|
+
`${certificate.serialNumber}:${certificate.cryptographicSignature}`,
|
|
305
|
+
accent
|
|
306
|
+
);
|
|
307
|
+
const signatoriesHtml = renderSignatoriesHtml(certificate.signatories);
|
|
308
|
+
const witnessesHtml = renderWitnessesHtml(certificate.witnesses);
|
|
309
|
+
const fragments = renderOptionalHtmlFragments(
|
|
310
|
+
certificate,
|
|
311
|
+
template,
|
|
312
|
+
secondaryFormattedList
|
|
313
|
+
);
|
|
314
|
+
return `<!DOCTYPE html>
|
|
315
|
+
<html lang="en">
|
|
316
|
+
<head>
|
|
317
|
+
<meta charset="utf-8" />
|
|
318
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
319
|
+
<title>${escapeHtml(template.titleText)} — ${escapeHtml(certificate.serialNumber)}</title>
|
|
320
|
+
<style>
|
|
321
|
+
@page { size: A4 landscape; margin: 12mm; }
|
|
322
|
+
* { box-sizing: border-box; }
|
|
323
|
+
body {
|
|
324
|
+
margin: 0;
|
|
325
|
+
padding: 24px;
|
|
326
|
+
font-family: Georgia, "Times New Roman", serif;
|
|
327
|
+
background: #f8fafc;
|
|
328
|
+
color: #0f172a;
|
|
329
|
+
}
|
|
330
|
+
.certificate-frame {
|
|
331
|
+
max-width: 1020px;
|
|
332
|
+
margin: 0 auto;
|
|
333
|
+
background: #ffffff;
|
|
334
|
+
border: 3px solid ${accent};
|
|
335
|
+
padding: 10px;
|
|
336
|
+
box-shadow: 0 10px 25px rgba(15, 23, 42, 0.08);
|
|
337
|
+
position: relative;
|
|
338
|
+
}
|
|
339
|
+
.certificate-inner {
|
|
340
|
+
border: 1px solid ${accent};
|
|
341
|
+
padding: 36px 48px;
|
|
342
|
+
position: relative;
|
|
343
|
+
}
|
|
344
|
+
.watermark-alert {
|
|
345
|
+
background: #fef2f2;
|
|
346
|
+
color: #991b1b;
|
|
347
|
+
border: 2px solid #dc2626;
|
|
348
|
+
padding: 8px 16px;
|
|
349
|
+
text-align: center;
|
|
350
|
+
font-weight: bold;
|
|
351
|
+
letter-spacing: 0.1em;
|
|
352
|
+
margin-bottom: 16px;
|
|
353
|
+
}
|
|
354
|
+
.header-Invocation {
|
|
355
|
+
text-align: center;
|
|
356
|
+
font-size: 20px;
|
|
357
|
+
color: ${accent};
|
|
358
|
+
margin-bottom: 8px;
|
|
359
|
+
font-weight: 600;
|
|
360
|
+
}
|
|
361
|
+
.org-name {
|
|
362
|
+
text-align: center;
|
|
363
|
+
font-size: 24px;
|
|
364
|
+
font-weight: 700;
|
|
365
|
+
letter-spacing: 0.06em;
|
|
366
|
+
text-transform: uppercase;
|
|
367
|
+
color: #0f172a;
|
|
368
|
+
}
|
|
369
|
+
.org-subheader {
|
|
370
|
+
text-align: center;
|
|
371
|
+
font-size: 13px;
|
|
372
|
+
color: #475569;
|
|
373
|
+
margin-top: 4px;
|
|
374
|
+
margin-bottom: 24px;
|
|
375
|
+
}
|
|
376
|
+
.cert-title {
|
|
377
|
+
text-align: center;
|
|
378
|
+
font-size: 30px;
|
|
379
|
+
color: ${accent};
|
|
380
|
+
margin: 18px 0 24px;
|
|
381
|
+
font-weight: 700;
|
|
382
|
+
border-bottom: 2px solid ${accent};
|
|
383
|
+
display: inline-block;
|
|
384
|
+
padding-bottom: 6px;
|
|
385
|
+
}
|
|
386
|
+
.title-wrap { text-align: center; }
|
|
387
|
+
.recipient-banner {
|
|
388
|
+
text-align: center;
|
|
389
|
+
margin: 16px 0 20px;
|
|
390
|
+
}
|
|
391
|
+
.recipient-name {
|
|
392
|
+
font-size: 28px;
|
|
393
|
+
font-weight: 700;
|
|
394
|
+
color: #0f172a;
|
|
395
|
+
}
|
|
396
|
+
.recipient-chosen {
|
|
397
|
+
font-size: 18px;
|
|
398
|
+
color: ${accent};
|
|
399
|
+
margin-top: 4px;
|
|
400
|
+
font-style: italic;
|
|
401
|
+
}
|
|
402
|
+
.cert-body {
|
|
403
|
+
font-size: 16px;
|
|
404
|
+
line-height: 1.75;
|
|
405
|
+
text-align: center;
|
|
406
|
+
max-width: 820px;
|
|
407
|
+
margin: 0 auto 28px;
|
|
408
|
+
color: #1e293b;
|
|
409
|
+
}
|
|
410
|
+
.dates-bar {
|
|
411
|
+
display: flex;
|
|
412
|
+
justify-content: center;
|
|
413
|
+
gap: 28px;
|
|
414
|
+
font-size: 13px;
|
|
415
|
+
color: #334155;
|
|
416
|
+
margin-bottom: 28px;
|
|
417
|
+
flex-wrap: wrap;
|
|
418
|
+
}
|
|
419
|
+
.sig-row {
|
|
420
|
+
display: flex;
|
|
421
|
+
justify-content: space-around;
|
|
422
|
+
gap: 24px;
|
|
423
|
+
margin-top: 32px;
|
|
424
|
+
margin-bottom: 24px;
|
|
425
|
+
flex-wrap: wrap;
|
|
426
|
+
}
|
|
427
|
+
.sig-block {
|
|
428
|
+
min-width: 200px;
|
|
429
|
+
text-align: center;
|
|
430
|
+
}
|
|
431
|
+
.sig-line {
|
|
432
|
+
border-bottom: 1px solid #334155;
|
|
433
|
+
height: 36px;
|
|
434
|
+
margin-bottom: 8px;
|
|
435
|
+
}
|
|
436
|
+
.esign-anchor {
|
|
437
|
+
color: #ffffff;
|
|
438
|
+
font-size: 1px;
|
|
439
|
+
line-height: 1px;
|
|
440
|
+
opacity: 0.01;
|
|
441
|
+
user-select: none;
|
|
442
|
+
}
|
|
443
|
+
.sig-name { font-weight: 700; font-size: 15px; }
|
|
444
|
+
.sig-title { font-size: 13px; color: #475569; }
|
|
445
|
+
.sig-role { font-size: 12px; color: ${accent}; }
|
|
446
|
+
.witness-section {
|
|
447
|
+
border-top: 1px dashed #cbd5e1;
|
|
448
|
+
padding-top: 14px;
|
|
449
|
+
margin-top: 16px;
|
|
450
|
+
margin-bottom: 20px;
|
|
451
|
+
}
|
|
452
|
+
.witness-heading {
|
|
453
|
+
text-align: center;
|
|
454
|
+
font-size: 12px;
|
|
455
|
+
text-transform: uppercase;
|
|
456
|
+
letter-spacing: 0.08em;
|
|
457
|
+
color: #64748b;
|
|
458
|
+
margin-bottom: 10px;
|
|
459
|
+
}
|
|
460
|
+
.witness-grid {
|
|
461
|
+
display: flex;
|
|
462
|
+
justify-content: center;
|
|
463
|
+
gap: 24px;
|
|
464
|
+
flex-wrap: wrap;
|
|
465
|
+
}
|
|
466
|
+
.witness-card {
|
|
467
|
+
font-size: 13px;
|
|
468
|
+
text-align: center;
|
|
469
|
+
padding: 6px 14px;
|
|
470
|
+
background: #f8fafc;
|
|
471
|
+
border-radius: 6px;
|
|
472
|
+
border: 1px solid #e2e8f0;
|
|
473
|
+
}
|
|
474
|
+
.witness-name { font-weight: 600; }
|
|
475
|
+
.witness-meta { font-size: 11px; color: #64748b; display: flex; gap: 8px; justify-content: center; }
|
|
476
|
+
.crypto-footer {
|
|
477
|
+
display: flex;
|
|
478
|
+
align-items: center;
|
|
479
|
+
justify-content: space-between;
|
|
480
|
+
border-top: 2px solid #e2e8f0;
|
|
481
|
+
padding-top: 16px;
|
|
482
|
+
margin-top: 16px;
|
|
483
|
+
gap: 16px;
|
|
484
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
485
|
+
font-size: 11px;
|
|
486
|
+
color: #475569;
|
|
487
|
+
}
|
|
488
|
+
.crypto-details { line-height: 1.5; }
|
|
489
|
+
@media print {
|
|
490
|
+
body { background: #ffffff; padding: 0; }
|
|
491
|
+
.certificate-frame { box-shadow: none; }
|
|
492
|
+
}
|
|
493
|
+
</style>
|
|
494
|
+
</head>
|
|
495
|
+
<body>
|
|
496
|
+
<div class="certificate-frame">
|
|
497
|
+
<div class="certificate-inner">
|
|
498
|
+
${fragments.statusBanner}
|
|
499
|
+
${fragments.headerHtml}
|
|
500
|
+
<div class="org-name">${escapeHtml(template.organizationName)}</div>
|
|
501
|
+
${fragments.subheaderHtml}
|
|
502
|
+
<div class="title-wrap">
|
|
503
|
+
<div class="cert-title">${escapeHtml(template.titleText)}</div>
|
|
504
|
+
</div>
|
|
505
|
+
<div class="recipient-banner">
|
|
506
|
+
<div class="recipient-name">${escapeHtml(certificate.recipientFullName)}</div>
|
|
507
|
+
${fragments.chosenNameHtml}
|
|
508
|
+
</div>
|
|
509
|
+
<div class="cert-body">${renderedBody}</div>
|
|
510
|
+
<div class="dates-bar">
|
|
511
|
+
<span><strong>Serial No:</strong> ${escapeHtml(certificate.serialNumber)}</span>
|
|
512
|
+
<span><strong>Issued Date:</strong> ${escapeHtml(issuedDateFormatted)}</span>
|
|
513
|
+
${fragments.secondaryDateHtml}
|
|
514
|
+
${fragments.nationalIdHtml}
|
|
515
|
+
</div>
|
|
516
|
+
<div class="sig-row">${signatoriesHtml}</div>
|
|
517
|
+
${witnessesHtml}
|
|
518
|
+
<div class="crypto-footer">
|
|
519
|
+
<div class="crypto-details">
|
|
520
|
+
<div><strong>CRYPTOGRAPHICALLY SIGNED RECORD (Ed25519)</strong></div>
|
|
521
|
+
<div>Serial: ${escapeHtml(certificate.serialNumber)} | Key Fingerprint: ${escapeHtml(certificate.publicKeyFingerprint)}</div>
|
|
522
|
+
<div>Payload SHA-256: ${escapeHtml(certificate.canonicalPayloadHash.slice(0, 32))}...</div>
|
|
523
|
+
<div>Signature (Ed25519): ${escapeHtml(certificate.cryptographicSignature.slice(0, 40))}...</div>
|
|
524
|
+
${fragments.esignLineHtml}
|
|
525
|
+
<div>Verify Online: ${escapeHtml(verificationUrl)}</div>
|
|
526
|
+
</div>
|
|
527
|
+
<div>${matrixSvg}</div>
|
|
528
|
+
</div>
|
|
529
|
+
</div>
|
|
530
|
+
</div>
|
|
531
|
+
</body>
|
|
532
|
+
</html>`;
|
|
533
|
+
}
|
|
534
|
+
function escapeCsvCell(value) {
|
|
535
|
+
let str = String(value ?? "").replace(/\r?\n/g, " ").trim();
|
|
536
|
+
if (/^[=+\-@\t\r]/.test(str)) {
|
|
537
|
+
str = `'${str}`;
|
|
538
|
+
}
|
|
539
|
+
if (str.includes(",") || str.includes('"') || str.includes("'")) {
|
|
540
|
+
return `"${str.replace(/"/g, '""')}"`;
|
|
541
|
+
}
|
|
542
|
+
return str;
|
|
543
|
+
}
|
|
544
|
+
function exportCertificatesToCsv(records) {
|
|
545
|
+
const headers = [
|
|
546
|
+
"Serial Number",
|
|
547
|
+
"Template",
|
|
548
|
+
"Status",
|
|
549
|
+
"Recipient Full Name",
|
|
550
|
+
"Chosen Name / Title",
|
|
551
|
+
"Masked ID",
|
|
552
|
+
"Issued At",
|
|
553
|
+
"Expires At",
|
|
554
|
+
"Signatories",
|
|
555
|
+
"Witnesses Count",
|
|
556
|
+
"Key Fingerprint"
|
|
557
|
+
];
|
|
558
|
+
const rows = records.map((r) => [
|
|
559
|
+
escapeCsvCell(r.serialNumber),
|
|
560
|
+
escapeCsvCell(r.templateName || r.templateSlug),
|
|
561
|
+
escapeCsvCell(r.status),
|
|
562
|
+
escapeCsvCell(r.recipientFullName),
|
|
563
|
+
escapeCsvCell(r.recipientChosenName || ""),
|
|
564
|
+
escapeCsvCell(r.nationalIdMasked || ""),
|
|
565
|
+
escapeCsvCell(r.issuedAt),
|
|
566
|
+
escapeCsvCell(r.expiresAt || ""),
|
|
567
|
+
escapeCsvCell((r.signatories || []).map((s) => s.name).join("; ")),
|
|
568
|
+
escapeCsvCell((r.witnesses || []).length),
|
|
569
|
+
escapeCsvCell(r.publicKeyFingerprint)
|
|
570
|
+
]);
|
|
571
|
+
return [headers.join(","), ...rows.map((row) => row.join(","))].join("\n");
|
|
572
|
+
}
|
|
573
|
+
const bootstrap = async ({ strapi }) => {
|
|
574
|
+
const actions = [
|
|
575
|
+
{
|
|
576
|
+
section: "plugins",
|
|
577
|
+
displayName: "Access the Certificates Registry",
|
|
578
|
+
uid: "read",
|
|
579
|
+
pluginName: PLUGIN_ID
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
section: "plugins",
|
|
583
|
+
displayName: "Issue, Approve, and Revoke Certificates",
|
|
584
|
+
uid: "issue",
|
|
585
|
+
pluginName: PLUGIN_ID
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
section: "plugins",
|
|
589
|
+
displayName: "Manage Certificate Templates & Signing Keys",
|
|
590
|
+
uid: "settings",
|
|
591
|
+
pluginName: PLUGIN_ID
|
|
592
|
+
}
|
|
593
|
+
];
|
|
594
|
+
await strapi.admin?.services?.permission?.actionProvider?.registerMany(
|
|
595
|
+
actions
|
|
596
|
+
);
|
|
597
|
+
const settingsService = strapi.plugin(PLUGIN_ID).service("settings");
|
|
598
|
+
const keyPair = await settingsService.getSigningKeyPair();
|
|
599
|
+
const issuanceService = strapi.plugin(PLUGIN_ID).service("issuance");
|
|
600
|
+
const seeded = await issuanceService.seedDefaultTemplates();
|
|
601
|
+
strapi.log.info(
|
|
602
|
+
`[${PLUGIN_ID}] Bootstrapped with Ed25519 key fingerprint ${keyPair.fingerprint} (seeded ${seeded} default template(s))`
|
|
603
|
+
);
|
|
604
|
+
};
|
|
605
|
+
const kind$1 = "collectionType";
|
|
606
|
+
const collectionName$1 = "certificates_records";
|
|
607
|
+
const info$1 = { "singularName": "certificate", "pluralName": "certificates", "displayName": "Issued Certificate", "description": "Cryptographically signed official certificate record with signatory and witness attestation" };
|
|
608
|
+
const options$1 = { "draftAndPublish": false };
|
|
609
|
+
const pluginOptions$1 = { "content-manager": { "visible": true }, "content-type-builder": { "visible": true } };
|
|
610
|
+
const attributes$1 = { "serialNumber": { "type": "string", "required": true, "unique": true, "maxLength": 64 }, "templateDocumentId": { "type": "string", "required": true, "maxLength": 120 }, "templateSlug": { "type": "string", "required": true, "maxLength": 120 }, "templateName": { "type": "string", "required": true, "maxLength": 160 }, "status": { "type": "enumeration", "enum": ["pending_approval", "issued", "revoked", "expired"], "default": "pending_approval", "required": true }, "recipientFullName": { "type": "string", "required": true, "maxLength": 200 }, "recipientChosenName": { "type": "string", "maxLength": 200 }, "recipientEmail": { "type": "email" }, "recipientPhone": { "type": "string", "maxLength": 64 }, "recipientNationality": { "type": "string", "maxLength": 120 }, "nationalIdMasked": { "type": "string", "maxLength": 32 }, "nationalIdHash": { "type": "string", "private": true, "maxLength": 128 }, "issuedAt": { "type": "string", "required": true, "maxLength": 40 }, "expiresAt": { "type": "string", "maxLength": 40 }, "revokedAt": { "type": "string", "maxLength": 40 }, "revocationReason": { "type": "text" }, "signatories": { "type": "json", "default": [] }, "witnesses": { "type": "json", "default": [] }, "customData": { "type": "json", "default": {} }, "secondaryDates": { "type": "json", "default": {} }, "canonicalPayloadHash": { "type": "string", "required": true, "maxLength": 128 }, "cryptographicSignature": { "type": "text", "required": true }, "publicKeyFingerprint": { "type": "string", "required": true, "maxLength": 96 }, "verificationToken": { "type": "string", "private": true, "maxLength": 128 }, "esignProvider": { "type": "enumeration", "enum": ["none", "docusign", "webhook"], "default": "none" }, "esignEnvelopeId": { "type": "string", "maxLength": 128 }, "esignEnvelopeStatus": { "type": "enumeration", "enum": ["not_sent", "sent", "delivered", "completed", "declined", "voided"], "default": "not_sent" }, "esignCompletedAt": { "type": "string", "maxLength": 40 }, "linkedBookingCode": { "type": "string", "maxLength": 64 }, "notes": { "type": "text" } };
|
|
611
|
+
const certificateSchema = {
|
|
612
|
+
kind: kind$1,
|
|
613
|
+
collectionName: collectionName$1,
|
|
614
|
+
info: info$1,
|
|
615
|
+
options: options$1,
|
|
616
|
+
pluginOptions: pluginOptions$1,
|
|
617
|
+
attributes: attributes$1
|
|
618
|
+
};
|
|
619
|
+
const kind = "collectionType";
|
|
620
|
+
const collectionName = "certificates_templates";
|
|
621
|
+
const info = { "singularName": "template", "pluralName": "templates", "displayName": "Certificate Template", "description": "Reusable official certificate template with signatory rules, witness requirements, and body statement" };
|
|
622
|
+
const options = { "draftAndPublish": false };
|
|
623
|
+
const pluginOptions = { "content-manager": { "visible": true }, "content-type-builder": { "visible": true } };
|
|
624
|
+
const attributes = { "name": { "type": "string", "required": true, "maxLength": 160 }, "slug": { "type": "uid", "targetField": "name", "required": true }, "description": { "type": "text" }, "serialPrefix": { "type": "string", "required": true, "default": "CERT", "maxLength": 8 }, "defaultValidityDays": { "type": "integer", "min": 0 }, "requiresWitnesses": { "type": "boolean", "default": false }, "minWitnesses": { "type": "integer", "default": 0, "min": 0, "max": 10 }, "requiresApproval": { "type": "boolean", "default": true }, "esignEnabled": { "type": "boolean", "default": false }, "esignProvider": { "type": "enumeration", "enum": ["none", "docusign", "webhook"], "default": "none" }, "organizationName": { "type": "string", "required": true, "maxLength": 200 }, "organizationSubheader": { "type": "string", "maxLength": 240 }, "headerText": { "type": "string", "maxLength": 240 }, "titleText": { "type": "string", "required": true, "maxLength": 200 }, "bodyTemplate": { "type": "text", "required": true }, "footerText": { "type": "text" }, "defaultSignatories": { "type": "json", "default": [] }, "customFieldsSchema": { "type": "json", "default": [] }, "secondaryCalendars": { "type": "json", "default": [] }, "accentColor": { "type": "string", "default": "#14532d", "maxLength": 16 }, "active": { "type": "boolean", "default": true } };
|
|
625
|
+
const templateSchema = {
|
|
626
|
+
kind,
|
|
627
|
+
collectionName,
|
|
628
|
+
info,
|
|
629
|
+
options,
|
|
630
|
+
pluginOptions,
|
|
631
|
+
attributes
|
|
632
|
+
};
|
|
633
|
+
const contentTypes = {
|
|
634
|
+
template: { schema: templateSchema },
|
|
635
|
+
certificate: { schema: certificateSchema }
|
|
636
|
+
};
|
|
637
|
+
const admin$1 = ({ strapi }) => {
|
|
638
|
+
const getSettings = () => strapi.plugin(PLUGIN_ID).service("settings");
|
|
639
|
+
const getIssuance = () => strapi.plugin(PLUGIN_ID).service("issuance");
|
|
640
|
+
const getDocuSign = () => strapi.plugin(PLUGIN_ID).service("docusign");
|
|
641
|
+
return {
|
|
642
|
+
async getSettings(ctx) {
|
|
643
|
+
const data = await getSettings().getSettings();
|
|
644
|
+
ctx.body = { data };
|
|
645
|
+
},
|
|
646
|
+
async updateSettings(ctx) {
|
|
647
|
+
const payload = ctx.request.body || {};
|
|
648
|
+
const data = await getSettings().updateSettings(payload);
|
|
649
|
+
ctx.body = { data };
|
|
650
|
+
},
|
|
651
|
+
async rotateKeys(ctx) {
|
|
652
|
+
const data = await getSettings().rotateKeyPair();
|
|
653
|
+
ctx.body = { data };
|
|
654
|
+
},
|
|
655
|
+
async listTemplates(ctx) {
|
|
656
|
+
const data = await getIssuance().listTemplates(false);
|
|
657
|
+
ctx.body = { data };
|
|
658
|
+
},
|
|
659
|
+
async createTemplate(ctx) {
|
|
660
|
+
try {
|
|
661
|
+
const payload = ctx.request.body || {};
|
|
662
|
+
const data = await getIssuance().createTemplate(payload);
|
|
663
|
+
ctx.body = { data };
|
|
664
|
+
} catch (err) {
|
|
665
|
+
return ctx.badRequest(
|
|
666
|
+
err.message || "Failed to create template"
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
},
|
|
670
|
+
async seedTemplates(ctx) {
|
|
671
|
+
const seededCount = await getIssuance().seedDefaultTemplates();
|
|
672
|
+
const templates = await getIssuance().listTemplates(false);
|
|
673
|
+
ctx.body = { data: { seededCount, templates } };
|
|
674
|
+
},
|
|
675
|
+
async listCertificates(ctx) {
|
|
676
|
+
const { status, templateSlug, search } = ctx.query;
|
|
677
|
+
const result = await getIssuance().listCertificates({
|
|
678
|
+
status,
|
|
679
|
+
templateSlug,
|
|
680
|
+
search
|
|
681
|
+
});
|
|
682
|
+
ctx.body = { data: result.records, meta: { stats: result.stats } };
|
|
683
|
+
},
|
|
684
|
+
async issueCertificate(ctx) {
|
|
685
|
+
try {
|
|
686
|
+
const body = ctx.request.body || {};
|
|
687
|
+
const autoApprove = body.sendViaESign === true ? false : body.autoApprove !== false;
|
|
688
|
+
let data = await getIssuance().issueCertificate({
|
|
689
|
+
...body,
|
|
690
|
+
autoApprove
|
|
691
|
+
});
|
|
692
|
+
if (body.sendViaESign === true) {
|
|
693
|
+
const sent = await getDocuSign().sendEnvelopeForCertificate(
|
|
694
|
+
data.documentId
|
|
695
|
+
);
|
|
696
|
+
data = sent.certificate;
|
|
697
|
+
}
|
|
698
|
+
ctx.body = { data };
|
|
699
|
+
} catch (err) {
|
|
700
|
+
return ctx.badRequest(
|
|
701
|
+
err.message || "Failed to issue certificate"
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
},
|
|
705
|
+
async sendDocuSignEnvelope(ctx) {
|
|
706
|
+
const documentId = ctx.params.documentId;
|
|
707
|
+
if (!documentId) {
|
|
708
|
+
return ctx.badRequest("documentId is required");
|
|
709
|
+
}
|
|
710
|
+
try {
|
|
711
|
+
const result = await getDocuSign().sendEnvelopeForCertificate(documentId);
|
|
712
|
+
ctx.body = { data: result };
|
|
713
|
+
} catch (err) {
|
|
714
|
+
return ctx.badRequest(
|
|
715
|
+
err.message || "Failed to dispatch DocuSign envelope"
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
},
|
|
719
|
+
async approveCertificate(ctx) {
|
|
720
|
+
const documentId = ctx.params.documentId;
|
|
721
|
+
if (!documentId) {
|
|
722
|
+
return ctx.badRequest("documentId is required");
|
|
723
|
+
}
|
|
724
|
+
try {
|
|
725
|
+
const body = ctx.request.body || {};
|
|
726
|
+
const data = await getIssuance().approveCertificate(documentId, body);
|
|
727
|
+
ctx.body = { data };
|
|
728
|
+
} catch (err) {
|
|
729
|
+
return ctx.badRequest(
|
|
730
|
+
err.message || "Failed to approve certificate"
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
},
|
|
734
|
+
async revokeCertificate(ctx) {
|
|
735
|
+
const documentId = ctx.params.documentId;
|
|
736
|
+
if (!documentId) {
|
|
737
|
+
return ctx.badRequest("documentId is required");
|
|
738
|
+
}
|
|
739
|
+
try {
|
|
740
|
+
const reason = String(
|
|
741
|
+
(ctx.request.body || {}).reason || "Revoked by administrator"
|
|
742
|
+
);
|
|
743
|
+
const data = await getIssuance().revokeCertificate(documentId, reason);
|
|
744
|
+
ctx.body = { data };
|
|
745
|
+
} catch (err) {
|
|
746
|
+
return ctx.badRequest(
|
|
747
|
+
err.message || "Failed to revoke certificate"
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
},
|
|
751
|
+
async printCertificate(ctx) {
|
|
752
|
+
const documentId = ctx.params.documentId;
|
|
753
|
+
if (!documentId) {
|
|
754
|
+
return ctx.badRequest("documentId is required");
|
|
755
|
+
}
|
|
756
|
+
const rendered = await getIssuance().renderCertificateDocument(
|
|
757
|
+
{ documentId },
|
|
758
|
+
{ isAdmin: true }
|
|
759
|
+
);
|
|
760
|
+
if (!rendered) {
|
|
761
|
+
return ctx.notFound("Certificate not found");
|
|
762
|
+
}
|
|
763
|
+
ctx.set("Content-Type", "text/html; charset=utf-8");
|
|
764
|
+
ctx.set(
|
|
765
|
+
"Content-Security-Policy",
|
|
766
|
+
"default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:;"
|
|
767
|
+
);
|
|
768
|
+
ctx.set("X-Content-Type-Options", "nosniff");
|
|
769
|
+
ctx.set("X-Frame-Options", "SAMEORIGIN");
|
|
770
|
+
ctx.set("Cache-Control", "no-store");
|
|
771
|
+
ctx.body = rendered.html;
|
|
772
|
+
},
|
|
773
|
+
async exportCsv(ctx) {
|
|
774
|
+
const { status, templateSlug } = ctx.query;
|
|
775
|
+
const csv = await getIssuance().exportCsv({ status, templateSlug });
|
|
776
|
+
ctx.set("Content-Type", "text/csv; charset=utf-8");
|
|
777
|
+
ctx.set(
|
|
778
|
+
"Content-Disposition",
|
|
779
|
+
'attachment; filename="certificates-registry.csv"'
|
|
780
|
+
);
|
|
781
|
+
ctx.set("X-Content-Type-Options", "nosniff");
|
|
782
|
+
ctx.set("Cache-Control", "no-store");
|
|
783
|
+
ctx.body = csv;
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
};
|
|
787
|
+
const rateLimitStore = /* @__PURE__ */ new Map();
|
|
788
|
+
function checkRateLimit(key, maxRequests = 20, windowMs = 6e4) {
|
|
789
|
+
const now = Date.now();
|
|
790
|
+
const existing = rateLimitStore.get(key) || [];
|
|
791
|
+
const recent = existing.filter((ts) => now - ts < windowMs);
|
|
792
|
+
if (recent.length >= maxRequests) {
|
|
793
|
+
rateLimitStore.set(key, recent);
|
|
794
|
+
return false;
|
|
795
|
+
}
|
|
796
|
+
recent.push(now);
|
|
797
|
+
rateLimitStore.set(key, recent);
|
|
798
|
+
return true;
|
|
799
|
+
}
|
|
800
|
+
function normalizeSignatories(raw) {
|
|
801
|
+
if (!Array.isArray(raw)) return [];
|
|
802
|
+
return raw.filter(
|
|
803
|
+
(item) => Boolean(item && typeof item === "object")
|
|
804
|
+
).map((item) => ({
|
|
805
|
+
name: String(item.name || "").trim(),
|
|
806
|
+
title: String(item.title || "").trim(),
|
|
807
|
+
role: item.role ? String(item.role).trim() : void 0,
|
|
808
|
+
email: item.email ? String(item.email).trim() : void 0,
|
|
809
|
+
routingOrder: typeof item.routingOrder === "number" && Number.isFinite(item.routingOrder) ? Math.max(1, Math.trunc(item.routingOrder)) : void 0,
|
|
810
|
+
signatureImageUrl: item.signatureImageUrl ? String(item.signatureImageUrl).trim() : void 0,
|
|
811
|
+
signedAt: item.signedAt ? String(item.signedAt).trim() : void 0
|
|
812
|
+
})).filter((s) => Boolean(s.name && s.title));
|
|
813
|
+
}
|
|
814
|
+
function normalizeWitnesses(raw) {
|
|
815
|
+
if (!Array.isArray(raw)) return [];
|
|
816
|
+
return raw.filter(
|
|
817
|
+
(item) => Boolean(item && typeof item === "object")
|
|
818
|
+
).map((item) => {
|
|
819
|
+
const rawDocId = item.documentIdRaw ? String(item.documentIdRaw) : void 0;
|
|
820
|
+
const maskedDocId = item.documentIdMasked ? String(item.documentIdMasked).trim() : rawDocId ? maskNationalId(rawDocId) : void 0;
|
|
821
|
+
return {
|
|
822
|
+
fullName: String(item.fullName || "").trim(),
|
|
823
|
+
documentIdMasked: maskedDocId,
|
|
824
|
+
role: item.role ? String(item.role).trim() : void 0,
|
|
825
|
+
email: item.email ? String(item.email).trim() : void 0,
|
|
826
|
+
routingOrder: typeof item.routingOrder === "number" && Number.isFinite(item.routingOrder) ? Math.max(1, Math.trunc(item.routingOrder)) : void 0,
|
|
827
|
+
signedAt: item.signedAt ? String(item.signedAt).trim() : void 0
|
|
828
|
+
};
|
|
829
|
+
}).filter((w) => Boolean(w.fullName));
|
|
830
|
+
}
|
|
831
|
+
function asStr(val, fallback = "") {
|
|
832
|
+
return val ? String(val) : fallback;
|
|
833
|
+
}
|
|
834
|
+
function asOptStr(val) {
|
|
835
|
+
return val ? String(val) : void 0;
|
|
836
|
+
}
|
|
837
|
+
function asNullStr(val) {
|
|
838
|
+
return val ? String(val) : null;
|
|
839
|
+
}
|
|
840
|
+
function asArr(val, fallback = []) {
|
|
841
|
+
return Array.isArray(val) ? val : fallback;
|
|
842
|
+
}
|
|
843
|
+
function asObj(val) {
|
|
844
|
+
return val && typeof val === "object" ? val : {};
|
|
845
|
+
}
|
|
846
|
+
function asEsignProvider(val) {
|
|
847
|
+
const raw = asStr(val, "none");
|
|
848
|
+
return raw === "docusign" || raw === "webhook" ? raw : "none";
|
|
849
|
+
}
|
|
850
|
+
function asNonNegativeInt(val, fallback = 0) {
|
|
851
|
+
return typeof val === "number" && Number.isFinite(val) ? Math.max(0, Math.trunc(val)) : fallback;
|
|
852
|
+
}
|
|
853
|
+
function normalizeTemplateRecord(doc) {
|
|
854
|
+
return {
|
|
855
|
+
id: doc.id,
|
|
856
|
+
documentId: asStr(doc.documentId, asStr(doc.id)),
|
|
857
|
+
name: asStr(doc.name),
|
|
858
|
+
slug: asStr(doc.slug),
|
|
859
|
+
description: asOptStr(doc.description),
|
|
860
|
+
serialPrefix: asStr(doc.serialPrefix, "CERT"),
|
|
861
|
+
defaultValidityDays: typeof doc.defaultValidityDays === "number" ? doc.defaultValidityDays : null,
|
|
862
|
+
requiresWitnesses: Boolean(doc.requiresWitnesses),
|
|
863
|
+
minWitnesses: asNonNegativeInt(doc.minWitnesses, 0),
|
|
864
|
+
requiresApproval: doc.requiresApproval !== false,
|
|
865
|
+
esignEnabled: Boolean(doc.esignEnabled),
|
|
866
|
+
esignProvider: asEsignProvider(doc.esignProvider),
|
|
867
|
+
organizationName: asStr(doc.organizationName),
|
|
868
|
+
organizationSubheader: asOptStr(doc.organizationSubheader),
|
|
869
|
+
headerText: asOptStr(doc.headerText),
|
|
870
|
+
titleText: asStr(doc.titleText),
|
|
871
|
+
bodyTemplate: asStr(doc.bodyTemplate),
|
|
872
|
+
footerText: asOptStr(doc.footerText),
|
|
873
|
+
defaultSignatories: normalizeSignatories(doc.defaultSignatories),
|
|
874
|
+
customFieldsSchema: asArr(doc.customFieldsSchema),
|
|
875
|
+
secondaryCalendars: asArr(doc.secondaryCalendars),
|
|
876
|
+
accentColor: asStr(doc.accentColor, "#14532d"),
|
|
877
|
+
active: doc.active !== false
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
function normalizeCertificateRecord(doc, includePrivate = false) {
|
|
881
|
+
const record = {
|
|
882
|
+
id: doc.id,
|
|
883
|
+
documentId: asStr(doc.documentId, asStr(doc.id)),
|
|
884
|
+
serialNumber: asStr(doc.serialNumber),
|
|
885
|
+
templateDocumentId: asStr(doc.templateDocumentId),
|
|
886
|
+
templateSlug: asStr(doc.templateSlug),
|
|
887
|
+
templateName: asStr(doc.templateName),
|
|
888
|
+
status: asStr(doc.status, "pending_approval"),
|
|
889
|
+
recipientFullName: asStr(doc.recipientFullName),
|
|
890
|
+
recipientChosenName: asOptStr(doc.recipientChosenName),
|
|
891
|
+
recipientEmail: asOptStr(doc.recipientEmail),
|
|
892
|
+
recipientPhone: asOptStr(doc.recipientPhone),
|
|
893
|
+
recipientNationality: asOptStr(doc.recipientNationality),
|
|
894
|
+
nationalIdMasked: asOptStr(doc.nationalIdMasked),
|
|
895
|
+
issuedAt: asStr(doc.issuedAt),
|
|
896
|
+
expiresAt: asNullStr(doc.expiresAt),
|
|
897
|
+
revokedAt: asNullStr(doc.revokedAt),
|
|
898
|
+
revocationReason: asNullStr(doc.revocationReason),
|
|
899
|
+
signatories: normalizeSignatories(doc.signatories),
|
|
900
|
+
witnesses: normalizeWitnesses(doc.witnesses),
|
|
901
|
+
customData: asObj(doc.customData),
|
|
902
|
+
secondaryDates: asObj(
|
|
903
|
+
doc.secondaryDates
|
|
904
|
+
),
|
|
905
|
+
canonicalPayloadHash: asStr(doc.canonicalPayloadHash),
|
|
906
|
+
cryptographicSignature: asStr(doc.cryptographicSignature),
|
|
907
|
+
publicKeyFingerprint: asStr(doc.publicKeyFingerprint),
|
|
908
|
+
esignProvider: asEsignProvider(doc.esignProvider),
|
|
909
|
+
esignEnvelopeId: asOptStr(doc.esignEnvelopeId),
|
|
910
|
+
esignEnvelopeStatus: asStr(
|
|
911
|
+
doc.esignEnvelopeStatus,
|
|
912
|
+
"not_sent"
|
|
913
|
+
),
|
|
914
|
+
esignCompletedAt: asNullStr(doc.esignCompletedAt),
|
|
915
|
+
linkedBookingCode: asOptStr(doc.linkedBookingCode),
|
|
916
|
+
notes: asOptStr(doc.notes)
|
|
917
|
+
};
|
|
918
|
+
if (includePrivate) {
|
|
919
|
+
record.nationalIdHash = asOptStr(doc.nationalIdHash);
|
|
920
|
+
record.verificationToken = asOptStr(doc.verificationToken);
|
|
921
|
+
}
|
|
922
|
+
return record;
|
|
923
|
+
}
|
|
924
|
+
function buildCanonicalPayload(params) {
|
|
925
|
+
return {
|
|
926
|
+
version: "1.0",
|
|
927
|
+
serialNumber: params.serialNumber,
|
|
928
|
+
templateSlug: params.templateSlug,
|
|
929
|
+
organizationName: params.organizationName,
|
|
930
|
+
recipientFullName: params.recipientFullName,
|
|
931
|
+
recipientChosenName: params.recipientChosenName || "",
|
|
932
|
+
nationalIdHash: params.nationalIdHash || "",
|
|
933
|
+
issuedAt: params.issuedAt,
|
|
934
|
+
expiresAt: params.expiresAt || "",
|
|
935
|
+
signatories: (params.signatories || []).map((s) => ({
|
|
936
|
+
name: s.name,
|
|
937
|
+
title: s.title
|
|
938
|
+
})),
|
|
939
|
+
witnesses: (params.witnesses || []).map((w) => ({
|
|
940
|
+
fullName: w.fullName,
|
|
941
|
+
role: w.role || "Witness"
|
|
942
|
+
}))
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
function evaluateCertificateVerification(params) {
|
|
946
|
+
const { cert, template, settings: settings2, options: options2 } = params;
|
|
947
|
+
const organizationName = template?.organizationName || settings2.organizationName;
|
|
948
|
+
const canonicalPayload = buildCanonicalPayload({
|
|
949
|
+
serialNumber: cert.serialNumber,
|
|
950
|
+
templateSlug: cert.templateSlug,
|
|
951
|
+
organizationName,
|
|
952
|
+
recipientFullName: cert.recipientFullName,
|
|
953
|
+
recipientChosenName: cert.recipientChosenName,
|
|
954
|
+
nationalIdHash: cert.nationalIdHash,
|
|
955
|
+
issuedAt: cert.issuedAt,
|
|
956
|
+
expiresAt: cert.expiresAt,
|
|
957
|
+
signatories: cert.signatories,
|
|
958
|
+
witnesses: cert.witnesses
|
|
959
|
+
});
|
|
960
|
+
const cryptographicallyVerified = verifyCanonicalPayloadSignature(
|
|
961
|
+
canonicalPayload,
|
|
962
|
+
cert.cryptographicSignature,
|
|
963
|
+
settings2.publicKeyPem
|
|
964
|
+
);
|
|
965
|
+
let effectiveStatus = cert.status;
|
|
966
|
+
if (effectiveStatus === "issued" && cert.expiresAt && new Date(cert.expiresAt).getTime() < Date.now()) {
|
|
967
|
+
effectiveStatus = "expired";
|
|
968
|
+
}
|
|
969
|
+
const isTokenValid = verifyTokenConstantTime(
|
|
970
|
+
options2?.token,
|
|
971
|
+
cert.verificationToken
|
|
972
|
+
);
|
|
973
|
+
const isUnmasked = isTokenValid || !settings2.maskPublicRecipientName;
|
|
974
|
+
const nationalIdMatches = options2?.nationalId ? verifyNationalIdHash(
|
|
975
|
+
options2.nationalId,
|
|
976
|
+
cert.serialNumber,
|
|
977
|
+
cert.nationalIdHash
|
|
978
|
+
) : void 0;
|
|
979
|
+
return {
|
|
980
|
+
valid: effectiveStatus === "issued" && cryptographicallyVerified,
|
|
981
|
+
cryptographicallyVerified,
|
|
982
|
+
status: effectiveStatus,
|
|
983
|
+
serialNumber: cert.serialNumber,
|
|
984
|
+
templateSlug: cert.templateSlug,
|
|
985
|
+
templateName: cert.templateName,
|
|
986
|
+
organizationName,
|
|
987
|
+
recipientNameDisplay: isUnmasked ? cert.recipientFullName : maskRecipientName(cert.recipientFullName),
|
|
988
|
+
recipientChosenName: cert.recipientChosenName,
|
|
989
|
+
nationalIdMasked: cert.nationalIdMasked,
|
|
990
|
+
nationalIdMatches,
|
|
991
|
+
issuedAt: cert.issuedAt,
|
|
992
|
+
expiresAt: cert.expiresAt,
|
|
993
|
+
revokedAt: cert.revokedAt,
|
|
994
|
+
revocationReason: cert.revocationReason,
|
|
995
|
+
secondaryDates: cert.secondaryDates,
|
|
996
|
+
signatories: cert.signatories,
|
|
997
|
+
witnesses: cert.witnesses.map((w) => ({
|
|
998
|
+
fullName: isUnmasked ? w.fullName : maskRecipientName(w.fullName),
|
|
999
|
+
role: w.role
|
|
1000
|
+
})),
|
|
1001
|
+
canonicalPayloadHash: cert.canonicalPayloadHash,
|
|
1002
|
+
cryptographicSignature: cert.cryptographicSignature,
|
|
1003
|
+
publicKeyFingerprint: cert.publicKeyFingerprint,
|
|
1004
|
+
publicKeyPem: settings2.publicKeyPem,
|
|
1005
|
+
esignProvider: cert.esignProvider,
|
|
1006
|
+
esignEnvelopeId: cert.esignEnvelopeId,
|
|
1007
|
+
esignEnvelopeStatus: cert.esignEnvelopeStatus,
|
|
1008
|
+
esignCompletedAt: cert.esignCompletedAt,
|
|
1009
|
+
isUnmasked,
|
|
1010
|
+
verifiedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
function renderPrintableFromRecord(params) {
|
|
1014
|
+
const { cert, template, settings: settings2, options: options2 } = params;
|
|
1015
|
+
if (!options2?.isAdmin) {
|
|
1016
|
+
const tokenOk = verifyTokenConstantTime(
|
|
1017
|
+
options2?.token,
|
|
1018
|
+
cert.verificationToken
|
|
1019
|
+
);
|
|
1020
|
+
if (!tokenOk) {
|
|
1021
|
+
throw new Error("Valid verificationToken required to print certificate");
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
const effectiveTemplate = template || {
|
|
1025
|
+
documentId: "fallback",
|
|
1026
|
+
name: cert.templateName,
|
|
1027
|
+
slug: cert.templateSlug,
|
|
1028
|
+
serialPrefix: "CERT",
|
|
1029
|
+
requiresWitnesses: false,
|
|
1030
|
+
minWitnesses: 0,
|
|
1031
|
+
requiresApproval: false,
|
|
1032
|
+
organizationName: settings2.organizationName,
|
|
1033
|
+
organizationSubheader: settings2.organizationSubheader,
|
|
1034
|
+
titleText: cert.templateName,
|
|
1035
|
+
bodyTemplate: "This is to certify that {{recipientFullName}} has been officially attested by {{organizationName}} on {{issuedDateFormatted}}.",
|
|
1036
|
+
defaultSignatories: cert.signatories,
|
|
1037
|
+
active: true
|
|
1038
|
+
};
|
|
1039
|
+
const verificationUrl = `${settings2.verificationBaseUrl}/${encodeURIComponent(
|
|
1040
|
+
cert.serialNumber
|
|
1041
|
+
)}`;
|
|
1042
|
+
const html = renderPrintableCertificateHtml({
|
|
1043
|
+
certificate: cert,
|
|
1044
|
+
template: effectiveTemplate,
|
|
1045
|
+
verificationUrl
|
|
1046
|
+
});
|
|
1047
|
+
return { html, serialNumber: cert.serialNumber };
|
|
1048
|
+
}
|
|
1049
|
+
function filterAndComputeCertificateStats(allRecords, filters) {
|
|
1050
|
+
const stats = {
|
|
1051
|
+
total: allRecords.length,
|
|
1052
|
+
issued: allRecords.filter((r) => r.status === "issued").length,
|
|
1053
|
+
pending_approval: allRecords.filter((r) => r.status === "pending_approval").length,
|
|
1054
|
+
revoked: allRecords.filter((r) => r.status === "revoked").length,
|
|
1055
|
+
expired: allRecords.filter((r) => r.status === "expired").length
|
|
1056
|
+
};
|
|
1057
|
+
const records = allRecords.filter((r) => {
|
|
1058
|
+
if (filters?.status && r.status !== filters.status) return false;
|
|
1059
|
+
if (filters?.templateSlug && r.templateSlug !== filters.templateSlug) {
|
|
1060
|
+
return false;
|
|
1061
|
+
}
|
|
1062
|
+
if (filters?.search) {
|
|
1063
|
+
const q = filters.search.toLowerCase();
|
|
1064
|
+
const hay = [
|
|
1065
|
+
r.serialNumber,
|
|
1066
|
+
r.recipientFullName,
|
|
1067
|
+
r.recipientChosenName || "",
|
|
1068
|
+
r.recipientEmail || ""
|
|
1069
|
+
].join(" ").toLowerCase();
|
|
1070
|
+
if (!hay.includes(q)) return false;
|
|
1071
|
+
}
|
|
1072
|
+
return true;
|
|
1073
|
+
});
|
|
1074
|
+
return { records, stats };
|
|
1075
|
+
}
|
|
1076
|
+
const contentApi$1 = ({ strapi }) => {
|
|
1077
|
+
const getSettings = () => strapi.plugin(PLUGIN_ID).service("settings");
|
|
1078
|
+
const getIssuance = () => strapi.plugin(PLUGIN_ID).service("issuance");
|
|
1079
|
+
const getDocuSign = () => strapi.plugin(PLUGIN_ID).service("docusign");
|
|
1080
|
+
return {
|
|
1081
|
+
async listPublicTemplates(ctx) {
|
|
1082
|
+
const templates = await getIssuance().listTemplates(true);
|
|
1083
|
+
const sanitized = templates.map((t) => ({
|
|
1084
|
+
documentId: t.documentId,
|
|
1085
|
+
name: t.name,
|
|
1086
|
+
slug: t.slug,
|
|
1087
|
+
description: t.description,
|
|
1088
|
+
requiresWitnesses: t.requiresWitnesses,
|
|
1089
|
+
minWitnesses: t.minWitnesses,
|
|
1090
|
+
organizationName: t.organizationName,
|
|
1091
|
+
titleText: t.titleText,
|
|
1092
|
+
customFieldsSchema: t.customFieldsSchema || []
|
|
1093
|
+
}));
|
|
1094
|
+
ctx.body = { data: sanitized };
|
|
1095
|
+
},
|
|
1096
|
+
async getPublicKey(ctx) {
|
|
1097
|
+
const settings2 = await getSettings().getSettings();
|
|
1098
|
+
ctx.body = {
|
|
1099
|
+
data: {
|
|
1100
|
+
organizationName: settings2.organizationName,
|
|
1101
|
+
algorithm: "Ed25519",
|
|
1102
|
+
publicKeyFingerprint: settings2.publicKeyFingerprint,
|
|
1103
|
+
publicKeyPem: settings2.publicKeyPem
|
|
1104
|
+
}
|
|
1105
|
+
};
|
|
1106
|
+
},
|
|
1107
|
+
async applyCertificate(ctx) {
|
|
1108
|
+
const ipKey = `apply:${ctx.ip || "unknown"}`;
|
|
1109
|
+
if (!checkRateLimit(ipKey, 10, 6e4)) {
|
|
1110
|
+
ctx.status = 429;
|
|
1111
|
+
ctx.body = {
|
|
1112
|
+
error: { message: "Too many certificate requests. Please wait." }
|
|
1113
|
+
};
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
const settings2 = await getSettings().getSettings();
|
|
1117
|
+
if (!settings2.allowPublicApplications) {
|
|
1118
|
+
return ctx.forbidden("Public certificate applications are disabled");
|
|
1119
|
+
}
|
|
1120
|
+
try {
|
|
1121
|
+
const body = ctx.request.body || {};
|
|
1122
|
+
const created = await getIssuance().issueCertificate({
|
|
1123
|
+
...body,
|
|
1124
|
+
autoApprove: false
|
|
1125
|
+
});
|
|
1126
|
+
ctx.body = {
|
|
1127
|
+
data: {
|
|
1128
|
+
serialNumber: created.serialNumber,
|
|
1129
|
+
status: created.status,
|
|
1130
|
+
templateSlug: created.templateSlug,
|
|
1131
|
+
templateName: created.templateName,
|
|
1132
|
+
recipientFullName: created.recipientFullName,
|
|
1133
|
+
recipientChosenName: created.recipientChosenName,
|
|
1134
|
+
issuedAt: created.issuedAt,
|
|
1135
|
+
verificationToken: created.verificationToken
|
|
1136
|
+
}
|
|
1137
|
+
};
|
|
1138
|
+
} catch (err) {
|
|
1139
|
+
return ctx.badRequest(
|
|
1140
|
+
err.message || "Invalid certificate application"
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
},
|
|
1144
|
+
async verifyBySerial(ctx) {
|
|
1145
|
+
const ipKey = `verify:${ctx.ip || "unknown"}`;
|
|
1146
|
+
if (!checkRateLimit(ipKey, 30, 6e4)) {
|
|
1147
|
+
ctx.status = 429;
|
|
1148
|
+
ctx.body = {
|
|
1149
|
+
error: { message: "Rate limit exceeded for verification." }
|
|
1150
|
+
};
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
const serial = ctx.params.serial;
|
|
1154
|
+
if (!serial) {
|
|
1155
|
+
return ctx.badRequest("Certificate serial number is required");
|
|
1156
|
+
}
|
|
1157
|
+
const result = await getIssuance().verifyCertificateBySerial(serial, {
|
|
1158
|
+
token: ctx.query.token,
|
|
1159
|
+
nationalId: ctx.query.nationalId
|
|
1160
|
+
});
|
|
1161
|
+
if (!result) {
|
|
1162
|
+
return ctx.notFound(
|
|
1163
|
+
"Certificate serial number not found in official registry"
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
ctx.set("Cache-Control", "no-store");
|
|
1167
|
+
ctx.body = { data: result };
|
|
1168
|
+
},
|
|
1169
|
+
async downloadPrintableHtml(ctx) {
|
|
1170
|
+
const serial = ctx.params.serial;
|
|
1171
|
+
const token = ctx.query.token;
|
|
1172
|
+
if (!serial || !token) {
|
|
1173
|
+
return ctx.badRequest(
|
|
1174
|
+
"Both serial and verification token are required"
|
|
1175
|
+
);
|
|
1176
|
+
}
|
|
1177
|
+
try {
|
|
1178
|
+
const rendered = await getIssuance().renderCertificateDocument(
|
|
1179
|
+
{ serialNumber: serial },
|
|
1180
|
+
{ token, isAdmin: false }
|
|
1181
|
+
);
|
|
1182
|
+
if (!rendered) {
|
|
1183
|
+
return ctx.notFound("Certificate not found");
|
|
1184
|
+
}
|
|
1185
|
+
ctx.set("Content-Type", "text/html; charset=utf-8");
|
|
1186
|
+
ctx.set(
|
|
1187
|
+
"Content-Security-Policy",
|
|
1188
|
+
"default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:;"
|
|
1189
|
+
);
|
|
1190
|
+
ctx.set("X-Content-Type-Options", "nosniff");
|
|
1191
|
+
ctx.set("X-Frame-Options", "SAMEORIGIN");
|
|
1192
|
+
ctx.set("Cache-Control", "no-store");
|
|
1193
|
+
ctx.body = rendered.html;
|
|
1194
|
+
} catch (err) {
|
|
1195
|
+
return ctx.forbidden(
|
|
1196
|
+
err.message || "Invalid verification token"
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
},
|
|
1200
|
+
async docusignWebhook(ctx) {
|
|
1201
|
+
const ipKey = `webhook:${ctx.ip || "unknown"}`;
|
|
1202
|
+
if (!checkRateLimit(ipKey, 60, 6e4)) {
|
|
1203
|
+
ctx.status = 429;
|
|
1204
|
+
ctx.body = { error: { message: "Webhook rate limit exceeded." } };
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
const payload = ctx.request.body || {};
|
|
1208
|
+
const rawBody = typeof ctx.request.rawBody === "string" && ctx.request.rawBody ? ctx.request.rawBody : JSON.stringify(payload);
|
|
1209
|
+
const signatureHeader = ctx.headers?.["x-docusign-signature-1"] || ctx.headers?.["X-DocuSign-Signature-1"];
|
|
1210
|
+
try {
|
|
1211
|
+
const result = await getDocuSign().handleWebhookEvent({
|
|
1212
|
+
rawBody,
|
|
1213
|
+
signatureHeader,
|
|
1214
|
+
payload
|
|
1215
|
+
});
|
|
1216
|
+
ctx.body = {
|
|
1217
|
+
data: {
|
|
1218
|
+
envelopeId: result.envelopeId,
|
|
1219
|
+
envelopeStatus: result.envelopeStatus,
|
|
1220
|
+
serialNumber: result.certificate.serialNumber,
|
|
1221
|
+
status: result.certificate.status
|
|
1222
|
+
}
|
|
1223
|
+
};
|
|
1224
|
+
} catch (err) {
|
|
1225
|
+
return ctx.forbidden(
|
|
1226
|
+
err.message || "DocuSign webhook verification failed"
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
};
|
|
1231
|
+
};
|
|
1232
|
+
const controllers = {
|
|
1233
|
+
admin: admin$1,
|
|
1234
|
+
"content-api": contentApi$1
|
|
1235
|
+
};
|
|
1236
|
+
function mergeAttributesIntoTarget(target, attrName, attrDef) {
|
|
1237
|
+
if (!target) return;
|
|
1238
|
+
target[attrName] = {
|
|
1239
|
+
...target[attrName],
|
|
1240
|
+
...attrDef
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
function applyExtraAttributesToSchema(schema, extraAttributes) {
|
|
1244
|
+
for (const [attrName, attrDef] of Object.entries(extraAttributes)) {
|
|
1245
|
+
if (!attrName || !attrDef || typeof attrDef !== "object") continue;
|
|
1246
|
+
mergeAttributesIntoTarget(schema?.attributes, attrName, attrDef);
|
|
1247
|
+
mergeAttributesIntoTarget(
|
|
1248
|
+
schema?.__schema__?.attributes,
|
|
1249
|
+
attrName,
|
|
1250
|
+
attrDef
|
|
1251
|
+
);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
const register = ({ strapi }) => {
|
|
1255
|
+
const config = strapi.config?.get?.(`plugin::${PLUGIN_ID}`) ?? {};
|
|
1256
|
+
for (const [targetKey, extraAttributes] of Object.entries(
|
|
1257
|
+
config.schemaExtensions ?? {}
|
|
1258
|
+
)) {
|
|
1259
|
+
if (!extraAttributes || typeof extraAttributes !== "object") continue;
|
|
1260
|
+
const resolvedUid = targetKey.startsWith("plugin::") ? targetKey : `plugin::${PLUGIN_ID}.${targetKey}`;
|
|
1261
|
+
const schema = strapi.contentTypes?.[resolvedUid];
|
|
1262
|
+
applyExtraAttributesToSchema(schema, extraAttributes);
|
|
1263
|
+
}
|
|
1264
|
+
strapi.log.debug(`[${PLUGIN_ID}] Registering official certificates plugin`);
|
|
1265
|
+
};
|
|
1266
|
+
const admin = {
|
|
1267
|
+
type: "admin",
|
|
1268
|
+
routes: [
|
|
1269
|
+
{
|
|
1270
|
+
method: "GET",
|
|
1271
|
+
path: "/settings",
|
|
1272
|
+
handler: "admin.getSettings",
|
|
1273
|
+
config: { policies: [] }
|
|
1274
|
+
},
|
|
1275
|
+
{
|
|
1276
|
+
method: "PUT",
|
|
1277
|
+
path: "/settings",
|
|
1278
|
+
handler: "admin.updateSettings",
|
|
1279
|
+
config: { policies: [] }
|
|
1280
|
+
},
|
|
1281
|
+
{
|
|
1282
|
+
method: "POST",
|
|
1283
|
+
path: "/settings/rotate-keys",
|
|
1284
|
+
handler: "admin.rotateKeys",
|
|
1285
|
+
config: { policies: [] }
|
|
1286
|
+
},
|
|
1287
|
+
{
|
|
1288
|
+
method: "GET",
|
|
1289
|
+
path: "/templates",
|
|
1290
|
+
handler: "admin.listTemplates",
|
|
1291
|
+
config: { policies: [] }
|
|
1292
|
+
},
|
|
1293
|
+
{
|
|
1294
|
+
method: "POST",
|
|
1295
|
+
path: "/templates",
|
|
1296
|
+
handler: "admin.createTemplate",
|
|
1297
|
+
config: { policies: [] }
|
|
1298
|
+
},
|
|
1299
|
+
{
|
|
1300
|
+
method: "POST",
|
|
1301
|
+
path: "/templates/seed",
|
|
1302
|
+
handler: "admin.seedTemplates",
|
|
1303
|
+
config: { policies: [] }
|
|
1304
|
+
},
|
|
1305
|
+
{
|
|
1306
|
+
method: "GET",
|
|
1307
|
+
path: "/certificates",
|
|
1308
|
+
handler: "admin.listCertificates",
|
|
1309
|
+
config: { policies: [] }
|
|
1310
|
+
},
|
|
1311
|
+
{
|
|
1312
|
+
method: "POST",
|
|
1313
|
+
path: "/certificates/issue",
|
|
1314
|
+
handler: "admin.issueCertificate",
|
|
1315
|
+
config: { policies: [] }
|
|
1316
|
+
},
|
|
1317
|
+
{
|
|
1318
|
+
method: "POST",
|
|
1319
|
+
path: "/certificates/:documentId/send-docusign",
|
|
1320
|
+
handler: "admin.sendDocuSignEnvelope",
|
|
1321
|
+
config: { policies: [] }
|
|
1322
|
+
},
|
|
1323
|
+
{
|
|
1324
|
+
method: "PUT",
|
|
1325
|
+
path: "/certificates/:documentId/approve",
|
|
1326
|
+
handler: "admin.approveCertificate",
|
|
1327
|
+
config: { policies: [] }
|
|
1328
|
+
},
|
|
1329
|
+
{
|
|
1330
|
+
method: "PUT",
|
|
1331
|
+
path: "/certificates/:documentId/revoke",
|
|
1332
|
+
handler: "admin.revokeCertificate",
|
|
1333
|
+
config: { policies: [] }
|
|
1334
|
+
},
|
|
1335
|
+
{
|
|
1336
|
+
method: "GET",
|
|
1337
|
+
path: "/certificates/:documentId/print",
|
|
1338
|
+
handler: "admin.printCertificate",
|
|
1339
|
+
config: { policies: [] }
|
|
1340
|
+
},
|
|
1341
|
+
{
|
|
1342
|
+
method: "GET",
|
|
1343
|
+
path: "/certificates/export/csv",
|
|
1344
|
+
handler: "admin.exportCsv",
|
|
1345
|
+
config: { policies: [] }
|
|
1346
|
+
}
|
|
1347
|
+
]
|
|
1348
|
+
};
|
|
1349
|
+
const contentApi = {
|
|
1350
|
+
type: "content-api",
|
|
1351
|
+
routes: [
|
|
1352
|
+
{
|
|
1353
|
+
method: "GET",
|
|
1354
|
+
path: "/templates",
|
|
1355
|
+
handler: "content-api.listPublicTemplates",
|
|
1356
|
+
config: { auth: false }
|
|
1357
|
+
},
|
|
1358
|
+
{
|
|
1359
|
+
method: "GET",
|
|
1360
|
+
path: "/public-key",
|
|
1361
|
+
handler: "content-api.getPublicKey",
|
|
1362
|
+
config: { auth: false }
|
|
1363
|
+
},
|
|
1364
|
+
{
|
|
1365
|
+
method: "POST",
|
|
1366
|
+
path: "/apply",
|
|
1367
|
+
handler: "content-api.applyCertificate",
|
|
1368
|
+
config: { auth: false }
|
|
1369
|
+
},
|
|
1370
|
+
{
|
|
1371
|
+
method: "GET",
|
|
1372
|
+
path: "/verify/:serial",
|
|
1373
|
+
handler: "content-api.verifyBySerial",
|
|
1374
|
+
config: { auth: false }
|
|
1375
|
+
},
|
|
1376
|
+
{
|
|
1377
|
+
method: "GET",
|
|
1378
|
+
path: "/verify/:serial/print",
|
|
1379
|
+
handler: "content-api.downloadPrintableHtml",
|
|
1380
|
+
config: { auth: false }
|
|
1381
|
+
},
|
|
1382
|
+
{
|
|
1383
|
+
method: "POST",
|
|
1384
|
+
path: "/webhooks/docusign",
|
|
1385
|
+
handler: "content-api.docusignWebhook",
|
|
1386
|
+
config: { auth: false }
|
|
1387
|
+
}
|
|
1388
|
+
]
|
|
1389
|
+
};
|
|
1390
|
+
const routes = {
|
|
1391
|
+
admin,
|
|
1392
|
+
"content-api": contentApi
|
|
1393
|
+
};
|
|
1394
|
+
function stripTrailingSlashes(value) {
|
|
1395
|
+
let end = value.length;
|
|
1396
|
+
while (end > 0 && value.charCodeAt(end - 1) === 47) {
|
|
1397
|
+
end -= 1;
|
|
1398
|
+
}
|
|
1399
|
+
return end === value.length ? value : value.slice(0, end);
|
|
1400
|
+
}
|
|
1401
|
+
const CERTIFICATE_UID$1 = "plugin::certificates.certificate";
|
|
1402
|
+
const PRIVATE_HOST_PREFIXES = ["127.", "10.", "192.168.", "169.254."];
|
|
1403
|
+
function isPrivateOrLoopbackHost(host) {
|
|
1404
|
+
if (!host || host === "localhost") return true;
|
|
1405
|
+
if (PRIVATE_HOST_PREFIXES.some((prefix) => host.startsWith(prefix))) {
|
|
1406
|
+
return true;
|
|
1407
|
+
}
|
|
1408
|
+
return /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(host);
|
|
1409
|
+
}
|
|
1410
|
+
function validateDocuSignBaseUrl(rawUrl) {
|
|
1411
|
+
const cleaned = stripTrailingSlashes(String(rawUrl || "").trim());
|
|
1412
|
+
if (!cleaned.startsWith("https://")) {
|
|
1413
|
+
throw new Error("DocuSign baseUrl must use HTTPS");
|
|
1414
|
+
}
|
|
1415
|
+
const withoutScheme = cleaned.slice("https://".length);
|
|
1416
|
+
const host = withoutScheme.split("/")[0].split(":")[0].toLowerCase();
|
|
1417
|
+
if (isPrivateOrLoopbackHost(host)) {
|
|
1418
|
+
throw new Error("DocuSign baseUrl cannot target private or loopback hosts");
|
|
1419
|
+
}
|
|
1420
|
+
const isOfficialDocuSign = host.endsWith(".docusign.net") || host.endsWith(".docusign.com");
|
|
1421
|
+
if (!isOfficialDocuSign) {
|
|
1422
|
+
throw new Error(
|
|
1423
|
+
"DocuSign baseUrl must belong to *.docusign.net or *.docusign.com"
|
|
1424
|
+
);
|
|
1425
|
+
}
|
|
1426
|
+
return cleaned;
|
|
1427
|
+
}
|
|
1428
|
+
function buildDocuSignEnvelopePayload(params) {
|
|
1429
|
+
const { certificate, htmlDocument, emailSubjectPrefix } = params;
|
|
1430
|
+
const signers = [];
|
|
1431
|
+
let recipientIdSeq = 1;
|
|
1432
|
+
(certificate.witnesses || []).forEach((w, idx) => {
|
|
1433
|
+
if (!w.email) return;
|
|
1434
|
+
signers.push({
|
|
1435
|
+
email: w.email,
|
|
1436
|
+
name: w.fullName,
|
|
1437
|
+
recipientId: String(recipientIdSeq++),
|
|
1438
|
+
routingOrder: String(w.routingOrder ?? 1),
|
|
1439
|
+
roleName: w.role || `Witness ${idx + 1}`,
|
|
1440
|
+
tabs: {
|
|
1441
|
+
signHereTabs: [
|
|
1442
|
+
{
|
|
1443
|
+
anchorString: `/wit_${idx + 1}/`,
|
|
1444
|
+
anchorUnits: "pixels",
|
|
1445
|
+
anchorXOffset: "0",
|
|
1446
|
+
anchorYOffset: "-6"
|
|
1447
|
+
}
|
|
1448
|
+
]
|
|
1449
|
+
}
|
|
1450
|
+
});
|
|
1451
|
+
});
|
|
1452
|
+
(certificate.signatories || []).forEach((s, idx) => {
|
|
1453
|
+
if (!s.email) return;
|
|
1454
|
+
signers.push({
|
|
1455
|
+
email: s.email,
|
|
1456
|
+
name: s.name,
|
|
1457
|
+
recipientId: String(recipientIdSeq++),
|
|
1458
|
+
routingOrder: String(s.routingOrder ?? 2),
|
|
1459
|
+
roleName: s.role || s.title,
|
|
1460
|
+
tabs: {
|
|
1461
|
+
signHereTabs: [
|
|
1462
|
+
{
|
|
1463
|
+
anchorString: `/sig_${idx + 1}/`,
|
|
1464
|
+
anchorUnits: "pixels",
|
|
1465
|
+
anchorXOffset: "0",
|
|
1466
|
+
anchorYOffset: "-6"
|
|
1467
|
+
}
|
|
1468
|
+
]
|
|
1469
|
+
}
|
|
1470
|
+
});
|
|
1471
|
+
});
|
|
1472
|
+
if (signers.length === 0) {
|
|
1473
|
+
throw new Error(
|
|
1474
|
+
"At least one signatory or witness must have an email address to send a DocuSign envelope"
|
|
1475
|
+
);
|
|
1476
|
+
}
|
|
1477
|
+
const subjectPrefix = emailSubjectPrefix || "Official Certificate Signature Request";
|
|
1478
|
+
const documentBase64 = Buffer.from(htmlDocument, "utf8").toString("base64");
|
|
1479
|
+
return {
|
|
1480
|
+
emailSubject: `${subjectPrefix} — ${certificate.serialNumber} (${certificate.recipientFullName})`,
|
|
1481
|
+
status: "sent",
|
|
1482
|
+
documents: [
|
|
1483
|
+
{
|
|
1484
|
+
documentBase64,
|
|
1485
|
+
name: `${certificate.serialNumber}.html`,
|
|
1486
|
+
fileExtension: "html",
|
|
1487
|
+
documentId: "1"
|
|
1488
|
+
}
|
|
1489
|
+
],
|
|
1490
|
+
recipients: { signers },
|
|
1491
|
+
customFields: {
|
|
1492
|
+
textCustomFields: [
|
|
1493
|
+
{
|
|
1494
|
+
name: "serialNumber",
|
|
1495
|
+
value: certificate.serialNumber,
|
|
1496
|
+
show: "false"
|
|
1497
|
+
},
|
|
1498
|
+
{
|
|
1499
|
+
name: "certificateDocumentId",
|
|
1500
|
+
value: certificate.documentId,
|
|
1501
|
+
show: "false"
|
|
1502
|
+
}
|
|
1503
|
+
]
|
|
1504
|
+
}
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
function resolveDocuSignCredentials(settings2, options2) {
|
|
1508
|
+
if (!settings2.esign?.enabled || settings2.esign.provider !== "docusign") {
|
|
1509
|
+
throw new Error(
|
|
1510
|
+
"DocuSign eSignature provider is not enabled in Certificates settings"
|
|
1511
|
+
);
|
|
1512
|
+
}
|
|
1513
|
+
const safeBaseUrl = validateDocuSignBaseUrl(settings2.esign.baseUrl);
|
|
1514
|
+
const accountId = firstNonEmptyString(
|
|
1515
|
+
settings2.esign.accountId,
|
|
1516
|
+
process.env.DOCUSIGN_ACCOUNT_ID
|
|
1517
|
+
);
|
|
1518
|
+
if (!accountId) {
|
|
1519
|
+
throw new Error("DocuSign accountId is required");
|
|
1520
|
+
}
|
|
1521
|
+
const accessToken = firstNonEmptyString(
|
|
1522
|
+
options2?.accessToken,
|
|
1523
|
+
process.env.DOCUSIGN_ACCESS_TOKEN
|
|
1524
|
+
);
|
|
1525
|
+
if (!accessToken) {
|
|
1526
|
+
throw new Error(
|
|
1527
|
+
"DOCUSIGN_ACCESS_TOKEN environment variable is required to dispatch DocuSign envelopes"
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1530
|
+
const fetchFn = options2?.fetchImpl ?? globalThis.fetch;
|
|
1531
|
+
if (!fetchFn) {
|
|
1532
|
+
throw new Error("Fetch implementation unavailable");
|
|
1533
|
+
}
|
|
1534
|
+
return { safeBaseUrl, accountId, accessToken, fetchFn };
|
|
1535
|
+
}
|
|
1536
|
+
async function dispatchDocuSignEnvelopeRequest(fetchFn, endpoint, accessToken, envelopePayload) {
|
|
1537
|
+
const response = await fetchFn(endpoint, {
|
|
1538
|
+
method: "POST",
|
|
1539
|
+
headers: {
|
|
1540
|
+
"Authorization": `Bearer ${accessToken}`,
|
|
1541
|
+
"Content-Type": "application/json",
|
|
1542
|
+
"Accept": "application/json"
|
|
1543
|
+
},
|
|
1544
|
+
body: JSON.stringify(envelopePayload)
|
|
1545
|
+
});
|
|
1546
|
+
if (!response.ok) {
|
|
1547
|
+
throw new Error(
|
|
1548
|
+
`DocuSign API returned HTTP ${response.status} when creating envelope`
|
|
1549
|
+
);
|
|
1550
|
+
}
|
|
1551
|
+
const responseJson = await response.json();
|
|
1552
|
+
const envelopeId = String(responseJson.envelopeId || "").trim();
|
|
1553
|
+
if (!envelopeId) {
|
|
1554
|
+
throw new Error("DocuSign API did not return an envelopeId");
|
|
1555
|
+
}
|
|
1556
|
+
return envelopeId;
|
|
1557
|
+
}
|
|
1558
|
+
function asRecord(val, fallback) {
|
|
1559
|
+
return val && typeof val === "object" ? val : fallback;
|
|
1560
|
+
}
|
|
1561
|
+
const ALLOWED_ENVELOPE_STATUSES = [
|
|
1562
|
+
"sent",
|
|
1563
|
+
"delivered",
|
|
1564
|
+
"completed",
|
|
1565
|
+
"declined",
|
|
1566
|
+
"voided"
|
|
1567
|
+
];
|
|
1568
|
+
function firstNonEmptyString(...values) {
|
|
1569
|
+
for (const val of values) {
|
|
1570
|
+
if (typeof val === "string" && val.trim().length > 0) {
|
|
1571
|
+
return val.trim();
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
return "";
|
|
1575
|
+
}
|
|
1576
|
+
function parseWebhookEnvelopeInfo(payload) {
|
|
1577
|
+
const dataObj = asRecord(payload.data, payload);
|
|
1578
|
+
const summaryObj = asRecord(dataObj.envelopeSummary, dataObj);
|
|
1579
|
+
const envelopeId = firstNonEmptyString(
|
|
1580
|
+
dataObj.envelopeId,
|
|
1581
|
+
summaryObj.envelopeId,
|
|
1582
|
+
payload.envelopeId
|
|
1583
|
+
);
|
|
1584
|
+
if (!envelopeId) {
|
|
1585
|
+
throw new Error("Missing envelopeId in DocuSign webhook payload");
|
|
1586
|
+
}
|
|
1587
|
+
const rawStatus = firstNonEmptyString(
|
|
1588
|
+
summaryObj.status,
|
|
1589
|
+
payload.status,
|
|
1590
|
+
"sent"
|
|
1591
|
+
).toLowerCase();
|
|
1592
|
+
const envelopeStatus = ALLOWED_ENVELOPE_STATUSES.includes(rawStatus) ? rawStatus : "sent";
|
|
1593
|
+
return { envelopeId, envelopeStatus };
|
|
1594
|
+
}
|
|
1595
|
+
const docusign = ({ strapi }) => {
|
|
1596
|
+
const getSettingsService = () => strapi.plugin(PLUGIN_ID).service("settings");
|
|
1597
|
+
const getIssuanceService = () => strapi.plugin(PLUGIN_ID).service("issuance");
|
|
1598
|
+
const completeWebhookEnvelope = async (existing, fallbackDoc, envelopeId) => {
|
|
1599
|
+
const nowIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
1600
|
+
const stampedWitnesses = existing.witnesses.map((w) => ({
|
|
1601
|
+
...w,
|
|
1602
|
+
signedAt: w.signedAt || nowIso
|
|
1603
|
+
}));
|
|
1604
|
+
const stampedSignatories = existing.signatories.map((s) => ({
|
|
1605
|
+
...s,
|
|
1606
|
+
signedAt: s.signedAt || nowIso
|
|
1607
|
+
}));
|
|
1608
|
+
await getIssuanceService().approveCertificate(existing.documentId, {
|
|
1609
|
+
witnesses: stampedWitnesses,
|
|
1610
|
+
signatories: stampedSignatories
|
|
1611
|
+
});
|
|
1612
|
+
const updatedCompleted = await strapi.documents(CERTIFICATE_UID$1).update({
|
|
1613
|
+
documentId: existing.documentId,
|
|
1614
|
+
data: {
|
|
1615
|
+
esignProvider: "docusign",
|
|
1616
|
+
esignEnvelopeStatus: "completed",
|
|
1617
|
+
esignCompletedAt: nowIso
|
|
1618
|
+
}
|
|
1619
|
+
});
|
|
1620
|
+
const finalRecord = normalizeCertificateRecord(
|
|
1621
|
+
updatedCompleted || fallbackDoc,
|
|
1622
|
+
true
|
|
1623
|
+
);
|
|
1624
|
+
strapi.eventHub?.emit("certificates.docusign.completed", {
|
|
1625
|
+
serialNumber: finalRecord.serialNumber,
|
|
1626
|
+
envelopeId
|
|
1627
|
+
});
|
|
1628
|
+
return {
|
|
1629
|
+
envelopeId,
|
|
1630
|
+
envelopeStatus: "completed",
|
|
1631
|
+
certificate: finalRecord
|
|
1632
|
+
};
|
|
1633
|
+
};
|
|
1634
|
+
return {
|
|
1635
|
+
async sendEnvelopeForCertificate(documentId, options2) {
|
|
1636
|
+
const settings2 = await getSettingsService().getSettings();
|
|
1637
|
+
const { safeBaseUrl, accountId, accessToken, fetchFn } = resolveDocuSignCredentials(settings2, options2);
|
|
1638
|
+
const rawDoc = await strapi.documents(CERTIFICATE_UID$1).findOne({ documentId });
|
|
1639
|
+
if (!rawDoc) {
|
|
1640
|
+
throw new Error("Certificate record not found");
|
|
1641
|
+
}
|
|
1642
|
+
const cert = normalizeCertificateRecord(rawDoc, true);
|
|
1643
|
+
const rendered = await getIssuanceService().renderCertificateDocument(
|
|
1644
|
+
{ documentId },
|
|
1645
|
+
{ isAdmin: true }
|
|
1646
|
+
);
|
|
1647
|
+
if (!rendered) {
|
|
1648
|
+
throw new Error(
|
|
1649
|
+
"Failed to render certificate HTML for DocuSign envelope"
|
|
1650
|
+
);
|
|
1651
|
+
}
|
|
1652
|
+
const envelopePayload = buildDocuSignEnvelopePayload({
|
|
1653
|
+
certificate: cert,
|
|
1654
|
+
htmlDocument: rendered.html,
|
|
1655
|
+
emailSubjectPrefix: settings2.esign.emailSubjectPrefix
|
|
1656
|
+
});
|
|
1657
|
+
const endpoint = `${safeBaseUrl}/v2.1/accounts/${encodeURIComponent(accountId)}/envelopes`;
|
|
1658
|
+
const envelopeId = await dispatchDocuSignEnvelopeRequest(
|
|
1659
|
+
fetchFn,
|
|
1660
|
+
endpoint,
|
|
1661
|
+
accessToken,
|
|
1662
|
+
envelopePayload
|
|
1663
|
+
);
|
|
1664
|
+
const updated = await strapi.documents(CERTIFICATE_UID$1).update({
|
|
1665
|
+
documentId,
|
|
1666
|
+
data: {
|
|
1667
|
+
esignProvider: "docusign",
|
|
1668
|
+
esignEnvelopeId: envelopeId,
|
|
1669
|
+
esignEnvelopeStatus: "sent"
|
|
1670
|
+
}
|
|
1671
|
+
});
|
|
1672
|
+
const updatedRecord = normalizeCertificateRecord(updated || rawDoc, true);
|
|
1673
|
+
strapi.eventHub?.emit("certificates.docusign.sent", {
|
|
1674
|
+
serialNumber: updatedRecord.serialNumber,
|
|
1675
|
+
envelopeId
|
|
1676
|
+
});
|
|
1677
|
+
return {
|
|
1678
|
+
envelopeId,
|
|
1679
|
+
envelopeStatus: "sent",
|
|
1680
|
+
certificate: updatedRecord
|
|
1681
|
+
};
|
|
1682
|
+
},
|
|
1683
|
+
async handleWebhookEvent(params) {
|
|
1684
|
+
const secret = firstNonEmptyString(
|
|
1685
|
+
params.webhookSecret,
|
|
1686
|
+
process.env.DOCUSIGN_WEBHOOK_SECRET
|
|
1687
|
+
);
|
|
1688
|
+
if (!secret) {
|
|
1689
|
+
throw new Error("DOCUSIGN_WEBHOOK_SECRET is not configured");
|
|
1690
|
+
}
|
|
1691
|
+
if (!verifyDocuSignWebhookHmac(
|
|
1692
|
+
params.rawBody,
|
|
1693
|
+
params.signatureHeader,
|
|
1694
|
+
secret
|
|
1695
|
+
)) {
|
|
1696
|
+
throw new Error("Invalid DocuSign webhook HMAC-SHA256 signature");
|
|
1697
|
+
}
|
|
1698
|
+
const { envelopeId, envelopeStatus } = parseWebhookEnvelopeInfo(
|
|
1699
|
+
params.payload
|
|
1700
|
+
);
|
|
1701
|
+
const matches = await strapi.documents(CERTIFICATE_UID$1).findMany({
|
|
1702
|
+
filters: { esignEnvelopeId: envelopeId },
|
|
1703
|
+
limit: 1
|
|
1704
|
+
});
|
|
1705
|
+
if (matches.length === 0) {
|
|
1706
|
+
throw new Error(
|
|
1707
|
+
`No certificate found for DocuSign envelopeId ${envelopeId}`
|
|
1708
|
+
);
|
|
1709
|
+
}
|
|
1710
|
+
const existing = normalizeCertificateRecord(matches[0], true);
|
|
1711
|
+
if (envelopeStatus === "completed") {
|
|
1712
|
+
return completeWebhookEnvelope(existing, matches[0], envelopeId);
|
|
1713
|
+
}
|
|
1714
|
+
const updated = await strapi.documents(CERTIFICATE_UID$1).update({
|
|
1715
|
+
documentId: existing.documentId,
|
|
1716
|
+
data: { esignEnvelopeStatus: envelopeStatus }
|
|
1717
|
+
});
|
|
1718
|
+
return {
|
|
1719
|
+
envelopeId,
|
|
1720
|
+
envelopeStatus,
|
|
1721
|
+
certificate: normalizeCertificateRecord(updated || matches[0], true)
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
};
|
|
1725
|
+
};
|
|
1726
|
+
const TEMPLATE_UID = "plugin::certificates.template";
|
|
1727
|
+
const CERTIFICATE_UID = "plugin::certificates.certificate";
|
|
1728
|
+
const trimStr = (val, fallback = "") => (val ? String(val).trim() : "") || fallback;
|
|
1729
|
+
const trimOptStr = (val) => trimStr(val) || void 0;
|
|
1730
|
+
const pickNonEmptyArray = (primary, fallback) => Array.isArray(primary) && primary.length > 0 ? primary : fallback;
|
|
1731
|
+
const slugifyName = (name, explicitSlug) => trimStr(explicitSlug) || name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1732
|
+
const DEFAULT_BODY_TEMPLATE = "This is to certify that {{recipientFullName}} has been officially attested by {{organizationName}} on {{issuedDateFormatted}}.";
|
|
1733
|
+
function buildTemplateCreateData(name, data, settings2) {
|
|
1734
|
+
const days = data.defaultValidityDays;
|
|
1735
|
+
return {
|
|
1736
|
+
name,
|
|
1737
|
+
slug: slugifyName(name, data.slug),
|
|
1738
|
+
description: trimStr(data.description),
|
|
1739
|
+
serialPrefix: trimStr(data.serialPrefix, "CERT").toUpperCase().slice(0, 8),
|
|
1740
|
+
defaultValidityDays: typeof days === "number" ? days : null,
|
|
1741
|
+
requiresWitnesses: Boolean(data.requiresWitnesses),
|
|
1742
|
+
minWitnesses: typeof data.minWitnesses === "number" ? data.minWitnesses : 0,
|
|
1743
|
+
requiresApproval: data.requiresApproval !== false,
|
|
1744
|
+
organizationName: trimStr(data.organizationName, settings2.organizationName),
|
|
1745
|
+
organizationSubheader: data.organizationSubheader ?? settings2.organizationSubheader,
|
|
1746
|
+
headerText: trimStr(data.headerText),
|
|
1747
|
+
titleText: trimStr(data.titleText, name),
|
|
1748
|
+
bodyTemplate: trimStr(data.bodyTemplate, DEFAULT_BODY_TEMPLATE),
|
|
1749
|
+
footerText: trimStr(data.footerText),
|
|
1750
|
+
defaultSignatories: normalizeSignatories(data.defaultSignatories),
|
|
1751
|
+
customFieldsSchema: Array.isArray(data.customFieldsSchema) ? data.customFieldsSchema : [],
|
|
1752
|
+
secondaryCalendars: Array.isArray(data.secondaryCalendars) ? data.secondaryCalendars : settings2.secondaryCalendars,
|
|
1753
|
+
accentColor: trimStr(data.accentColor, "#14532d"),
|
|
1754
|
+
active: data.active !== false
|
|
1755
|
+
};
|
|
1756
|
+
}
|
|
1757
|
+
function assertWitnessCount(template, witnesses, enforceNow) {
|
|
1758
|
+
if (enforceNow && template?.requiresWitnesses) {
|
|
1759
|
+
if (witnesses.length < template.minWitnesses) {
|
|
1760
|
+
throw new Error(
|
|
1761
|
+
`Template "${template.name}" requires at least ${template.minWitnesses} official witness(es) before issuance`
|
|
1762
|
+
);
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
function emitCertEvent(strapi, event, record) {
|
|
1767
|
+
strapi.eventHub?.emit(event, {
|
|
1768
|
+
serialNumber: record.serialNumber,
|
|
1769
|
+
templateSlug: record.templateSlug,
|
|
1770
|
+
status: record.status
|
|
1771
|
+
});
|
|
1772
|
+
}
|
|
1773
|
+
function resolveIssueDatesAndIdentity(input, template) {
|
|
1774
|
+
const issuedDateObj = input.issuedAt ? new Date(input.issuedAt) : /* @__PURE__ */ new Date();
|
|
1775
|
+
if (Number.isNaN(issuedDateObj.getTime())) {
|
|
1776
|
+
throw new Error("Invalid issuedAt date");
|
|
1777
|
+
}
|
|
1778
|
+
const issuedAt = issuedDateObj.toISOString();
|
|
1779
|
+
const days = template.defaultValidityDays;
|
|
1780
|
+
const computedExpiry = typeof days === "number" && days > 0 ? new Date(issuedDateObj.getTime() + days * 864e5).toISOString() : null;
|
|
1781
|
+
const serialNumber = generateSerialNumber(
|
|
1782
|
+
template.serialPrefix,
|
|
1783
|
+
issuedDateObj.getUTCFullYear()
|
|
1784
|
+
);
|
|
1785
|
+
const rawNationalId = trimOptStr(input.nationalIdRaw);
|
|
1786
|
+
return {
|
|
1787
|
+
issuedDateObj,
|
|
1788
|
+
fields: {
|
|
1789
|
+
issuedAt,
|
|
1790
|
+
expiresAt: input.expiresAt ?? computedExpiry,
|
|
1791
|
+
serialNumber,
|
|
1792
|
+
verificationToken: generateVerificationToken(),
|
|
1793
|
+
nationalIdMasked: rawNationalId ? maskNationalId(rawNationalId) : "",
|
|
1794
|
+
nationalIdHash: rawNationalId ? hashNationalId(rawNationalId, serialNumber) : ""
|
|
1795
|
+
}
|
|
1796
|
+
};
|
|
1797
|
+
}
|
|
1798
|
+
function resolveApprovalFields(existing, options2) {
|
|
1799
|
+
return {
|
|
1800
|
+
witnesses: options2?.witnesses !== void 0 ? normalizeWitnesses(options2.witnesses) : existing.witnesses,
|
|
1801
|
+
signatories: options2?.signatories !== void 0 ? normalizeSignatories(options2.signatories) : existing.signatories,
|
|
1802
|
+
recipientChosenName: options2?.recipientChosenName !== void 0 ? trimStr(options2.recipientChosenName) : existing.recipientChosenName,
|
|
1803
|
+
notes: options2?.notes !== void 0 ? options2.notes : existing.notes
|
|
1804
|
+
};
|
|
1805
|
+
}
|
|
1806
|
+
const issuance = ({ strapi }) => {
|
|
1807
|
+
const getSettingsService = () => strapi.plugin(PLUGIN_ID).service("settings");
|
|
1808
|
+
return {
|
|
1809
|
+
async listTemplates(activeOnly = false) {
|
|
1810
|
+
const filters = activeOnly ? { active: true } : void 0;
|
|
1811
|
+
const docs = await strapi.documents(TEMPLATE_UID).findMany({ filters, limit: 100 });
|
|
1812
|
+
return docs.map(normalizeTemplateRecord);
|
|
1813
|
+
},
|
|
1814
|
+
async findTemplate(identifier) {
|
|
1815
|
+
if (identifier.templateDocumentId) {
|
|
1816
|
+
const doc = await strapi.documents(TEMPLATE_UID).findOne({
|
|
1817
|
+
documentId: identifier.templateDocumentId
|
|
1818
|
+
});
|
|
1819
|
+
if (doc) return normalizeTemplateRecord(doc);
|
|
1820
|
+
}
|
|
1821
|
+
if (identifier.templateSlug) {
|
|
1822
|
+
const [first] = await strapi.documents(TEMPLATE_UID).findMany({
|
|
1823
|
+
filters: { slug: identifier.templateSlug },
|
|
1824
|
+
limit: 1
|
|
1825
|
+
});
|
|
1826
|
+
if (first) return normalizeTemplateRecord(first);
|
|
1827
|
+
}
|
|
1828
|
+
return null;
|
|
1829
|
+
},
|
|
1830
|
+
async createTemplate(data) {
|
|
1831
|
+
const settings2 = await getSettingsService().getSettings();
|
|
1832
|
+
const name = trimStr(data.name);
|
|
1833
|
+
if (!name) throw new Error("Template name is required");
|
|
1834
|
+
const created = await strapi.documents(TEMPLATE_UID).create({
|
|
1835
|
+
data: buildTemplateCreateData(name, data, settings2)
|
|
1836
|
+
});
|
|
1837
|
+
return normalizeTemplateRecord(created);
|
|
1838
|
+
},
|
|
1839
|
+
async seedDefaultTemplates() {
|
|
1840
|
+
const settings2 = await getSettingsService().getSettings();
|
|
1841
|
+
let seededCount = 0;
|
|
1842
|
+
for (const tpl of settings2.defaultTemplates || []) {
|
|
1843
|
+
if (!await this.findTemplate({ templateSlug: tpl.slug })) {
|
|
1844
|
+
await this.createTemplate(tpl);
|
|
1845
|
+
seededCount++;
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
return seededCount;
|
|
1849
|
+
},
|
|
1850
|
+
async issueCertificate(input) {
|
|
1851
|
+
const recipientFullName = trimStr(input.recipientFullName);
|
|
1852
|
+
if (!recipientFullName) throw new Error("recipientFullName is required");
|
|
1853
|
+
const template = await this.findTemplate({
|
|
1854
|
+
templateDocumentId: input.templateDocumentId,
|
|
1855
|
+
templateSlug: input.templateSlug
|
|
1856
|
+
});
|
|
1857
|
+
if (!template?.active)
|
|
1858
|
+
throw new Error("Active certificate template not found");
|
|
1859
|
+
const settings2 = await getSettingsService().getSettings();
|
|
1860
|
+
const keyPair = await getSettingsService().getSigningKeyPair();
|
|
1861
|
+
const isIssued = input.autoApprove === true || !template.requiresApproval;
|
|
1862
|
+
const status = isIssued ? "issued" : "pending_approval";
|
|
1863
|
+
const witnesses = normalizeWitnesses(input.witnesses);
|
|
1864
|
+
assertWitnessCount(template, witnesses, isIssued);
|
|
1865
|
+
const signatories = pickNonEmptyArray(
|
|
1866
|
+
normalizeSignatories(input.signatories),
|
|
1867
|
+
template.defaultSignatories
|
|
1868
|
+
);
|
|
1869
|
+
const { issuedDateObj, fields } = resolveIssueDatesAndIdentity(
|
|
1870
|
+
input,
|
|
1871
|
+
template
|
|
1872
|
+
);
|
|
1873
|
+
const secondaryDates = formatSecondaryCalendars(
|
|
1874
|
+
issuedDateObj,
|
|
1875
|
+
pickNonEmptyArray(
|
|
1876
|
+
template.secondaryCalendars,
|
|
1877
|
+
settings2.secondaryCalendars
|
|
1878
|
+
),
|
|
1879
|
+
trimStr(input.locale, trimStr(settings2.defaultLocale, "en"))
|
|
1880
|
+
);
|
|
1881
|
+
const signed = signCanonicalPayload(
|
|
1882
|
+
buildCanonicalPayload({
|
|
1883
|
+
...fields,
|
|
1884
|
+
templateSlug: template.slug,
|
|
1885
|
+
organizationName: trimStr(
|
|
1886
|
+
template.organizationName,
|
|
1887
|
+
settings2.organizationName
|
|
1888
|
+
),
|
|
1889
|
+
recipientFullName,
|
|
1890
|
+
recipientChosenName: input.recipientChosenName,
|
|
1891
|
+
signatories,
|
|
1892
|
+
witnesses
|
|
1893
|
+
}),
|
|
1894
|
+
keyPair.privateKeyPem
|
|
1895
|
+
);
|
|
1896
|
+
const created = await strapi.documents(CERTIFICATE_UID).create({
|
|
1897
|
+
data: {
|
|
1898
|
+
...fields,
|
|
1899
|
+
templateDocumentId: template.documentId,
|
|
1900
|
+
templateSlug: template.slug,
|
|
1901
|
+
templateName: template.name,
|
|
1902
|
+
status,
|
|
1903
|
+
recipientFullName,
|
|
1904
|
+
recipientChosenName: trimStr(input.recipientChosenName),
|
|
1905
|
+
recipientEmail: trimOptStr(input.recipientEmail),
|
|
1906
|
+
recipientPhone: trimStr(input.recipientPhone),
|
|
1907
|
+
recipientNationality: trimStr(input.recipientNationality),
|
|
1908
|
+
signatories,
|
|
1909
|
+
witnesses,
|
|
1910
|
+
customData: input.customData ?? {},
|
|
1911
|
+
secondaryDates,
|
|
1912
|
+
canonicalPayloadHash: signed.payloadHash,
|
|
1913
|
+
cryptographicSignature: signed.signature,
|
|
1914
|
+
publicKeyFingerprint: keyPair.fingerprint,
|
|
1915
|
+
linkedBookingCode: trimStr(input.linkedBookingCode),
|
|
1916
|
+
notes: trimStr(input.notes)
|
|
1917
|
+
}
|
|
1918
|
+
});
|
|
1919
|
+
const record = normalizeCertificateRecord(created, true);
|
|
1920
|
+
emitCertEvent(
|
|
1921
|
+
strapi,
|
|
1922
|
+
isIssued ? "certificates.issued" : "certificates.requested",
|
|
1923
|
+
record
|
|
1924
|
+
);
|
|
1925
|
+
return record;
|
|
1926
|
+
},
|
|
1927
|
+
async approveCertificate(documentId, options2) {
|
|
1928
|
+
const existingDoc = await strapi.documents(CERTIFICATE_UID).findOne({ documentId });
|
|
1929
|
+
if (!existingDoc) throw new Error("Certificate record not found");
|
|
1930
|
+
const existing = normalizeCertificateRecord(existingDoc, true);
|
|
1931
|
+
const template = await this.findTemplate({
|
|
1932
|
+
templateDocumentId: existing.templateDocumentId,
|
|
1933
|
+
templateSlug: existing.templateSlug
|
|
1934
|
+
});
|
|
1935
|
+
const { witnesses, signatories, recipientChosenName, notes } = resolveApprovalFields(existing, options2);
|
|
1936
|
+
assertWitnessCount(template, witnesses, true);
|
|
1937
|
+
const keyPair = await getSettingsService().getSigningKeyPair();
|
|
1938
|
+
const settings2 = await getSettingsService().getSettings();
|
|
1939
|
+
const issuedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1940
|
+
const signed = signCanonicalPayload(
|
|
1941
|
+
buildCanonicalPayload({
|
|
1942
|
+
...existing,
|
|
1943
|
+
organizationName: trimStr(
|
|
1944
|
+
template?.organizationName,
|
|
1945
|
+
settings2.organizationName
|
|
1946
|
+
),
|
|
1947
|
+
recipientChosenName,
|
|
1948
|
+
issuedAt,
|
|
1949
|
+
signatories,
|
|
1950
|
+
witnesses
|
|
1951
|
+
}),
|
|
1952
|
+
keyPair.privateKeyPem
|
|
1953
|
+
);
|
|
1954
|
+
const updated = await strapi.documents(CERTIFICATE_UID).update({
|
|
1955
|
+
documentId,
|
|
1956
|
+
data: {
|
|
1957
|
+
status: "issued",
|
|
1958
|
+
issuedAt,
|
|
1959
|
+
recipientChosenName: trimStr(recipientChosenName),
|
|
1960
|
+
signatories,
|
|
1961
|
+
witnesses,
|
|
1962
|
+
canonicalPayloadHash: signed.payloadHash,
|
|
1963
|
+
cryptographicSignature: signed.signature,
|
|
1964
|
+
publicKeyFingerprint: keyPair.fingerprint,
|
|
1965
|
+
notes
|
|
1966
|
+
}
|
|
1967
|
+
});
|
|
1968
|
+
if (!updated) throw new Error("Failed to update certificate record");
|
|
1969
|
+
const record = normalizeCertificateRecord(updated, true);
|
|
1970
|
+
emitCertEvent(strapi, "certificates.issued", record);
|
|
1971
|
+
return record;
|
|
1972
|
+
},
|
|
1973
|
+
async revokeCertificate(documentId, reason) {
|
|
1974
|
+
const cleanReason = trimStr(reason, "Revoked by administrator");
|
|
1975
|
+
const updated = await strapi.documents(CERTIFICATE_UID).update({
|
|
1976
|
+
documentId,
|
|
1977
|
+
data: {
|
|
1978
|
+
status: "revoked",
|
|
1979
|
+
revokedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1980
|
+
revocationReason: cleanReason
|
|
1981
|
+
}
|
|
1982
|
+
});
|
|
1983
|
+
if (!updated) throw new Error("Certificate record not found");
|
|
1984
|
+
const record = normalizeCertificateRecord(updated, false);
|
|
1985
|
+
strapi.eventHub?.emit("certificates.revoked", {
|
|
1986
|
+
serialNumber: record.serialNumber,
|
|
1987
|
+
revocationReason: cleanReason
|
|
1988
|
+
});
|
|
1989
|
+
return record;
|
|
1990
|
+
},
|
|
1991
|
+
async verifyCertificateBySerial(serialNumber, options2) {
|
|
1992
|
+
const cleanSerial = trimStr(serialNumber).toUpperCase();
|
|
1993
|
+
if (!cleanSerial) return null;
|
|
1994
|
+
const [match] = await strapi.documents(CERTIFICATE_UID).findMany({
|
|
1995
|
+
filters: { serialNumber: cleanSerial },
|
|
1996
|
+
limit: 1
|
|
1997
|
+
});
|
|
1998
|
+
if (!match) return null;
|
|
1999
|
+
const cert = normalizeCertificateRecord(match, true);
|
|
2000
|
+
const settings2 = await getSettingsService().getSettings();
|
|
2001
|
+
const template = await this.findTemplate({
|
|
2002
|
+
templateDocumentId: cert.templateDocumentId,
|
|
2003
|
+
templateSlug: cert.templateSlug
|
|
2004
|
+
});
|
|
2005
|
+
return evaluateCertificateVerification({
|
|
2006
|
+
cert,
|
|
2007
|
+
template,
|
|
2008
|
+
settings: settings2,
|
|
2009
|
+
options: options2
|
|
2010
|
+
});
|
|
2011
|
+
},
|
|
2012
|
+
async renderCertificateDocument(identifier, options2) {
|
|
2013
|
+
let rawDoc = null;
|
|
2014
|
+
if (identifier.documentId) {
|
|
2015
|
+
rawDoc = await strapi.documents(CERTIFICATE_UID).findOne({
|
|
2016
|
+
documentId: identifier.documentId
|
|
2017
|
+
});
|
|
2018
|
+
} else if (identifier.serialNumber) {
|
|
2019
|
+
const [first] = await strapi.documents(CERTIFICATE_UID).findMany({
|
|
2020
|
+
filters: {
|
|
2021
|
+
serialNumber: identifier.serialNumber.trim().toUpperCase()
|
|
2022
|
+
},
|
|
2023
|
+
limit: 1
|
|
2024
|
+
});
|
|
2025
|
+
rawDoc = first || null;
|
|
2026
|
+
}
|
|
2027
|
+
if (!rawDoc) return null;
|
|
2028
|
+
const cert = normalizeCertificateRecord(rawDoc, true);
|
|
2029
|
+
const settings2 = await getSettingsService().getSettings();
|
|
2030
|
+
const template = await this.findTemplate({
|
|
2031
|
+
templateDocumentId: cert.templateDocumentId,
|
|
2032
|
+
templateSlug: cert.templateSlug
|
|
2033
|
+
});
|
|
2034
|
+
return renderPrintableFromRecord({
|
|
2035
|
+
cert,
|
|
2036
|
+
template,
|
|
2037
|
+
settings: settings2,
|
|
2038
|
+
options: options2
|
|
2039
|
+
});
|
|
2040
|
+
},
|
|
2041
|
+
async listCertificates(filters) {
|
|
2042
|
+
const docs = await strapi.documents(CERTIFICATE_UID).findMany({ limit: 500 });
|
|
2043
|
+
return filterAndComputeCertificateStats(
|
|
2044
|
+
docs.map((d) => normalizeCertificateRecord(d, true)),
|
|
2045
|
+
filters
|
|
2046
|
+
);
|
|
2047
|
+
},
|
|
2048
|
+
async exportCsv(filters) {
|
|
2049
|
+
return exportCertificatesToCsv(
|
|
2050
|
+
(await this.listCertificates(filters)).records
|
|
2051
|
+
);
|
|
2052
|
+
}
|
|
2053
|
+
};
|
|
2054
|
+
};
|
|
2055
|
+
const SETTINGS_STORE_KEY = "settings";
|
|
2056
|
+
const KEYPAIR_STORE_KEY = "ed25519_keypair";
|
|
2057
|
+
const DEFAULT_SECONDARY_CALENDARS = [
|
|
2058
|
+
{
|
|
2059
|
+
id: "islamic-umalqura",
|
|
2060
|
+
label: "Hijri (Umm al-Qura)",
|
|
2061
|
+
intlCalendar: "islamic-umalqura",
|
|
2062
|
+
enabled: true,
|
|
2063
|
+
dayOffset: 0
|
|
2064
|
+
}
|
|
2065
|
+
];
|
|
2066
|
+
const DEFAULT_ESIGN_CONFIG = {
|
|
2067
|
+
enabled: false,
|
|
2068
|
+
provider: "none",
|
|
2069
|
+
baseUrl: "https://demo.docusign.net/restapi",
|
|
2070
|
+
accountId: "",
|
|
2071
|
+
integrationKey: "",
|
|
2072
|
+
emailSubjectPrefix: "Official Certificate Signature Request"
|
|
2073
|
+
};
|
|
2074
|
+
const DEFAULT_SETTINGS = {
|
|
2075
|
+
organizationName: "Official Certification Authority",
|
|
2076
|
+
organizationSubheader: "Office of Official Records & Attestation",
|
|
2077
|
+
verificationBaseUrl: "http://localhost:1337/api/certificates/verify",
|
|
2078
|
+
defaultTimezone: "UTC",
|
|
2079
|
+
defaultLocale: "en",
|
|
2080
|
+
maskPublicRecipientName: true,
|
|
2081
|
+
allowPublicApplications: true,
|
|
2082
|
+
secondaryCalendars: DEFAULT_SECONDARY_CALENDARS,
|
|
2083
|
+
esign: DEFAULT_ESIGN_CONFIG,
|
|
2084
|
+
defaultTemplates: []
|
|
2085
|
+
};
|
|
2086
|
+
function sanitizeCalendarSystems(raw) {
|
|
2087
|
+
if (!Array.isArray(raw)) return DEFAULT_SECONDARY_CALENDARS;
|
|
2088
|
+
return raw.filter(
|
|
2089
|
+
(item) => Boolean(item && typeof item === "object")
|
|
2090
|
+
).map((item) => ({
|
|
2091
|
+
id: String(item.id || "islamic-umalqura").trim(),
|
|
2092
|
+
label: String(item.label || item.id || "Hijri").trim(),
|
|
2093
|
+
intlCalendar: String(item.intlCalendar || "islamic-umalqura").trim(),
|
|
2094
|
+
enabled: item.enabled !== false,
|
|
2095
|
+
dayOffset: typeof item.dayOffset === "number" && Number.isFinite(item.dayOffset) ? Math.max(-5, Math.min(5, Math.trunc(item.dayOffset))) : 0
|
|
2096
|
+
}));
|
|
2097
|
+
}
|
|
2098
|
+
function pickFirstString(...candidates) {
|
|
2099
|
+
for (const candidate of candidates) {
|
|
2100
|
+
if (candidate !== void 0 && candidate !== "") {
|
|
2101
|
+
return String(candidate).trim();
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
return "";
|
|
2105
|
+
}
|
|
2106
|
+
function normalizeEsignProvider(providerRaw, enabled) {
|
|
2107
|
+
if (providerRaw === "docusign" || providerRaw === "webhook") {
|
|
2108
|
+
return providerRaw;
|
|
2109
|
+
}
|
|
2110
|
+
return enabled ? "docusign" : "none";
|
|
2111
|
+
}
|
|
2112
|
+
function toRecordObject(raw) {
|
|
2113
|
+
return raw && typeof raw === "object" ? raw : {};
|
|
2114
|
+
}
|
|
2115
|
+
function sanitizeESignConfig(raw, fallback) {
|
|
2116
|
+
const obj = toRecordObject(raw);
|
|
2117
|
+
const envAccountId = process.env.DOCUSIGN_ACCOUNT_ID?.trim();
|
|
2118
|
+
const envBaseUrl = process.env.DOCUSIGN_BASE_URL?.trim();
|
|
2119
|
+
const envIntegrationKey = process.env.DOCUSIGN_INTEGRATION_KEY?.trim();
|
|
2120
|
+
const enabled = Boolean(
|
|
2121
|
+
obj.enabled ?? fallback.enabled ?? Boolean(envAccountId)
|
|
2122
|
+
);
|
|
2123
|
+
const providerRaw = pickFirstString(obj.provider, fallback.provider, "none");
|
|
2124
|
+
return {
|
|
2125
|
+
enabled,
|
|
2126
|
+
provider: normalizeEsignProvider(providerRaw, Boolean(obj.enabled)),
|
|
2127
|
+
baseUrl: stripTrailingSlashes(
|
|
2128
|
+
pickFirstString(
|
|
2129
|
+
obj.baseUrl,
|
|
2130
|
+
envBaseUrl,
|
|
2131
|
+
fallback.baseUrl,
|
|
2132
|
+
DEFAULT_ESIGN_CONFIG.baseUrl
|
|
2133
|
+
)
|
|
2134
|
+
),
|
|
2135
|
+
accountId: pickFirstString(obj.accountId, envAccountId, fallback.accountId),
|
|
2136
|
+
integrationKey: pickFirstString(
|
|
2137
|
+
obj.integrationKey,
|
|
2138
|
+
envIntegrationKey,
|
|
2139
|
+
fallback.integrationKey
|
|
2140
|
+
),
|
|
2141
|
+
emailSubjectPrefix: pickFirstString(
|
|
2142
|
+
obj.emailSubjectPrefix,
|
|
2143
|
+
fallback.emailSubjectPrefix,
|
|
2144
|
+
DEFAULT_ESIGN_CONFIG.emailSubjectPrefix
|
|
2145
|
+
)
|
|
2146
|
+
};
|
|
2147
|
+
}
|
|
2148
|
+
const settings = ({ strapi }) => {
|
|
2149
|
+
const getPluginStore = () => strapi.store({
|
|
2150
|
+
type: "plugin",
|
|
2151
|
+
name: PLUGIN_ID
|
|
2152
|
+
});
|
|
2153
|
+
return {
|
|
2154
|
+
async getSigningKeyPair() {
|
|
2155
|
+
const envPrivate = process.env.CERTIFICATES_PRIVATE_KEY_PEM?.trim();
|
|
2156
|
+
const envPublic = process.env.CERTIFICATES_PUBLIC_KEY_PEM?.trim();
|
|
2157
|
+
if (envPrivate && envPublic) {
|
|
2158
|
+
return {
|
|
2159
|
+
privateKeyPem: envPrivate.replace(/\\n/g, "\n"),
|
|
2160
|
+
publicKeyPem: envPublic.replace(/\\n/g, "\n"),
|
|
2161
|
+
fingerprint: computePublicKeyFingerprint(
|
|
2162
|
+
envPublic.replace(/\\n/g, "\n")
|
|
2163
|
+
)
|
|
2164
|
+
};
|
|
2165
|
+
}
|
|
2166
|
+
const store = getPluginStore();
|
|
2167
|
+
const stored = await store.get({ key: KEYPAIR_STORE_KEY });
|
|
2168
|
+
if (stored?.privateKeyPem && stored?.publicKeyPem) {
|
|
2169
|
+
return {
|
|
2170
|
+
privateKeyPem: stored.privateKeyPem,
|
|
2171
|
+
publicKeyPem: stored.publicKeyPem,
|
|
2172
|
+
fingerprint: stored.fingerprint || computePublicKeyFingerprint(stored.publicKeyPem)
|
|
2173
|
+
};
|
|
2174
|
+
}
|
|
2175
|
+
const generated = generateEd25519KeyPair();
|
|
2176
|
+
await store.set({
|
|
2177
|
+
key: KEYPAIR_STORE_KEY,
|
|
2178
|
+
value: generated
|
|
2179
|
+
});
|
|
2180
|
+
strapi.log.info(
|
|
2181
|
+
`[${PLUGIN_ID}] Generated and persisted new Ed25519 signing keypair (${generated.fingerprint}). Set CERTIFICATES_PRIVATE_KEY_PEM and CERTIFICATES_PUBLIC_KEY_PEM in production.`
|
|
2182
|
+
);
|
|
2183
|
+
return generated;
|
|
2184
|
+
},
|
|
2185
|
+
async rotateKeyPair() {
|
|
2186
|
+
const store = getPluginStore();
|
|
2187
|
+
const generated = generateEd25519KeyPair();
|
|
2188
|
+
await store.set({
|
|
2189
|
+
key: KEYPAIR_STORE_KEY,
|
|
2190
|
+
value: generated
|
|
2191
|
+
});
|
|
2192
|
+
strapi.log.warn(
|
|
2193
|
+
`[${PLUGIN_ID}] Rotated Ed25519 signing keypair to ${generated.fingerprint}`
|
|
2194
|
+
);
|
|
2195
|
+
return {
|
|
2196
|
+
publicKeyPem: generated.publicKeyPem,
|
|
2197
|
+
publicKeyFingerprint: generated.fingerprint
|
|
2198
|
+
};
|
|
2199
|
+
},
|
|
2200
|
+
async getSettings() {
|
|
2201
|
+
const store = getPluginStore();
|
|
2202
|
+
const configDefaults = strapi.config.get(
|
|
2203
|
+
`plugin::${PLUGIN_ID}`,
|
|
2204
|
+
{}
|
|
2205
|
+
) || {};
|
|
2206
|
+
const stored = await store.get({ key: SETTINGS_STORE_KEY }) || {};
|
|
2207
|
+
const keyPair = await this.getSigningKeyPair();
|
|
2208
|
+
const merged = {
|
|
2209
|
+
...DEFAULT_SETTINGS,
|
|
2210
|
+
...configDefaults,
|
|
2211
|
+
...stored
|
|
2212
|
+
};
|
|
2213
|
+
const defaultEsign = sanitizeESignConfig(
|
|
2214
|
+
configDefaults.esign,
|
|
2215
|
+
DEFAULT_ESIGN_CONFIG
|
|
2216
|
+
);
|
|
2217
|
+
return {
|
|
2218
|
+
organizationName: String(
|
|
2219
|
+
merged.organizationName || DEFAULT_SETTINGS.organizationName
|
|
2220
|
+
).trim(),
|
|
2221
|
+
organizationSubheader: String(
|
|
2222
|
+
merged.organizationSubheader ?? DEFAULT_SETTINGS.organizationSubheader
|
|
2223
|
+
).trim(),
|
|
2224
|
+
verificationBaseUrl: stripTrailingSlashes(
|
|
2225
|
+
String(
|
|
2226
|
+
merged.verificationBaseUrl || DEFAULT_SETTINGS.verificationBaseUrl
|
|
2227
|
+
).trim()
|
|
2228
|
+
),
|
|
2229
|
+
defaultTimezone: String(
|
|
2230
|
+
merged.defaultTimezone || DEFAULT_SETTINGS.defaultTimezone
|
|
2231
|
+
).trim(),
|
|
2232
|
+
defaultLocale: String(
|
|
2233
|
+
merged.defaultLocale || DEFAULT_SETTINGS.defaultLocale
|
|
2234
|
+
).trim(),
|
|
2235
|
+
maskPublicRecipientName: merged.maskPublicRecipientName !== false,
|
|
2236
|
+
allowPublicApplications: merged.allowPublicApplications !== false,
|
|
2237
|
+
publicKeyPem: keyPair.publicKeyPem,
|
|
2238
|
+
publicKeyFingerprint: keyPair.fingerprint,
|
|
2239
|
+
secondaryCalendars: sanitizeCalendarSystems(merged.secondaryCalendars),
|
|
2240
|
+
esign: sanitizeESignConfig(stored.esign, defaultEsign),
|
|
2241
|
+
defaultTemplates: Array.isArray(merged.defaultTemplates) ? merged.defaultTemplates : []
|
|
2242
|
+
};
|
|
2243
|
+
},
|
|
2244
|
+
async updateSettings(partial) {
|
|
2245
|
+
const current = await this.getSettings();
|
|
2246
|
+
const updated = {
|
|
2247
|
+
organizationName: typeof partial.organizationName === "string" ? partial.organizationName.trim() : current.organizationName,
|
|
2248
|
+
organizationSubheader: typeof partial.organizationSubheader === "string" ? partial.organizationSubheader.trim() : current.organizationSubheader,
|
|
2249
|
+
verificationBaseUrl: typeof partial.verificationBaseUrl === "string" ? stripTrailingSlashes(partial.verificationBaseUrl.trim()) : current.verificationBaseUrl,
|
|
2250
|
+
defaultTimezone: typeof partial.defaultTimezone === "string" ? partial.defaultTimezone.trim() : current.defaultTimezone,
|
|
2251
|
+
defaultLocale: typeof partial.defaultLocale === "string" ? partial.defaultLocale.trim() : current.defaultLocale,
|
|
2252
|
+
maskPublicRecipientName: typeof partial.maskPublicRecipientName === "boolean" ? partial.maskPublicRecipientName : current.maskPublicRecipientName,
|
|
2253
|
+
allowPublicApplications: typeof partial.allowPublicApplications === "boolean" ? partial.allowPublicApplications : current.allowPublicApplications,
|
|
2254
|
+
secondaryCalendars: partial.secondaryCalendars !== void 0 ? sanitizeCalendarSystems(partial.secondaryCalendars) : current.secondaryCalendars,
|
|
2255
|
+
esign: partial.esign !== void 0 ? sanitizeESignConfig(partial.esign, current.esign) : current.esign,
|
|
2256
|
+
defaultTemplates: current.defaultTemplates
|
|
2257
|
+
};
|
|
2258
|
+
const store = getPluginStore();
|
|
2259
|
+
await store.set({
|
|
2260
|
+
key: SETTINGS_STORE_KEY,
|
|
2261
|
+
value: updated
|
|
2262
|
+
});
|
|
2263
|
+
return this.getSettings();
|
|
2264
|
+
}
|
|
2265
|
+
};
|
|
2266
|
+
};
|
|
2267
|
+
const services = {
|
|
2268
|
+
settings,
|
|
2269
|
+
issuance,
|
|
2270
|
+
docusign
|
|
2271
|
+
};
|
|
2272
|
+
const index = {
|
|
2273
|
+
register,
|
|
2274
|
+
bootstrap,
|
|
2275
|
+
contentTypes,
|
|
2276
|
+
controllers,
|
|
2277
|
+
routes,
|
|
2278
|
+
services
|
|
2279
|
+
};
|
|
2280
|
+
export {
|
|
2281
|
+
index as default
|
|
2282
|
+
};
|