@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.
Files changed (188) hide show
  1. package/.commiat +4 -0
  2. package/.env.example +47 -0
  3. package/README.md +110 -0
  4. package/index.js +94 -0
  5. package/package.json +67 -0
  6. package/public/css/styles.css +139 -0
  7. package/public/js/animations.js +41 -0
  8. package/sdk/error-tracking/browser/package.json +16 -0
  9. package/sdk/error-tracking/browser/src/core.js +270 -0
  10. package/sdk/error-tracking/browser/src/embed.js +18 -0
  11. package/sdk/error-tracking/browser/src/index.js +1 -0
  12. package/server.js +5 -0
  13. package/src/admin/endpointRegistry.js +300 -0
  14. package/src/controllers/admin.controller.js +321 -0
  15. package/src/controllers/adminAssets.controller.js +530 -0
  16. package/src/controllers/adminAssetsStorage.controller.js +260 -0
  17. package/src/controllers/adminEjsVirtual.controller.js +354 -0
  18. package/src/controllers/adminFeatureFlags.controller.js +155 -0
  19. package/src/controllers/adminHeadless.controller.js +1071 -0
  20. package/src/controllers/adminI18n.controller.js +604 -0
  21. package/src/controllers/adminJsonConfigs.controller.js +97 -0
  22. package/src/controllers/adminLlm.controller.js +273 -0
  23. package/src/controllers/adminMigration.controller.js +257 -0
  24. package/src/controllers/adminSeoConfig.controller.js +515 -0
  25. package/src/controllers/adminStats.controller.js +121 -0
  26. package/src/controllers/adminUploadNamespaces.controller.js +208 -0
  27. package/src/controllers/assets.controller.js +248 -0
  28. package/src/controllers/auth.controller.js +93 -0
  29. package/src/controllers/billing.controller.js +223 -0
  30. package/src/controllers/featureFlags.controller.js +35 -0
  31. package/src/controllers/forms.controller.js +217 -0
  32. package/src/controllers/globalSettings.controller.js +252 -0
  33. package/src/controllers/headlessCrud.controller.js +126 -0
  34. package/src/controllers/i18n.controller.js +12 -0
  35. package/src/controllers/invite.controller.js +249 -0
  36. package/src/controllers/jsonConfigs.controller.js +19 -0
  37. package/src/controllers/metrics.controller.js +149 -0
  38. package/src/controllers/notificationAdmin.controller.js +264 -0
  39. package/src/controllers/notifications.controller.js +131 -0
  40. package/src/controllers/org.controller.js +357 -0
  41. package/src/controllers/orgAdmin.controller.js +491 -0
  42. package/src/controllers/stripeAdmin.controller.js +410 -0
  43. package/src/controllers/user.controller.js +361 -0
  44. package/src/controllers/userAdmin.controller.js +277 -0
  45. package/src/controllers/waitingList.controller.js +167 -0
  46. package/src/controllers/webhook.controller.js +200 -0
  47. package/src/middleware/auth.js +66 -0
  48. package/src/middleware/errorCapture.js +170 -0
  49. package/src/middleware/headlessApiTokenAuth.js +57 -0
  50. package/src/middleware/org.js +108 -0
  51. package/src/middleware.js +901 -0
  52. package/src/models/ActionEvent.js +31 -0
  53. package/src/models/ActivityLog.js +41 -0
  54. package/src/models/Asset.js +84 -0
  55. package/src/models/AuditEvent.js +93 -0
  56. package/src/models/EmailLog.js +28 -0
  57. package/src/models/ErrorAggregate.js +72 -0
  58. package/src/models/FormSubmission.js +41 -0
  59. package/src/models/GlobalSetting.js +38 -0
  60. package/src/models/HeadlessApiToken.js +24 -0
  61. package/src/models/HeadlessModelDefinition.js +41 -0
  62. package/src/models/I18nEntry.js +77 -0
  63. package/src/models/I18nLocale.js +33 -0
  64. package/src/models/Invite.js +70 -0
  65. package/src/models/JsonConfig.js +46 -0
  66. package/src/models/Notification.js +60 -0
  67. package/src/models/Organization.js +57 -0
  68. package/src/models/OrganizationMember.js +43 -0
  69. package/src/models/StripeCatalogItem.js +77 -0
  70. package/src/models/StripeWebhookEvent.js +57 -0
  71. package/src/models/User.js +89 -0
  72. package/src/models/VirtualEjsFile.js +60 -0
  73. package/src/models/VirtualEjsFileVersion.js +43 -0
  74. package/src/models/VirtualEjsGroupChange.js +32 -0
  75. package/src/models/WaitingList.js +41 -0
  76. package/src/models/Webhook.js +63 -0
  77. package/src/models/Workflow.js +29 -0
  78. package/src/models/WorkflowExecution.js +12 -0
  79. package/src/routes/admin.routes.js +26 -0
  80. package/src/routes/adminAssets.routes.js +28 -0
  81. package/src/routes/adminAssetsStorage.routes.js +13 -0
  82. package/src/routes/adminAudit.routes.js +196 -0
  83. package/src/routes/adminEjsVirtual.routes.js +17 -0
  84. package/src/routes/adminErrors.routes.js +164 -0
  85. package/src/routes/adminFeatureFlags.routes.js +12 -0
  86. package/src/routes/adminHeadless.routes.js +38 -0
  87. package/src/routes/adminI18n.routes.js +22 -0
  88. package/src/routes/adminJsonConfigs.routes.js +15 -0
  89. package/src/routes/adminLlm.routes.js +12 -0
  90. package/src/routes/adminMigration.routes.js +81 -0
  91. package/src/routes/adminSeoConfig.routes.js +20 -0
  92. package/src/routes/adminUploadNamespaces.routes.js +13 -0
  93. package/src/routes/assets.routes.js +21 -0
  94. package/src/routes/auth.routes.js +12 -0
  95. package/src/routes/billing.routes.js +11 -0
  96. package/src/routes/errorTracking.routes.js +31 -0
  97. package/src/routes/featureFlags.routes.js +9 -0
  98. package/src/routes/forms.routes.js +9 -0
  99. package/src/routes/formsAdmin.routes.js +13 -0
  100. package/src/routes/globalSettings.routes.js +18 -0
  101. package/src/routes/headless.routes.js +15 -0
  102. package/src/routes/i18n.routes.js +8 -0
  103. package/src/routes/invite.routes.js +9 -0
  104. package/src/routes/jsonConfigs.routes.js +8 -0
  105. package/src/routes/log.routes.js +111 -0
  106. package/src/routes/metrics.routes.js +9 -0
  107. package/src/routes/notificationAdmin.routes.js +15 -0
  108. package/src/routes/notifications.routes.js +12 -0
  109. package/src/routes/org.routes.js +31 -0
  110. package/src/routes/orgAdmin.routes.js +20 -0
  111. package/src/routes/publicAssets.routes.js +7 -0
  112. package/src/routes/stripeAdmin.routes.js +20 -0
  113. package/src/routes/user.routes.js +22 -0
  114. package/src/routes/userAdmin.routes.js +15 -0
  115. package/src/routes/waitingList.routes.js +13 -0
  116. package/src/routes/waitingListAdmin.routes.js +9 -0
  117. package/src/routes/webhook.routes.js +32 -0
  118. package/src/routes/workflowWebhook.routes.js +54 -0
  119. package/src/routes/workflows.routes.js +110 -0
  120. package/src/services/assets.service.js +110 -0
  121. package/src/services/audit.service.js +62 -0
  122. package/src/services/auditLogger.js +165 -0
  123. package/src/services/ejsVirtual.service.js +614 -0
  124. package/src/services/email.service.js +351 -0
  125. package/src/services/errorLogger.js +221 -0
  126. package/src/services/featureFlags.service.js +202 -0
  127. package/src/services/forms.service.js +214 -0
  128. package/src/services/globalSettings.service.js +49 -0
  129. package/src/services/headlessApiTokens.service.js +158 -0
  130. package/src/services/headlessCrypto.service.js +31 -0
  131. package/src/services/headlessModels.service.js +356 -0
  132. package/src/services/i18n.service.js +314 -0
  133. package/src/services/i18nInferredKeys.service.js +337 -0
  134. package/src/services/jsonConfigs.service.js +392 -0
  135. package/src/services/llm.service.js +749 -0
  136. package/src/services/migration.service.js +581 -0
  137. package/src/services/migrationAssets/fsLocal.js +58 -0
  138. package/src/services/migrationAssets/index.js +134 -0
  139. package/src/services/migrationAssets/s3.js +75 -0
  140. package/src/services/migrationAssets/sftp.js +92 -0
  141. package/src/services/notification.service.js +212 -0
  142. package/src/services/objectStorage.service.js +514 -0
  143. package/src/services/seoConfig.service.js +402 -0
  144. package/src/services/storage.js +150 -0
  145. package/src/services/stripe.service.js +185 -0
  146. package/src/services/stripeHelper.service.js +264 -0
  147. package/src/services/uploadNamespaces.service.js +326 -0
  148. package/src/services/webhook.service.js +157 -0
  149. package/src/services/workflow.service.js +271 -0
  150. package/src/utils/asyncHandler.js +5 -0
  151. package/src/utils/encryption.js +80 -0
  152. package/src/utils/jwt.js +40 -0
  153. package/src/utils/orgRoles.js +156 -0
  154. package/src/utils/validation.js +26 -0
  155. package/src/utils/webhookRetry.js +93 -0
  156. package/views/admin-assets.ejs +444 -0
  157. package/views/admin-audit.ejs +283 -0
  158. package/views/admin-coolify-deploy.ejs +207 -0
  159. package/views/admin-dashboard-home.ejs +291 -0
  160. package/views/admin-dashboard.ejs +397 -0
  161. package/views/admin-ejs-virtual.ejs +280 -0
  162. package/views/admin-errors.ejs +368 -0
  163. package/views/admin-feature-flags.ejs +390 -0
  164. package/views/admin-forms.ejs +526 -0
  165. package/views/admin-global-settings.ejs +436 -0
  166. package/views/admin-headless.ejs +2020 -0
  167. package/views/admin-i18n-locales.ejs +221 -0
  168. package/views/admin-i18n.ejs +728 -0
  169. package/views/admin-json-configs.ejs +410 -0
  170. package/views/admin-llm.ejs +884 -0
  171. package/views/admin-metrics.ejs +274 -0
  172. package/views/admin-migration.ejs +814 -0
  173. package/views/admin-notifications.ejs +430 -0
  174. package/views/admin-organizations.ejs +984 -0
  175. package/views/admin-seo-config.ejs +673 -0
  176. package/views/admin-stripe-pricing.ejs +558 -0
  177. package/views/admin-test.ejs +342 -0
  178. package/views/admin-users.ejs +452 -0
  179. package/views/admin-waiting-list.ejs +547 -0
  180. package/views/admin-webhooks.ejs +329 -0
  181. package/views/admin-workflows.ejs +310 -0
  182. package/views/partials/admin-assets-script.ejs +2022 -0
  183. package/views/partials/admin-test-sidebar.ejs +14 -0
  184. package/views/partials/dashboard/nav-items.ejs +66 -0
  185. package/views/partials/dashboard/palette.ejs +63 -0
  186. package/views/partials/dashboard/sidebar.ejs +21 -0
  187. package/views/partials/dashboard/tab-bar.ejs +26 -0
  188. package/views/partials/footer.ejs +3 -0
