@intranefr/superbackend 1.4.3
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/.commiat +4 -0
- package/.env.example +47 -0
- package/README.md +110 -0
- package/index.js +94 -0
- package/package.json +67 -0
- package/public/css/styles.css +139 -0
- package/public/js/animations.js +41 -0
- package/sdk/error-tracking/browser/package.json +16 -0
- package/sdk/error-tracking/browser/src/core.js +270 -0
- package/sdk/error-tracking/browser/src/embed.js +18 -0
- package/sdk/error-tracking/browser/src/index.js +1 -0
- package/server.js +5 -0
- package/src/admin/endpointRegistry.js +300 -0
- package/src/controllers/admin.controller.js +321 -0
- package/src/controllers/adminAssets.controller.js +530 -0
- package/src/controllers/adminAssetsStorage.controller.js +260 -0
- package/src/controllers/adminEjsVirtual.controller.js +354 -0
- package/src/controllers/adminFeatureFlags.controller.js +155 -0
- package/src/controllers/adminHeadless.controller.js +1071 -0
- package/src/controllers/adminI18n.controller.js +604 -0
- package/src/controllers/adminJsonConfigs.controller.js +97 -0
- package/src/controllers/adminLlm.controller.js +273 -0
- package/src/controllers/adminMigration.controller.js +257 -0
- package/src/controllers/adminSeoConfig.controller.js +515 -0
- package/src/controllers/adminStats.controller.js +121 -0
- package/src/controllers/adminUploadNamespaces.controller.js +208 -0
- package/src/controllers/assets.controller.js +248 -0
- package/src/controllers/auth.controller.js +93 -0
- package/src/controllers/billing.controller.js +223 -0
- package/src/controllers/featureFlags.controller.js +35 -0
- package/src/controllers/forms.controller.js +217 -0
- package/src/controllers/globalSettings.controller.js +252 -0
- package/src/controllers/headlessCrud.controller.js +126 -0
- package/src/controllers/i18n.controller.js +12 -0
- package/src/controllers/invite.controller.js +249 -0
- package/src/controllers/jsonConfigs.controller.js +19 -0
- package/src/controllers/metrics.controller.js +149 -0
- package/src/controllers/notificationAdmin.controller.js +264 -0
- package/src/controllers/notifications.controller.js +131 -0
- package/src/controllers/org.controller.js +357 -0
- package/src/controllers/orgAdmin.controller.js +491 -0
- package/src/controllers/stripeAdmin.controller.js +410 -0
- package/src/controllers/user.controller.js +361 -0
- package/src/controllers/userAdmin.controller.js +277 -0
- package/src/controllers/waitingList.controller.js +167 -0
- package/src/controllers/webhook.controller.js +200 -0
- package/src/middleware/auth.js +66 -0
- package/src/middleware/errorCapture.js +170 -0
- package/src/middleware/headlessApiTokenAuth.js +57 -0
- package/src/middleware/org.js +108 -0
- package/src/middleware.js +901 -0
- package/src/models/ActionEvent.js +31 -0
- package/src/models/ActivityLog.js +41 -0
- package/src/models/Asset.js +84 -0
- package/src/models/AuditEvent.js +93 -0
- package/src/models/EmailLog.js +28 -0
- package/src/models/ErrorAggregate.js +72 -0
- package/src/models/FormSubmission.js +41 -0
- package/src/models/GlobalSetting.js +38 -0
- package/src/models/HeadlessApiToken.js +24 -0
- package/src/models/HeadlessModelDefinition.js +41 -0
- package/src/models/I18nEntry.js +77 -0
- package/src/models/I18nLocale.js +33 -0
- package/src/models/Invite.js +70 -0
- package/src/models/JsonConfig.js +46 -0
- package/src/models/Notification.js +60 -0
- package/src/models/Organization.js +57 -0
- package/src/models/OrganizationMember.js +43 -0
- package/src/models/StripeCatalogItem.js +77 -0
- package/src/models/StripeWebhookEvent.js +57 -0
- package/src/models/User.js +89 -0
- package/src/models/VirtualEjsFile.js +60 -0
- package/src/models/VirtualEjsFileVersion.js +43 -0
- package/src/models/VirtualEjsGroupChange.js +32 -0
- package/src/models/WaitingList.js +41 -0
- package/src/models/Webhook.js +63 -0
- package/src/models/Workflow.js +29 -0
- package/src/models/WorkflowExecution.js +12 -0
- package/src/routes/admin.routes.js +26 -0
- package/src/routes/adminAssets.routes.js +28 -0
- package/src/routes/adminAssetsStorage.routes.js +13 -0
- package/src/routes/adminAudit.routes.js +196 -0
- package/src/routes/adminEjsVirtual.routes.js +17 -0
- package/src/routes/adminErrors.routes.js +164 -0
- package/src/routes/adminFeatureFlags.routes.js +12 -0
- package/src/routes/adminHeadless.routes.js +38 -0
- package/src/routes/adminI18n.routes.js +22 -0
- package/src/routes/adminJsonConfigs.routes.js +15 -0
- package/src/routes/adminLlm.routes.js +12 -0
- package/src/routes/adminMigration.routes.js +81 -0
- package/src/routes/adminSeoConfig.routes.js +20 -0
- package/src/routes/adminUploadNamespaces.routes.js +13 -0
- package/src/routes/assets.routes.js +21 -0
- package/src/routes/auth.routes.js +12 -0
- package/src/routes/billing.routes.js +11 -0
- package/src/routes/errorTracking.routes.js +31 -0
- package/src/routes/featureFlags.routes.js +9 -0
- package/src/routes/forms.routes.js +9 -0
- package/src/routes/formsAdmin.routes.js +13 -0
- package/src/routes/globalSettings.routes.js +18 -0
- package/src/routes/headless.routes.js +15 -0
- package/src/routes/i18n.routes.js +8 -0
- package/src/routes/invite.routes.js +9 -0
- package/src/routes/jsonConfigs.routes.js +8 -0
- package/src/routes/log.routes.js +111 -0
- package/src/routes/metrics.routes.js +9 -0
- package/src/routes/notificationAdmin.routes.js +15 -0
- package/src/routes/notifications.routes.js +12 -0
- package/src/routes/org.routes.js +31 -0
- package/src/routes/orgAdmin.routes.js +20 -0
- package/src/routes/publicAssets.routes.js +7 -0
- package/src/routes/stripeAdmin.routes.js +20 -0
- package/src/routes/user.routes.js +22 -0
- package/src/routes/userAdmin.routes.js +15 -0
- package/src/routes/waitingList.routes.js +13 -0
- package/src/routes/waitingListAdmin.routes.js +9 -0
- package/src/routes/webhook.routes.js +32 -0
- package/src/routes/workflowWebhook.routes.js +54 -0
- package/src/routes/workflows.routes.js +110 -0
- package/src/services/assets.service.js +110 -0
- package/src/services/audit.service.js +62 -0
- package/src/services/auditLogger.js +165 -0
- package/src/services/ejsVirtual.service.js +614 -0
- package/src/services/email.service.js +351 -0
- package/src/services/errorLogger.js +221 -0
- package/src/services/featureFlags.service.js +202 -0
- package/src/services/forms.service.js +214 -0
- package/src/services/globalSettings.service.js +49 -0
- package/src/services/headlessApiTokens.service.js +158 -0
- package/src/services/headlessCrypto.service.js +31 -0
- package/src/services/headlessModels.service.js +356 -0
- package/src/services/i18n.service.js +314 -0
- package/src/services/i18nInferredKeys.service.js +337 -0
- package/src/services/jsonConfigs.service.js +392 -0
- package/src/services/llm.service.js +749 -0
- package/src/services/migration.service.js +581 -0
- package/src/services/migrationAssets/fsLocal.js +58 -0
- package/src/services/migrationAssets/index.js +134 -0
- package/src/services/migrationAssets/s3.js +75 -0
- package/src/services/migrationAssets/sftp.js +92 -0
- package/src/services/notification.service.js +212 -0
- package/src/services/objectStorage.service.js +514 -0
- package/src/services/seoConfig.service.js +402 -0
- package/src/services/storage.js +150 -0
- package/src/services/stripe.service.js +185 -0
- package/src/services/stripeHelper.service.js +264 -0
- package/src/services/uploadNamespaces.service.js +326 -0
- package/src/services/webhook.service.js +157 -0
- package/src/services/workflow.service.js +271 -0
- package/src/utils/asyncHandler.js +5 -0
- package/src/utils/encryption.js +80 -0
- package/src/utils/jwt.js +40 -0
- package/src/utils/orgRoles.js +156 -0
- package/src/utils/validation.js +26 -0
- package/src/utils/webhookRetry.js +93 -0
- package/views/admin-assets.ejs +444 -0
- package/views/admin-audit.ejs +283 -0
- package/views/admin-coolify-deploy.ejs +207 -0
- package/views/admin-dashboard-home.ejs +291 -0
- package/views/admin-dashboard.ejs +397 -0
- package/views/admin-ejs-virtual.ejs +280 -0
- package/views/admin-errors.ejs +368 -0
- package/views/admin-feature-flags.ejs +390 -0
- package/views/admin-forms.ejs +526 -0
- package/views/admin-global-settings.ejs +436 -0
- package/views/admin-headless.ejs +2020 -0
- package/views/admin-i18n-locales.ejs +221 -0
- package/views/admin-i18n.ejs +728 -0
- package/views/admin-json-configs.ejs +410 -0
- package/views/admin-llm.ejs +884 -0
- package/views/admin-metrics.ejs +274 -0
- package/views/admin-migration.ejs +814 -0
- package/views/admin-notifications.ejs +430 -0
- package/views/admin-organizations.ejs +984 -0
- package/views/admin-seo-config.ejs +673 -0
- package/views/admin-stripe-pricing.ejs +558 -0
- package/views/admin-test.ejs +342 -0
- package/views/admin-users.ejs +452 -0
- package/views/admin-waiting-list.ejs +547 -0
- package/views/admin-webhooks.ejs +329 -0
- package/views/admin-workflows.ejs +310 -0
- package/views/partials/admin-assets-script.ejs +2022 -0
- package/views/partials/admin-test-sidebar.ejs +14 -0
- package/views/partials/dashboard/nav-items.ejs +66 -0
- package/views/partials/dashboard/palette.ejs +63 -0
- package/views/partials/dashboard/sidebar.ejs +21 -0
- package/views/partials/dashboard/tab-bar.ejs +26 -0
- package/views/partials/footer.ejs +3 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const GlobalSetting = require('../models/GlobalSetting');
|
|
3
|
+
|
|
4
|
+
const FEATURE_FLAG_PREFIX = 'FEATURE_FLAG.';
|
|
5
|
+
|
|
6
|
+
const stripPrefix = (key) => {
|
|
7
|
+
if (!key) return key;
|
|
8
|
+
return key.startsWith(FEATURE_FLAG_PREFIX) ? key.slice(FEATURE_FLAG_PREFIX.length) : key;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const normalizeArray = (value) => {
|
|
12
|
+
if (!value) return [];
|
|
13
|
+
if (Array.isArray(value)) return value.map(String);
|
|
14
|
+
return [String(value)];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const normalizeDefinition = ({ key, raw }) => {
|
|
18
|
+
const description = raw?.description ? String(raw.description) : '';
|
|
19
|
+
const enabled = Boolean(raw?.enabled);
|
|
20
|
+
|
|
21
|
+
let rolloutPercentage = Number(raw?.rolloutPercentage ?? 0);
|
|
22
|
+
if (Number.isNaN(rolloutPercentage)) rolloutPercentage = 0;
|
|
23
|
+
rolloutPercentage = Math.max(0, Math.min(100, rolloutPercentage));
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
key,
|
|
27
|
+
description,
|
|
28
|
+
enabled,
|
|
29
|
+
rolloutPercentage,
|
|
30
|
+
allowListUserIds: normalizeArray(raw?.allowListUserIds),
|
|
31
|
+
allowListOrgIds: normalizeArray(raw?.allowListOrgIds),
|
|
32
|
+
denyListUserIds: normalizeArray(raw?.denyListUserIds),
|
|
33
|
+
denyListOrgIds: normalizeArray(raw?.denyListOrgIds),
|
|
34
|
+
payload: raw?.payload ?? null,
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const computeBucket = ({ flagKey, subjectId }) => {
|
|
39
|
+
const input = `${flagKey}:${subjectId}`;
|
|
40
|
+
const hash = crypto.createHash('sha256').update(input).digest('hex');
|
|
41
|
+
const int = parseInt(hash.slice(0, 8), 16);
|
|
42
|
+
return int % 100;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const evaluateDefinition = ({ def, userId, orgId, anonId }) => {
|
|
46
|
+
const userIdStr = userId ? String(userId) : null;
|
|
47
|
+
const orgIdStr = orgId ? String(orgId) : null;
|
|
48
|
+
const anonIdStr = anonId ? String(anonId) : null;
|
|
49
|
+
|
|
50
|
+
const deny =
|
|
51
|
+
(userIdStr && def.denyListUserIds.includes(userIdStr)) ||
|
|
52
|
+
(orgIdStr && def.denyListOrgIds.includes(orgIdStr));
|
|
53
|
+
if (deny) {
|
|
54
|
+
return { key: def.key, enabled: false };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const allow =
|
|
58
|
+
(userIdStr && def.allowListUserIds.includes(userIdStr)) ||
|
|
59
|
+
(orgIdStr && def.allowListOrgIds.includes(orgIdStr));
|
|
60
|
+
if (allow) {
|
|
61
|
+
return { key: def.key, enabled: true, payload: def.payload };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (def.enabled) {
|
|
65
|
+
return { key: def.key, enabled: true, payload: def.payload };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (def.rolloutPercentage > 0) {
|
|
69
|
+
const subjectId = orgIdStr || userIdStr || anonIdStr;
|
|
70
|
+
if (!subjectId) {
|
|
71
|
+
return { key: def.key, enabled: false };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const bucket = computeBucket({ flagKey: def.key, subjectId });
|
|
75
|
+
const enabled = bucket < def.rolloutPercentage;
|
|
76
|
+
return enabled
|
|
77
|
+
? { key: def.key, enabled: true, payload: def.payload }
|
|
78
|
+
: { key: def.key, enabled: false };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { key: def.key, enabled: false };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const loadAllDefinitions = async () => {
|
|
85
|
+
const settings = await GlobalSetting.find({
|
|
86
|
+
key: { $regex: `^${FEATURE_FLAG_PREFIX}` },
|
|
87
|
+
type: 'json',
|
|
88
|
+
})
|
|
89
|
+
.sort({ key: 1 })
|
|
90
|
+
.lean();
|
|
91
|
+
|
|
92
|
+
return settings
|
|
93
|
+
.map((s) => {
|
|
94
|
+
let raw;
|
|
95
|
+
try {
|
|
96
|
+
raw = s?.value ? JSON.parse(s.value) : {};
|
|
97
|
+
} catch {
|
|
98
|
+
raw = {};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const key = stripPrefix(s.key);
|
|
102
|
+
return normalizeDefinition({ key, raw: { ...raw, description: raw?.description ?? s.description } });
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const evaluateAllForRequest = async ({ userId, orgId, anonId }) => {
|
|
107
|
+
const defs = await loadAllDefinitions();
|
|
108
|
+
return defs.map((def) => evaluateDefinition({ def, userId, orgId, anonId }));
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const flagsArrayToMap = (flagsArray) => {
|
|
112
|
+
const out = {};
|
|
113
|
+
(flagsArray || []).forEach((f) => {
|
|
114
|
+
if (!f || !f.key) return;
|
|
115
|
+
out[f.key] = {
|
|
116
|
+
enabled: Boolean(f.enabled),
|
|
117
|
+
...(Object.prototype.hasOwnProperty.call(f, 'payload') ? { payload: f.payload } : {}),
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
return out;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const parseCookies = (cookieHeader) => {
|
|
124
|
+
const cookies = {};
|
|
125
|
+
if (!cookieHeader) return cookies;
|
|
126
|
+
|
|
127
|
+
cookieHeader.split(';').forEach((part) => {
|
|
128
|
+
const idx = part.indexOf('=');
|
|
129
|
+
if (idx === -1) return;
|
|
130
|
+
const k = part.slice(0, idx).trim();
|
|
131
|
+
const v = part.slice(idx + 1).trim();
|
|
132
|
+
if (!k) return;
|
|
133
|
+
cookies[k] = decodeURIComponent(v);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return cookies;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const ensureAnonIdCookie = (req, res) => {
|
|
140
|
+
const cookies = parseCookies(req.headers?.cookie);
|
|
141
|
+
const existing = cookies.saas_anon_id;
|
|
142
|
+
if (existing) return existing;
|
|
143
|
+
|
|
144
|
+
const generated = crypto.randomBytes(16).toString('hex');
|
|
145
|
+
if (res && typeof res.setHeader === 'function') {
|
|
146
|
+
const cookie = `saas_anon_id=${encodeURIComponent(generated)}; Path=/; Max-Age=31536000; SameSite=Lax`;
|
|
147
|
+
res.setHeader('Set-Cookie', cookie);
|
|
148
|
+
}
|
|
149
|
+
return generated;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
function createFeatureFlagsEjsMiddleware(opts = {}) {
|
|
153
|
+
const enabled = opts.enabled !== false;
|
|
154
|
+
const includeForApi = Boolean(opts.includeForApi);
|
|
155
|
+
|
|
156
|
+
return async (req, res, next) => {
|
|
157
|
+
try {
|
|
158
|
+
if (!enabled) return next();
|
|
159
|
+
|
|
160
|
+
const isApi = String(req.originalUrl || req.url || '').startsWith('/api/');
|
|
161
|
+
if (isApi && !includeForApi) return next();
|
|
162
|
+
|
|
163
|
+
const accept = String(req.headers?.accept || '');
|
|
164
|
+
const isHtml = accept.includes('text/html') || accept.includes('*/*') || !accept;
|
|
165
|
+
if (!isHtml) return next();
|
|
166
|
+
|
|
167
|
+
const orgId = req.query.orgId || req.headers['x-org-id'] || null;
|
|
168
|
+
const anonId =
|
|
169
|
+
req.query.anonId ||
|
|
170
|
+
req.headers['x-anon-id'] ||
|
|
171
|
+
ensureAnonIdCookie(req, res);
|
|
172
|
+
|
|
173
|
+
const flagsArray = await evaluateAllForRequest({ userId: null, orgId, anonId });
|
|
174
|
+
const flags = flagsArrayToMap(flagsArray);
|
|
175
|
+
|
|
176
|
+
res.locals.featureFlags = flags;
|
|
177
|
+
res.locals.ff = (key, defaultValue = false) => {
|
|
178
|
+
if (!key) return defaultValue;
|
|
179
|
+
const entry = flags[String(key)];
|
|
180
|
+
return typeof entry?.enabled === 'boolean' ? entry.enabled : defaultValue;
|
|
181
|
+
};
|
|
182
|
+
res.locals.ffPayload = (key, defaultValue = null) => {
|
|
183
|
+
if (!key) return defaultValue;
|
|
184
|
+
const entry = flags[String(key)];
|
|
185
|
+
return Object.prototype.hasOwnProperty.call(entry || {}, 'payload') ? entry.payload : defaultValue;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
next();
|
|
189
|
+
} catch (e) {
|
|
190
|
+
next(e);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
module.exports = {
|
|
196
|
+
FEATURE_FLAG_PREFIX,
|
|
197
|
+
stripPrefix,
|
|
198
|
+
loadAllDefinitions,
|
|
199
|
+
evaluateAllForRequest,
|
|
200
|
+
flagsArrayToMap,
|
|
201
|
+
createFeatureFlagsEjsMiddleware,
|
|
202
|
+
};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
const FormSubmission = require('../models/FormSubmission');
|
|
2
|
+
const JsonConfig = require('../models/JsonConfig');
|
|
3
|
+
const auditService = require('./audit.service');
|
|
4
|
+
const emailService = require('./email.service');
|
|
5
|
+
const webhookService = require('./webhook.service');
|
|
6
|
+
const jsonConfigsService = require('./jsonConfigs.service');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Forms Service
|
|
10
|
+
* Manages form definitions (stored in JsonConfigs) and submissions
|
|
11
|
+
*/
|
|
12
|
+
class FormsService {
|
|
13
|
+
/**
|
|
14
|
+
* Get all form definitions
|
|
15
|
+
*/
|
|
16
|
+
async getForms() {
|
|
17
|
+
const config = await JsonConfig.findOne({ slug: 'form-definitions' });
|
|
18
|
+
if (!config) return [];
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(config.jsonRaw || '[]');
|
|
21
|
+
} catch (e) {
|
|
22
|
+
console.error('[FormsService] Error parsing jsonRaw in getForms:', e);
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get a specific form definition by ID
|
|
29
|
+
*/
|
|
30
|
+
async getFormById(formId) {
|
|
31
|
+
const forms = await this.getForms();
|
|
32
|
+
return forms.find(f => f.id === formId);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Save a form definition
|
|
37
|
+
*/
|
|
38
|
+
async saveForm(formData) {
|
|
39
|
+
console.log('[FormsService] saveForm start', formData);
|
|
40
|
+
let config = await JsonConfig.findOne({ slug: 'form-definitions' });
|
|
41
|
+
|
|
42
|
+
let forms = [];
|
|
43
|
+
if (config) {
|
|
44
|
+
try {
|
|
45
|
+
forms = JSON.parse(config.jsonRaw || '[]');
|
|
46
|
+
} catch (e) {
|
|
47
|
+
console.error('[FormsService] Error parsing jsonRaw:', e);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const index = forms.findIndex(f => f.id === formData.id);
|
|
52
|
+
if (index >= 0) {
|
|
53
|
+
forms[index] = { ...forms[index], ...formData, updatedAt: new Date() };
|
|
54
|
+
} else {
|
|
55
|
+
forms.push({
|
|
56
|
+
...formData,
|
|
57
|
+
id: formData.id || `form_${Date.now()}`,
|
|
58
|
+
createdAt: new Date(),
|
|
59
|
+
updatedAt: new Date()
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const jsonRaw = JSON.stringify(forms);
|
|
64
|
+
|
|
65
|
+
if (config) {
|
|
66
|
+
await jsonConfigsService.updateJsonConfig(config._id, { jsonRaw });
|
|
67
|
+
} else {
|
|
68
|
+
await jsonConfigsService.createJsonConfig({
|
|
69
|
+
title: 'Form Definitions',
|
|
70
|
+
alias: 'form-definitions',
|
|
71
|
+
jsonRaw,
|
|
72
|
+
publicEnabled: false
|
|
73
|
+
});
|
|
74
|
+
// Note: createJsonConfig generates a unique slug based on title,
|
|
75
|
+
// but we want 'form-definitions' as the lookup key (slug or alias).
|
|
76
|
+
// Since createJsonConfig handles unique slug generation, we set alias to 'form-definitions'.
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
console.log('[FormsService] Form definition saved');
|
|
80
|
+
return formData;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Delete a form definition
|
|
85
|
+
*/
|
|
86
|
+
async deleteForm(formId) {
|
|
87
|
+
const config = await JsonConfig.findOne({ slug: 'form-definitions' });
|
|
88
|
+
if (config) {
|
|
89
|
+
try {
|
|
90
|
+
let forms = JSON.parse(config.jsonRaw || '[]');
|
|
91
|
+
forms = forms.filter(f => f.id !== formId);
|
|
92
|
+
config.jsonRaw = JSON.stringify(forms);
|
|
93
|
+
await config.save();
|
|
94
|
+
} catch (e) {
|
|
95
|
+
console.error('[FormsService] Error during deleteForm:', e);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Handle form submission
|
|
102
|
+
*/
|
|
103
|
+
async submitForm(formId, fields, meta = {}) {
|
|
104
|
+
const formConfig = await this.getFormById(formId);
|
|
105
|
+
if (!formConfig) {
|
|
106
|
+
throw new Error('Form not found');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const submission = new FormSubmission({
|
|
110
|
+
formKey: formId,
|
|
111
|
+
actorType: meta.actorType || 'anonymous',
|
|
112
|
+
actorId: meta.actorId || 'guest',
|
|
113
|
+
userId: meta.userId || null,
|
|
114
|
+
fields: fields,
|
|
115
|
+
meta: {
|
|
116
|
+
ip: meta.ip,
|
|
117
|
+
userAgent: meta.userAgent,
|
|
118
|
+
referer: meta.referer,
|
|
119
|
+
organizationId: meta.organizationId || meta.orgId || formConfig.organizationId
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await submission.save();
|
|
124
|
+
|
|
125
|
+
// Trigger Generic Webhooks
|
|
126
|
+
const orgId = submission.meta.organizationId;
|
|
127
|
+
if (orgId) {
|
|
128
|
+
webhookService.emit('form.submitted', {
|
|
129
|
+
submissionId: submission._id,
|
|
130
|
+
formId,
|
|
131
|
+
fields,
|
|
132
|
+
meta: submission.meta
|
|
133
|
+
}, orgId);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Trigger Legacy per-form Webhook
|
|
137
|
+
if (formConfig.webhookUrl) {
|
|
138
|
+
this.triggerWebhook(formConfig.webhookUrl, submission);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Trigger Email Notification
|
|
142
|
+
if (formConfig.notifyEmail) {
|
|
143
|
+
this.sendNotification(formConfig.notifyEmail, formConfig.name, fields);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Audit Log
|
|
147
|
+
await auditService.createAuditEvent({
|
|
148
|
+
action: 'FORM_SUBMISSION',
|
|
149
|
+
entityType: 'FormSubmission',
|
|
150
|
+
entityId: submission._id,
|
|
151
|
+
actorType: submission.actorType,
|
|
152
|
+
actorId: submission.actorId,
|
|
153
|
+
meta: {
|
|
154
|
+
formId,
|
|
155
|
+
email: fields.email,
|
|
156
|
+
organizationId: orgId
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
return submission;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Get submissions for a form
|
|
165
|
+
*/
|
|
166
|
+
async getSubmissions(query = {}, options = {}) {
|
|
167
|
+
const { limit = 50, offset = 0 } = options;
|
|
168
|
+
const filter = {};
|
|
169
|
+
if (query.formId) filter.formKey = query.formId;
|
|
170
|
+
|
|
171
|
+
const entries = await FormSubmission.find(filter)
|
|
172
|
+
.sort({ createdAt: -1 })
|
|
173
|
+
.limit(limit)
|
|
174
|
+
.skip(offset);
|
|
175
|
+
|
|
176
|
+
const total = await FormSubmission.countDocuments(filter);
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
entries,
|
|
180
|
+
pagination: { total, limit, offset }
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Trigger external webhook (async)
|
|
186
|
+
*/
|
|
187
|
+
triggerWebhook(url, data) {
|
|
188
|
+
fetch(url, {
|
|
189
|
+
method: 'POST',
|
|
190
|
+
headers: { 'Content-Type': 'application/json' },
|
|
191
|
+
body: JSON.stringify(data)
|
|
192
|
+
}).catch(err => console.error(`[FormsService] Webhook failed for ${url}:`, err.message));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Send email notification (async)
|
|
197
|
+
*/
|
|
198
|
+
sendNotification(to, formName, data) {
|
|
199
|
+
emailService.sendEmail({
|
|
200
|
+
to,
|
|
201
|
+
subject: `New Submission: ${formName}`,
|
|
202
|
+
text: `New form submission received for ${formName}.\n\nData:\n${JSON.stringify(data, null, 2)}`
|
|
203
|
+
}).catch(err => console.error(`[FormsService] Email notification failed:`, err.message));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Delete a form submission
|
|
208
|
+
*/
|
|
209
|
+
async deleteSubmission(submissionId) {
|
|
210
|
+
await FormSubmission.findByIdAndDelete(submissionId);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
module.exports = new FormsService();
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const GlobalSetting = require('../models/GlobalSetting');
|
|
2
|
+
const { decryptString } = require('../utils/encryption');
|
|
3
|
+
|
|
4
|
+
const settingsCache = new Map();
|
|
5
|
+
const CACHE_TTL = 60000;
|
|
6
|
+
|
|
7
|
+
async function getSettingValue(key, defaultValue = null) {
|
|
8
|
+
const cached = settingsCache.get(key);
|
|
9
|
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
10
|
+
return cached.value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const setting = await GlobalSetting.findOne({ key }).lean();
|
|
15
|
+
if (!setting) {
|
|
16
|
+
settingsCache.set(key, { value: defaultValue, timestamp: Date.now() });
|
|
17
|
+
return defaultValue;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let value;
|
|
21
|
+
if (setting.type === 'encrypted') {
|
|
22
|
+
try {
|
|
23
|
+
const payload = JSON.parse(setting.value);
|
|
24
|
+
value = decryptString(payload);
|
|
25
|
+
} catch (e) {
|
|
26
|
+
console.error(`Error decrypting setting ${key}:`, e);
|
|
27
|
+
value = defaultValue;
|
|
28
|
+
}
|
|
29
|
+
} else {
|
|
30
|
+
value = setting.value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
settingsCache.set(key, { value, timestamp: Date.now() });
|
|
34
|
+
return value;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.error(`Error fetching setting ${key}:`, error);
|
|
37
|
+
settingsCache.set(key, { value: defaultValue, timestamp: Date.now() });
|
|
38
|
+
return defaultValue;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function clearSettingsCache() {
|
|
43
|
+
settingsCache.clear();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = {
|
|
47
|
+
getSettingValue,
|
|
48
|
+
clearSettingsCache,
|
|
49
|
+
};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
const HeadlessApiToken = require('../models/HeadlessApiToken');
|
|
2
|
+
const { generateApiTokenPlaintext, hashToken, timingSafeEqualHex } = require('./headlessCrypto.service');
|
|
3
|
+
|
|
4
|
+
const VALID_OPERATIONS = new Set(['create', 'read', 'update', 'delete']);
|
|
5
|
+
|
|
6
|
+
function normalizeOperations(ops) {
|
|
7
|
+
const list = Array.isArray(ops) ? ops : [];
|
|
8
|
+
const normalized = list
|
|
9
|
+
.map((o) => String(o || '').trim().toLowerCase())
|
|
10
|
+
.filter((o) => o);
|
|
11
|
+
|
|
12
|
+
for (const op of normalized) {
|
|
13
|
+
if (!VALID_OPERATIONS.has(op)) {
|
|
14
|
+
const err = new Error(`Invalid operation: ${op}`);
|
|
15
|
+
err.code = 'VALIDATION';
|
|
16
|
+
throw err;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return Array.from(new Set(normalized));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizePermissions(perms) {
|
|
24
|
+
const list = Array.isArray(perms) ? perms : [];
|
|
25
|
+
return list
|
|
26
|
+
.map((p) => {
|
|
27
|
+
const modelCode = String(p?.modelCode || '').trim();
|
|
28
|
+
const operations = normalizeOperations(p?.operations);
|
|
29
|
+
return { modelCode, operations };
|
|
30
|
+
})
|
|
31
|
+
.filter((p) => p.modelCode);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function createApiToken({ name, permissions, ttlSeconds }) {
|
|
35
|
+
const normalizedName = String(name || '').trim();
|
|
36
|
+
if (!normalizedName) {
|
|
37
|
+
const err = new Error('name is required');
|
|
38
|
+
err.code = 'VALIDATION';
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const plaintext = generateApiTokenPlaintext();
|
|
43
|
+
const tokenHash = hashToken(plaintext);
|
|
44
|
+
const expiresAt = ttlSeconds ? new Date(Date.now() + Number(ttlSeconds) * 1000) : null;
|
|
45
|
+
|
|
46
|
+
const doc = await HeadlessApiToken.create({
|
|
47
|
+
name: normalizedName,
|
|
48
|
+
tokenHash,
|
|
49
|
+
permissions: normalizePermissions(permissions),
|
|
50
|
+
expiresAt,
|
|
51
|
+
isActive: true,
|
|
52
|
+
lastUsedAt: null,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return { token: plaintext, item: doc.toObject() };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function listApiTokens() {
|
|
59
|
+
return HeadlessApiToken.find({}).sort({ createdAt: -1 }).lean();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function getApiTokenById(id) {
|
|
63
|
+
return HeadlessApiToken.findById(id).lean();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function updateApiToken(id, updates) {
|
|
67
|
+
const doc = await HeadlessApiToken.findById(id);
|
|
68
|
+
if (!doc) {
|
|
69
|
+
const err = new Error('API token not found');
|
|
70
|
+
err.code = 'NOT_FOUND';
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (updates.name !== undefined) {
|
|
75
|
+
const normalizedName = String(updates.name || '').trim();
|
|
76
|
+
if (!normalizedName) {
|
|
77
|
+
const err = new Error('name is required');
|
|
78
|
+
err.code = 'VALIDATION';
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
doc.name = normalizedName;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (updates.permissions !== undefined) {
|
|
85
|
+
doc.permissions = normalizePermissions(updates.permissions);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (updates.isActive !== undefined) {
|
|
89
|
+
doc.isActive = Boolean(updates.isActive);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (updates.ttlSeconds !== undefined) {
|
|
93
|
+
const ttlSeconds = updates.ttlSeconds;
|
|
94
|
+
doc.expiresAt = ttlSeconds ? new Date(Date.now() + Number(ttlSeconds) * 1000) : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await doc.save();
|
|
98
|
+
return doc.toObject();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function deleteApiToken(id) {
|
|
102
|
+
const doc = await HeadlessApiToken.findById(id);
|
|
103
|
+
if (!doc) {
|
|
104
|
+
const err = new Error('API token not found');
|
|
105
|
+
err.code = 'NOT_FOUND';
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
await HeadlessApiToken.deleteOne({ _id: id });
|
|
109
|
+
return { success: true };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function authenticateApiToken(plaintextToken) {
|
|
113
|
+
const provided = String(plaintextToken || '').trim();
|
|
114
|
+
if (!provided) return null;
|
|
115
|
+
|
|
116
|
+
const tokenHash = hashToken(provided);
|
|
117
|
+
|
|
118
|
+
const candidates = await HeadlessApiToken.find({ isActive: true }).select(
|
|
119
|
+
'tokenHash permissions expiresAt isActive lastUsedAt',
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const match = candidates.find((c) => timingSafeEqualHex(c.tokenHash, tokenHash));
|
|
123
|
+
if (!match) return null;
|
|
124
|
+
|
|
125
|
+
if (match.expiresAt && new Date(match.expiresAt).getTime() <= Date.now()) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
match.lastUsedAt = new Date();
|
|
130
|
+
await match.save();
|
|
131
|
+
|
|
132
|
+
return match.toObject();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function tokenAllowsOperation(tokenDoc, modelCode, operation) {
|
|
136
|
+
if (!tokenDoc || !tokenDoc.permissions) return false;
|
|
137
|
+
const op = String(operation || '').trim().toLowerCase();
|
|
138
|
+
if (!VALID_OPERATIONS.has(op)) return false;
|
|
139
|
+
|
|
140
|
+
const code = String(modelCode || '').trim();
|
|
141
|
+
if (!code) return false;
|
|
142
|
+
|
|
143
|
+
const perm = (tokenDoc.permissions || []).find((p) => p.modelCode === code);
|
|
144
|
+
if (!perm) return false;
|
|
145
|
+
|
|
146
|
+
return Array.isArray(perm.operations) && perm.operations.includes(op);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = {
|
|
150
|
+
VALID_OPERATIONS,
|
|
151
|
+
createApiToken,
|
|
152
|
+
listApiTokens,
|
|
153
|
+
getApiTokenById,
|
|
154
|
+
updateApiToken,
|
|
155
|
+
deleteApiToken,
|
|
156
|
+
authenticateApiToken,
|
|
157
|
+
tokenAllowsOperation,
|
|
158
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
|
|
3
|
+
function base64UrlEncode(buf) {
|
|
4
|
+
return buf
|
|
5
|
+
.toString('base64')
|
|
6
|
+
.replace(/\+/g, '-')
|
|
7
|
+
.replace(/\//g, '_')
|
|
8
|
+
.replace(/=+$/g, '');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function generateApiTokenPlaintext() {
|
|
12
|
+
const raw = crypto.randomBytes(32);
|
|
13
|
+
return `hcms_${base64UrlEncode(raw)}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function hashToken(plaintext) {
|
|
17
|
+
return crypto.createHash('sha256').update(String(plaintext)).digest('hex');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function timingSafeEqualHex(a, b) {
|
|
21
|
+
const aBuf = Buffer.from(String(a), 'hex');
|
|
22
|
+
const bBuf = Buffer.from(String(b), 'hex');
|
|
23
|
+
if (aBuf.length !== bBuf.length) return false;
|
|
24
|
+
return crypto.timingSafeEqual(aBuf, bBuf);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = {
|
|
28
|
+
generateApiTokenPlaintext,
|
|
29
|
+
hashToken,
|
|
30
|
+
timingSafeEqualHex,
|
|
31
|
+
};
|