@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,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
|
+
};
|