@@ -0,0 +1,402 @@
1
+ const fs = require('fs');
2
+ const os = require('os');
3
+ const path = require('path');
4
+ const { execFile } = require('child_process');
5
+
6
+ const JsonConfig = require('../models/JsonConfig');
7
+ const GlobalSetting = require('../models/GlobalSetting');
8
+
9
+ const { parseJsonOrThrow } = require('./jsonConfigs.service');
10
+ const globalSettingsService = require('./globalSettings.service');
11
+
12
+ const SEO_CONFIG_SLUG = 'seo-config';
13
+ const DEFAULT_OG_PNG_OUTPUT_PATH = 'public/og/og-default.png';
14
+ const DEFAULT_OG_PNG_WIDTH = 1200;
15
+ const DEFAULT_OG_PNG_HEIGHT = 630;
16
+
17
+ const OG_SVG_SETTING_KEY = 'seoconfig.og.svg';
18
+
19
+ async function ensureSeoJsonConfigExists() {
20
+ const existing = await JsonConfig.findOne({ slug: SEO_CONFIG_SLUG });
21
+ if (existing) return existing;
22
+
23
+ const defaultConfig = {
24
+ siteName: '',
25
+ baseUrl: '',
26
+ defaultOgImagePath: '/og/og-default.png',
27
+ defaultTwitterCard: 'summary_large_image',
28
+ defaultRobots: 'index,follow',
29
+ pages: {},
30
+ };
31
+
32
+ return JsonConfig.create({
33
+ title: 'SEO Config',
34
+ slug: SEO_CONFIG_SLUG,
35
+ publicEnabled: false,
36
+ cacheTtlSeconds: 0,
37
+ jsonRaw: JSON.stringify(defaultConfig, null, 2),
38
+ jsonHash: null,
39
+ });
40
+ }
41
+
42
+ async function getSeoJsonConfig() {
43
+ const doc = await ensureSeoJsonConfigExists();
44
+ return doc.toObject ? doc.toObject() : doc;
45
+ }
46
+
47
+ async function getSeoConfigData() {
48
+ const doc = await ensureSeoJsonConfigExists();
49
+ const jsonRaw = String(doc.jsonRaw || '');
50
+ const data = parseJsonOrThrow(jsonRaw);
51
+ return { doc, data };
52
+ }
53
+
54
+ async function updateSeoJsonConfig(patch) {
55
+ const doc = await ensureSeoJsonConfigExists();
56
+
57
+ if (patch && Object.prototype.hasOwnProperty.call(patch, 'publicEnabled')) {
58
+ doc.publicEnabled = Boolean(patch.publicEnabled);
59
+ }
60
+
61
+ if (patch && Object.prototype.hasOwnProperty.call(patch, 'cacheTtlSeconds')) {
62
+ const ttl = Number(patch.cacheTtlSeconds || 0);
63
+ doc.cacheTtlSeconds = Number.isNaN(ttl) ? 0 : Math.max(0, ttl);
64
+ }
65
+
66
+ if (patch && Object.prototype.hasOwnProperty.call(patch, 'jsonRaw')) {
67
+ if (patch.jsonRaw === null || patch.jsonRaw === undefined) {
68
+ const err = new Error('jsonRaw is required');
69
+ err.code = 'VALIDATION';
70
+ throw err;
71
+ }
72
+
73
+ parseJsonOrThrow(patch.jsonRaw);
74
+ doc.jsonRaw = String(patch.jsonRaw);
75
+ }
76
+
77
+ await doc.save();
78
+ return doc.toObject();
79
+ }
80
+
81
+ async function applySeoPageEntry({ routePath, entry }) {
82
+ const route = String(routePath || '').trim();
83
+ if (!route || !route.startsWith('/')) {
84
+ const err = new Error('routePath must start with /');
85
+ err.code = 'VALIDATION';
86
+ throw err;
87
+ }
88
+
89
+ const e = entry && typeof entry === 'object' ? entry : null;
90
+ if (!e) {
91
+ const err = new Error('entry is required');
92
+ err.code = 'VALIDATION';
93
+ throw err;
94
+ }
95
+
96
+ const title = e.title !== undefined ? String(e.title || '') : '';
97
+ const description = e.description !== undefined ? String(e.description || '') : '';
98
+ const robots = e.robots !== undefined && e.robots !== null ? String(e.robots) : undefined;
99
+
100
+ if (!title.trim()) {
101
+ const err = new Error('entry.title is required');
102
+ err.code = 'VALIDATION';
103
+ throw err;
104
+ }
105
+ if (!description.trim()) {
106
+ const err = new Error('entry.description is required');
107
+ err.code = 'VALIDATION';
108
+ throw err;
109
+ }
110
+
111
+ const { doc, data } = await getSeoConfigData();
112
+
113
+ if (!data.pages || typeof data.pages !== 'object' || Array.isArray(data.pages)) {
114
+ data.pages = {};
115
+ }
116
+
117
+ const next = { title: title.trim(), description: description.trim() };
118
+ if (robots !== undefined) next.robots = robots;
119
+
120
+ data.pages[route] = next;
121
+ doc.jsonRaw = JSON.stringify(data, null, 2);
122
+ await doc.save();
123
+ return { routePath: route, entry: next, jsonRaw: doc.jsonRaw };
124
+ }
125
+
126
+ async function getOgSvgSettingRaw() {
127
+ const svg = await globalSettingsService.getSettingValue(OG_SVG_SETTING_KEY, '');
128
+ return typeof svg === 'string' ? svg : String(svg || '');
129
+ }
130
+
131
+ async function setOgSvgSettingRaw(svgRaw) {
132
+ const value = String(svgRaw || '');
133
+
134
+ const existing = await GlobalSetting.findOne({ key: OG_SVG_SETTING_KEY });
135
+ if (!existing) {
136
+ await GlobalSetting.create({
137
+ key: OG_SVG_SETTING_KEY,
138
+ value,
139
+ type: 'html',
140
+ description: 'Default OG image SVG (for SEO Config)',
141
+ templateVariables: [],
142
+ public: false,
143
+ });
144
+ globalSettingsService.clearSettingsCache();
145
+ return { created: true };
146
+ }
147
+
148
+ existing.value = value;
149
+ if (!existing.type) existing.type = 'html';
150
+ await existing.save();
151
+ globalSettingsService.clearSettingsCache();
152
+ return { created: false };
153
+ }
154
+
155
+ function ensurePublicOutputPathOrThrow(outputPath) {
156
+ const raw = String(outputPath || '').trim();
157
+ if (!raw) {
158
+ const err = new Error('outputPath is required');
159
+ err.code = 'VALIDATION';
160
+ throw err;
161
+ }
162
+
163
+ const normalized = raw.replace(/\\/g, '/');
164
+ if (!normalized.startsWith('public/')) {
165
+ const err = new Error('outputPath must be under public/');
166
+ err.code = 'VALIDATION';
167
+ throw err;
168
+ }
169
+
170
+ const resolved = path.resolve(process.cwd(), normalized);
171
+ const publicRoot = path.resolve(process.cwd(), 'public');
172
+ if (!resolved.startsWith(publicRoot + path.sep) && resolved !== publicRoot) {
173
+ const err = new Error('Invalid outputPath');
174
+ err.code = 'VALIDATION';
175
+ throw err;
176
+ }
177
+
178
+ return { normalized, resolved, publicRoot };
179
+ }
180
+
181
+ function writeTempFile(prefix, ext, contents) {
182
+ const filePath = path.join(os.tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}.${ext}`);
183
+ fs.writeFileSync(filePath, contents, 'utf8');
184
+ return filePath;
185
+ }
186
+
187
+ function execFilePromise(cmd, args, opts) {
188
+ return new Promise((resolve, reject) => {
189
+ execFile(cmd, args, opts || {}, (err, stdout, stderr) => {
190
+ if (err) {
191
+ err.stdout = stdout;
192
+ err.stderr = stderr;
193
+ reject(err);
194
+ return;
195
+ }
196
+ resolve({ stdout, stderr });
197
+ });
198
+ });
199
+ }
200
+
201
+ async function tryChromeScreenshot({ htmlFilePath, tmpOutPath, width, height }) {
202
+ const candidates = [
203
+ { cmd: 'google-chrome', args: ['--headless=new'] },
204
+ { cmd: 'chromium', args: ['--headless'] },
205
+ { cmd: 'chromium-browser', args: ['--headless'] },
206
+ ];
207
+
208
+ for (const c of candidates) {
209
+ try {
210
+ await execFilePromise(
211
+ c.cmd,
212
+ [
213
+ ...c.args,
214
+ '--disable-gpu',
215
+ '--hide-scrollbars',
216
+ `--window-size=${width},${height}`,
217
+ `--screenshot=${tmpOutPath}`,
218
+ `file://${htmlFilePath}`,
219
+ ],
220
+ { timeout: 30000 },
221
+ );
222
+
223
+ return { ok: true, tool: c.cmd };
224
+ } catch (e) {
225
+ if (e && (e.code === 'ENOENT' || e.errno === -2)) {
226
+ continue;
227
+ }
228
+ continue;
229
+ }
230
+ }
231
+
232
+ return { ok: false };
233
+ }
234
+
235
+ async function tryRsvgConvert({ svgPath, outPath, width, height }) {
236
+ try {
237
+ await execFilePromise('rsvg-convert', ['-w', String(width), '-h', String(height), svgPath, '-o', outPath], {
238
+ timeout: 30000,
239
+ });
240
+ return { ok: true, tool: 'rsvg-convert' };
241
+ } catch (e) {
242
+ if (e && (e.code === 'ENOENT' || e.errno === -2)) return { ok: false };
243
+ return { ok: false };
244
+ }
245
+ }
246
+
247
+ async function tryImageMagick({ svgPath, outPath, width, height }) {
248
+ const candidates = [
249
+ { cmd: 'magick', args: ['-background', 'none', '-density', '192', svgPath, '-resize', `${width}x${height}!`, outPath] },
250
+ { cmd: 'convert', args: ['-background', 'none', '-density', '192', svgPath, '-resize', `${width}x${height}!`, outPath] },
251
+ ];
252
+
253
+ for (const c of candidates) {
254
+ try {
255
+ await execFilePromise(c.cmd, c.args, { timeout: 30000 });
256
+ return { ok: true, tool: c.cmd };
257
+ } catch (e) {
258
+ if (e && (e.code === 'ENOENT' || e.errno === -2)) {
259
+ continue;
260
+ }
261
+ continue;
262
+ }
263
+ }
264
+
265
+ return { ok: false };
266
+ }
267
+
268
+ async function tryInkscape({ svgPath, outPath, width, height }) {
269
+ try {
270
+ await execFilePromise(
271
+ 'inkscape',
272
+ [svgPath, '--export-type=png', `--export-filename=${outPath}`, '-w', String(width), '-h', String(height)],
273
+ { timeout: 30000 },
274
+ );
275
+ return { ok: true, tool: 'inkscape' };
276
+ } catch (e) {
277
+ if (e && (e.code === 'ENOENT' || e.errno === -2)) return { ok: false };
278
+ return { ok: false };
279
+ }
280
+ }
281
+
282
+ async function generateOgPng({ svgRaw, outputPath, width, height }) {
283
+ const w = Number(width || DEFAULT_OG_PNG_WIDTH);
284
+ const h = Number(height || DEFAULT_OG_PNG_HEIGHT);
285
+
286
+ const safeWidth = Number.isFinite(w) && w > 0 ? Math.floor(w) : DEFAULT_OG_PNG_WIDTH;
287
+ const safeHeight = Number.isFinite(h) && h > 0 ? Math.floor(h) : DEFAULT_OG_PNG_HEIGHT;
288
+
289
+ const { normalized, resolved } = ensurePublicOutputPathOrThrow(outputPath || DEFAULT_OG_PNG_OUTPUT_PATH);
290
+
291
+ const svg = String(svgRaw || '').trim();
292
+ if (!svg) {
293
+ const err = new Error('SVG is empty');
294
+ err.code = 'VALIDATION';
295
+ throw err;
296
+ }
297
+
298
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
299
+
300
+ const tmpSvg = writeTempFile('seo-og', 'svg', svg);
301
+ const tmpHtml = writeTempFile(
302
+ 'seo-og',
303
+ 'html',
304
+ `<!doctype html><html><head><meta charset="utf-8" />
305
+ <meta name="viewport" content="width=${safeWidth}, height=${safeHeight}, initial-scale=1" />
306
+ <style>
307
+ html, body { margin: 0; padding: 0; width: ${safeWidth}px; height: ${safeHeight}px; overflow: hidden; background: transparent; }
308
+ img { display: block; width: ${safeWidth}px; height: ${safeHeight}px; }
309
+ </style></head><body><img src="file://${tmpSvg}" alt="og" /></body></html>`,
310
+ );
311
+
312
+ const tmpPng = path.join(os.tmpdir(), `seo-og-${Date.now()}-${Math.random().toString(16).slice(2)}.png`);
313
+
314
+ try {
315
+ const chromeRes = await tryChromeScreenshot({ htmlFilePath: tmpHtml, tmpOutPath: tmpPng, width: safeWidth, height: safeHeight });
316
+ if (chromeRes.ok) {
317
+ fs.renameSync(tmpPng, resolved);
318
+ return {
319
+ outputPath: normalized,
320
+ publicUrlPath: `/${normalized.replace(/^public\//, '')}`,
321
+ width: safeWidth,
322
+ height: safeHeight,
323
+ tool: chromeRes.tool,
324
+ };
325
+ }
326
+
327
+ const rsvgRes = await tryRsvgConvert({ svgPath: tmpSvg, outPath: resolved, width: safeWidth, height: safeHeight });
328
+ if (rsvgRes.ok) {
329
+ return {
330
+ outputPath: normalized,
331
+ publicUrlPath: `/${normalized.replace(/^public\//, '')}`,
332
+ width: safeWidth,
333
+ height: safeHeight,
334
+ tool: rsvgRes.tool,
335
+ };
336
+ }
337
+
338
+ const magickRes = await tryImageMagick({ svgPath: tmpSvg, outPath: resolved, width: safeWidth, height: safeHeight });
339
+ if (magickRes.ok) {
340
+ return {
341
+ outputPath: normalized,
342
+ publicUrlPath: `/${normalized.replace(/^public\//, '')}`,
343
+ width: safeWidth,
344
+ height: safeHeight,
345
+ tool: magickRes.tool,
346
+ };
347
+ }
348
+
349
+ const inkRes = await tryInkscape({ svgPath: tmpSvg, outPath: resolved, width: safeWidth, height: safeHeight });
350
+ if (inkRes.ok) {
351
+ return {
352
+ outputPath: normalized,
353
+ publicUrlPath: `/${normalized.replace(/^public\//, '')}`,
354
+ width: safeWidth,
355
+ height: safeHeight,
356
+ tool: inkRes.tool,
357
+ };
358
+ }
359
+
360
+ const err = new Error(
361
+ 'No SVG->PNG converter found. Install one of: Chrome/Chromium (google-chrome/chromium), ImageMagick (magick/convert), librsvg (rsvg-convert), or Inkscape.',
362
+ );
363
+ err.code = 'NO_CONVERTER';
364
+ throw err;
365
+ } finally {
366
+ try { fs.unlinkSync(tmpSvg); } catch {}
367
+ try { fs.unlinkSync(tmpHtml); } catch {}
368
+ try { fs.unlinkSync(tmpPng); } catch {}
369
+ }
370
+ }
371
+
372
+ async function getSeoconfigOpenRouterApiKey() {
373
+ const scoped = await globalSettingsService.getSettingValue('seoconfig.ai.openrouter.apiKey', null);
374
+ if (scoped) return scoped;
375
+ return globalSettingsService.getSettingValue('ai.openrouter.apiKey', null);
376
+ }
377
+
378
+ async function getSeoconfigOpenRouterModel() {
379
+ const scoped = await globalSettingsService.getSettingValue('seoconfig.ai.openrouter.model', null);
380
+ if (scoped) return scoped;
381
+ const fallback = await globalSettingsService.getSettingValue('ai.openrouter.model', null);
382
+ if (fallback) return fallback;
383
+ return 'google/gemini-2.5-flash-lite';
384
+ }
385
+
386
+ module.exports = {
387
+ SEO_CONFIG_SLUG,
388
+ OG_SVG_SETTING_KEY,
389
+ DEFAULT_OG_PNG_OUTPUT_PATH,
390
+ DEFAULT_OG_PNG_WIDTH,
391
+ DEFAULT_OG_PNG_HEIGHT,
392
+ ensureSeoJsonConfigExists,
393
+ getSeoJsonConfig,
394
+ getSeoConfigData,
395
+ updateSeoJsonConfig,
396
+ applySeoPageEntry,
397
+ getOgSvgSettingRaw,
398
+ setOgSvgSettingRaw,
399
+ generateOgPng,
400
+ getSeoconfigOpenRouterApiKey,
401
+ getSeoconfigOpenRouterModel,
402
+ };
@@ -0,0 +1,150 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const crypto = require('crypto');
4
+
5
+ // Simple getSetting using process.env
6
+ const getSetting = (key, defaultValue) => process.env[key] || defaultValue;
7
+
8
+ const ALLOWED_TYPES = {
9
+ image: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
10
+ video: ['video/mp4', 'video/webm', 'video/quicktime']
11
+ };
12
+
13
+ /**
14
+ * Ensure upload directory exists
15
+ */
16
+ function ensureUploadDir(subdir = '') {
17
+ const uploadDir = getSetting('UPLOAD_DIR', 'uploads');
18
+ const fullPath = path.join(process.cwd(), uploadDir, subdir);
19
+
20
+ if (!fs.existsSync(fullPath)) {
21
+ fs.mkdirSync(fullPath, { recursive: true });
22
+ }
23
+
24
+ return fullPath;
25
+ }
26
+
27
+ /**
28
+ * Generate unique filename
29
+ */
30
+ function generateFilename(originalName, prefix = '') {
31
+ const ext = path.extname(originalName).toLowerCase();
32
+ const hash = crypto.randomBytes(8).toString('hex');
33
+ const timestamp = Date.now();
34
+ return `${prefix}${timestamp}-${hash}${ext}`;
35
+ }
36
+
37
+ /**
38
+ * Validate file type
39
+ */
40
+ function validateFileType(mimetype, allowedCategory = 'image') {
41
+ const allowed = ALLOWED_TYPES[allowedCategory] || ALLOWED_TYPES.image;
42
+ return allowed.includes(mimetype);
43
+ }
44
+
45
+ /**
46
+ * Validate file size
47
+ */
48
+ function validateFileSize(size) {
49
+ const maxSize = parseInt(getSetting('MAX_FILE_SIZE', '10485760')); // 10MB default
50
+ return size <= maxSize;
51
+ }
52
+
53
+ /**
54
+ * Save uploaded file from base64 data
55
+ */
56
+ async function saveBase64File(base64Data, options = {}) {
57
+ const { subdir = 'images', prefix = '', allowedCategory = 'image' } = options;
58
+
59
+ // Extract mime type and data
60
+ const matches = base64Data.match(/^data:([A-Za-z-+\/]+);base64,(.+)$/);
61
+ if (!matches || matches.length !== 3) {
62
+ throw new Error('Invalid base64 data');
63
+ }
64
+
65
+ const mimetype = matches[1];
66
+ const data = matches[2];
67
+ const buffer = Buffer.from(data, 'base64');
68
+
69
+ // Validate
70
+ if (!validateFileType(mimetype, allowedCategory)) {
71
+ throw new Error('Invalid file type');
72
+ }
73
+
74
+ if (!validateFileSize(buffer.length)) {
75
+ throw new Error('File too large');
76
+ }
77
+
78
+ // Get extension from mimetype
79
+ const ext = mimetype.split('/')[1].replace('jpeg', 'jpg');
80
+ const filename = generateFilename(`file.${ext}`, prefix);
81
+
82
+ // Save file
83
+ const dir = ensureUploadDir(subdir);
84
+ const filepath = path.join(dir, filename);
85
+
86
+ fs.writeFileSync(filepath, buffer);
87
+
88
+ // Return relative URL
89
+ const uploadDir = getSetting('UPLOAD_DIR', 'uploads');
90
+ return `/${uploadDir}/${subdir}/${filename}`;
91
+ }
92
+
93
+ /**
94
+ * Save multipart file
95
+ */
96
+ async function saveMultipartFile(file, options = {}) {
97
+ const { subdir = 'images', prefix = '', allowedCategory = 'image' } = options;
98
+
99
+ // Validate
100
+ if (!validateFileType(file.mimetype, allowedCategory)) {
101
+ throw new Error('Invalid file type');
102
+ }
103
+
104
+ if (!validateFileSize(file.size)) {
105
+ throw new Error('File too large');
106
+ }
107
+
108
+ const filename = generateFilename(file.originalname || file.name, prefix);
109
+ const dir = ensureUploadDir(subdir);
110
+ const filepath = path.join(dir, filename);
111
+
112
+ // Move or copy file
113
+ if (file.mv) {
114
+ await file.mv(filepath);
115
+ } else if (file.path) {
116
+ fs.copyFileSync(file.path, filepath);
117
+ fs.unlinkSync(file.path);
118
+ } else if (file.buffer) {
119
+ fs.writeFileSync(filepath, file.buffer);
120
+ } else {
121
+ throw new Error('Unable to save file');
122
+ }
123
+
124
+ const uploadDir = getSetting('UPLOAD_DIR', 'uploads');
125
+ return `/${uploadDir}/${subdir}/${filename}`;
126
+ }
127
+
128
+ /**
129
+ * Delete file
130
+ */
131
+ function deleteFile(fileUrl) {
132
+ if (!fileUrl) return;
133
+
134
+ // Convert URL to filepath
135
+ const filepath = path.join(process.cwd(), fileUrl);
136
+
137
+ if (fs.existsSync(filepath)) {
138
+ fs.unlinkSync(filepath);
139
+ }
140
+ }
141
+
142
+ module.exports = {
143
+ ensureUploadDir,
144
+ generateFilename,
145
+ validateFileType,
146
+ validateFileSize,
147
+ saveBase64File,
148
+ saveMultipartFile,
149
+ deleteFile
150
+ };