@actuate-media/cms-core 0.28.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/diagnostics/env.test.js +18 -0
- package/dist/__tests__/diagnostics/env.test.js.map +1 -1
- package/dist/__tests__/media/seo.test.d.ts +2 -0
- package/dist/__tests__/media/seo.test.d.ts.map +1 -0
- package/dist/__tests__/media/seo.test.js +186 -0
- package/dist/__tests__/media/seo.test.js.map +1 -0
- package/dist/__tests__/redirects/graph.test.d.ts +2 -0
- package/dist/__tests__/redirects/graph.test.d.ts.map +1 -0
- package/dist/__tests__/redirects/graph.test.js +104 -0
- package/dist/__tests__/redirects/graph.test.js.map +1 -0
- package/dist/__tests__/redirects/suggest.test.d.ts +2 -0
- package/dist/__tests__/redirects/suggest.test.d.ts.map +1 -0
- package/dist/__tests__/redirects/suggest.test.js +28 -0
- package/dist/__tests__/redirects/suggest.test.js.map +1 -0
- package/dist/__tests__/seo/audit-engine.test.d.ts +2 -0
- package/dist/__tests__/seo/audit-engine.test.d.ts.map +1 -0
- package/dist/__tests__/seo/audit-engine.test.js +152 -0
- package/dist/__tests__/seo/audit-engine.test.js.map +1 -0
- package/dist/__tests__/seo/config-store.test.js +24 -160
- package/dist/__tests__/seo/config-store.test.js.map +1 -1
- package/dist/__tests__/seo/score.test.d.ts +2 -0
- package/dist/__tests__/seo/score.test.d.ts.map +1 -0
- package/dist/__tests__/seo/score.test.js +152 -0
- package/dist/__tests__/seo/score.test.js.map +1 -0
- package/dist/__tests__/setup/index.test.d.ts +2 -0
- package/dist/__tests__/setup/index.test.d.ts.map +1 -0
- package/dist/__tests__/setup/index.test.js +82 -0
- package/dist/__tests__/setup/index.test.js.map +1 -0
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +2095 -228
- package/dist/api/handlers.js.map +1 -1
- package/dist/diagnostics/env.d.ts.map +1 -1
- package/dist/diagnostics/env.js +9 -5
- package/dist/diagnostics/env.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/media/index.d.ts +2 -0
- package/dist/media/index.d.ts.map +1 -1
- package/dist/media/index.js +1 -0
- package/dist/media/index.js.map +1 -1
- package/dist/media/seo.d.ts +88 -0
- package/dist/media/seo.d.ts.map +1 -0
- package/dist/media/seo.js +215 -0
- package/dist/media/seo.js.map +1 -0
- package/dist/redirects/graph.d.ts +68 -0
- package/dist/redirects/graph.d.ts.map +1 -0
- package/dist/redirects/graph.js +136 -0
- package/dist/redirects/graph.js.map +1 -0
- package/dist/redirects/index.d.ts +5 -0
- package/dist/redirects/index.d.ts.map +1 -0
- package/dist/redirects/index.js +3 -0
- package/dist/redirects/index.js.map +1 -0
- package/dist/redirects/suggest.d.ts +21 -0
- package/dist/redirects/suggest.d.ts.map +1 -0
- package/dist/redirects/suggest.js +55 -0
- package/dist/redirects/suggest.js.map +1 -0
- package/dist/seo/audit-engine.d.ts +119 -0
- package/dist/seo/audit-engine.d.ts.map +1 -0
- package/dist/seo/audit-engine.js +402 -0
- package/dist/seo/audit-engine.js.map +1 -0
- package/dist/seo/audit-runner.d.ts +41 -0
- package/dist/seo/audit-runner.d.ts.map +1 -0
- package/dist/seo/audit-runner.js +223 -0
- package/dist/seo/audit-runner.js.map +1 -0
- package/dist/seo/config-store.d.ts +28 -0
- package/dist/seo/config-store.d.ts.map +1 -1
- package/dist/seo/config-store.js +22 -0
- package/dist/seo/config-store.js.map +1 -1
- package/dist/seo/index.d.ts +7 -2
- package/dist/seo/index.d.ts.map +1 -1
- package/dist/seo/index.js +4 -1
- package/dist/seo/index.js.map +1 -1
- package/dist/seo/score.d.ts +128 -0
- package/dist/seo/score.d.ts.map +1 -0
- package/dist/seo/score.js +213 -0
- package/dist/seo/score.js.map +1 -0
- package/dist/setup/index.d.ts +16 -1
- package/dist/setup/index.d.ts.map +1 -1
- package/dist/setup/index.js +52 -2
- package/dist/setup/index.js.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/0008_media_center/migration.sql +47 -0
- package/prisma/migrations/0009_seo/migration.sql +143 -0
- package/prisma/schema.prisma +183 -8
package/dist/api/handlers.js
CHANGED
|
@@ -165,6 +165,82 @@ function normalizeMediaItem(media) {
|
|
|
165
165
|
title: media.title ?? '',
|
|
166
166
|
};
|
|
167
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Build the admin editor route for a document that references a media asset.
|
|
170
|
+
* Pages own the `/pages/*` namespace; post-type collections use
|
|
171
|
+
* `/posts/:type/:id/edit`; everything else falls back to the generic
|
|
172
|
+
* collection editor. Kept in sync with the route table in `AdminRoot.tsx`.
|
|
173
|
+
*/
|
|
174
|
+
function mediaUsageEditPath(collection, id) {
|
|
175
|
+
if (collection === 'pages')
|
|
176
|
+
return `/pages/${id}/edit`;
|
|
177
|
+
const def = getActuateConfig()?.collections?.[collection];
|
|
178
|
+
if (def?.type === 'post')
|
|
179
|
+
return `/posts/${collection}/${id}/edit`;
|
|
180
|
+
return `/collections/${collection}/${id}`;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Map the `MediaUsage` join rows (with their related document) into the
|
|
184
|
+
* `{ page, path }[]` shape the admin drawer renders. Soft-deleted documents
|
|
185
|
+
* are excluded so trashed pages don't keep an asset looking "in use".
|
|
186
|
+
*/
|
|
187
|
+
function buildMediaUsedOn(mediaUsages) {
|
|
188
|
+
if (!Array.isArray(mediaUsages))
|
|
189
|
+
return [];
|
|
190
|
+
const seen = new Set();
|
|
191
|
+
const out = [];
|
|
192
|
+
for (const usage of mediaUsages) {
|
|
193
|
+
const doc = usage?.document;
|
|
194
|
+
if (!doc || doc.deletedAt || seen.has(doc.id))
|
|
195
|
+
continue;
|
|
196
|
+
seen.add(doc.id);
|
|
197
|
+
out.push({
|
|
198
|
+
page: doc.title || '(untitled)',
|
|
199
|
+
path: mediaUsageEditPath(doc.collection, doc.id),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
const MEDIA_TITLE_STOPWORDS = new Set([
|
|
205
|
+
'a',
|
|
206
|
+
'an',
|
|
207
|
+
'the',
|
|
208
|
+
'of',
|
|
209
|
+
'and',
|
|
210
|
+
'or',
|
|
211
|
+
'to',
|
|
212
|
+
'in',
|
|
213
|
+
'on',
|
|
214
|
+
'for',
|
|
215
|
+
'with',
|
|
216
|
+
'at',
|
|
217
|
+
'by',
|
|
218
|
+
'from',
|
|
219
|
+
'is',
|
|
220
|
+
'are',
|
|
221
|
+
'as',
|
|
222
|
+
]);
|
|
223
|
+
/**
|
|
224
|
+
* Derive a concise, Title-Cased title from AI-generated alt text. The alt
|
|
225
|
+
* text is already a model output describing the image, so the title stays
|
|
226
|
+
* grounded in real content rather than the filename.
|
|
227
|
+
*/
|
|
228
|
+
function deriveMediaTitleFromAlt(alt) {
|
|
229
|
+
if (!alt)
|
|
230
|
+
return '';
|
|
231
|
+
const firstClause = (alt.split(/[.;:\n]/)[0] ?? '').trim();
|
|
232
|
+
const titled = firstClause
|
|
233
|
+
.split(/\s+/)
|
|
234
|
+
.slice(0, 9)
|
|
235
|
+
.map((word, i) => {
|
|
236
|
+
const lower = word.toLowerCase();
|
|
237
|
+
if (i > 0 && MEDIA_TITLE_STOPWORDS.has(lower))
|
|
238
|
+
return lower;
|
|
239
|
+
return word.charAt(0).toUpperCase() + word.slice(1);
|
|
240
|
+
})
|
|
241
|
+
.join(' ');
|
|
242
|
+
return titled.slice(0, 80).replace(/[\s,]+$/, '');
|
|
243
|
+
}
|
|
168
244
|
function asRecord(value) {
|
|
169
245
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
170
246
|
? value
|
|
@@ -241,6 +317,61 @@ function hasModel(d, name) {
|
|
|
241
317
|
return false;
|
|
242
318
|
}
|
|
243
319
|
}
|
|
320
|
+
/** Format a number compactly, e.g. 8400 -> "8.4K", 1_200_000 -> "1.2M". */
|
|
321
|
+
function formatCompactNumber(n) {
|
|
322
|
+
if (!Number.isFinite(n))
|
|
323
|
+
return '0';
|
|
324
|
+
if (Math.abs(n) >= 1_000_000)
|
|
325
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
326
|
+
if (Math.abs(n) >= 1_000)
|
|
327
|
+
return `${(n / 1_000).toFixed(1)}K`;
|
|
328
|
+
return String(Math.round(n));
|
|
329
|
+
}
|
|
330
|
+
/** Map a 0-100 site score to a grade band (mirrors seo/score.gradeForSeoScore). */
|
|
331
|
+
function gradeForScoreBand(score) {
|
|
332
|
+
if (score >= 80)
|
|
333
|
+
return 'good';
|
|
334
|
+
if (score >= 60)
|
|
335
|
+
return 'fair';
|
|
336
|
+
if (score >= 1)
|
|
337
|
+
return 'poor';
|
|
338
|
+
return 'critical';
|
|
339
|
+
}
|
|
340
|
+
/** Minimal CSV line parser supporting double-quoted fields with escaped quotes. */
|
|
341
|
+
function parseCsvLine(line) {
|
|
342
|
+
const out = [];
|
|
343
|
+
let cur = '';
|
|
344
|
+
let inQuotes = false;
|
|
345
|
+
for (let i = 0; i < line.length; i++) {
|
|
346
|
+
const ch = line[i];
|
|
347
|
+
if (inQuotes) {
|
|
348
|
+
if (ch === '"') {
|
|
349
|
+
if (line[i + 1] === '"') {
|
|
350
|
+
cur += '"';
|
|
351
|
+
i++;
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
inQuotes = false;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
cur += ch;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
else if (ch === '"') {
|
|
362
|
+
inQuotes = true;
|
|
363
|
+
}
|
|
364
|
+
else if (ch === ',') {
|
|
365
|
+
out.push(cur);
|
|
366
|
+
cur = '';
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
cur += ch;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
out.push(cur);
|
|
373
|
+
return out;
|
|
374
|
+
}
|
|
244
375
|
function modelNotAvailable(name) {
|
|
245
376
|
return errorResponse(`The "${name}" model is not available in your Prisma schema. ` +
|
|
246
377
|
'Run `actuate db:init` for new schemas, or carefully update the existing Actuate block, create/apply a Prisma migration, then regenerate Prisma Client. ' +
|
|
@@ -672,6 +803,7 @@ const totpLimiter = createRateLimiter({ maxRequests: 10, windowMs: 15 * 60 * 100
|
|
|
672
803
|
const formLimiterGlobal = createRateLimiter({ maxRequests: 10, windowMs: 60_000 });
|
|
673
804
|
const aiGenerateLimiter = createRateLimiter({ maxRequests: 20, windowMs: 60 * 60 * 1000 });
|
|
674
805
|
const linkHealthLimiter = createRateLimiter({ maxRequests: 4, windowMs: 60 * 60 * 1000 });
|
|
806
|
+
const seoAuditLimiter = createRateLimiter({ maxRequests: 10, windowMs: 60 * 60 * 1000 });
|
|
675
807
|
async function checkRateLimitAsync(limiter, key) {
|
|
676
808
|
// Explicit, environment-gated bypass for test harnesses. Production never
|
|
677
809
|
// sets this — Vercel + the deploy guide both omit it. We deliberately do
|
|
@@ -2255,10 +2387,25 @@ export function registerCMSRoutes(router) {
|
|
|
2255
2387
|
where.folderId = folderParam;
|
|
2256
2388
|
}
|
|
2257
2389
|
const [items, total] = await Promise.all([
|
|
2258
|
-
db().media.findMany({
|
|
2390
|
+
db().media.findMany({
|
|
2391
|
+
where,
|
|
2392
|
+
skip,
|
|
2393
|
+
take: pageSize,
|
|
2394
|
+
orderBy: { createdAt: 'desc' },
|
|
2395
|
+
include: {
|
|
2396
|
+
mediaUsages: {
|
|
2397
|
+
include: {
|
|
2398
|
+
document: { select: { id: true, collection: true, title: true, deletedAt: true } },
|
|
2399
|
+
},
|
|
2400
|
+
},
|
|
2401
|
+
},
|
|
2402
|
+
}),
|
|
2259
2403
|
db().media.count({ where }),
|
|
2260
2404
|
]);
|
|
2261
|
-
const normalized = items.map(
|
|
2405
|
+
const normalized = items.map((item) => {
|
|
2406
|
+
const { mediaUsages, ...rest } = normalizeMediaItem(item);
|
|
2407
|
+
return { ...rest, usedOn: buildMediaUsedOn(mediaUsages) };
|
|
2408
|
+
});
|
|
2262
2409
|
return json({
|
|
2263
2410
|
data: {
|
|
2264
2411
|
data: normalized,
|
|
@@ -4058,133 +4205,208 @@ export function registerCMSRoutes(router) {
|
|
|
4058
4205
|
}
|
|
4059
4206
|
});
|
|
4060
4207
|
// ---------------------------------------------------------------------------
|
|
4061
|
-
//
|
|
4208
|
+
// Redirect engine — /seo/redirects/* (CRUD, chains/loops, 404 recovery, CSV)
|
|
4062
4209
|
// ---------------------------------------------------------------------------
|
|
4063
|
-
|
|
4210
|
+
const REDIRECT_STATUS_CODES = [301, 302, 307, 308, 410];
|
|
4211
|
+
function redirectToRule(r) {
|
|
4212
|
+
return {
|
|
4213
|
+
id: r.id,
|
|
4214
|
+
type: Number(r.statusCode ?? 301),
|
|
4215
|
+
fromPath: r.source ?? '',
|
|
4216
|
+
toPath: r.destination ?? '',
|
|
4217
|
+
enabled: r.enabled ?? true,
|
|
4218
|
+
priority: Number(r.priority ?? 0),
|
|
4219
|
+
preserveQuery: r.preserveQuery ?? false,
|
|
4220
|
+
hits: Number(r.hits ?? 0),
|
|
4221
|
+
lastHitAt: r.lastHitAt ?? null,
|
|
4222
|
+
stale: requireRedirectGraph().isRedirectStale(r.lastHitAt),
|
|
4223
|
+
source: r.source_kind ?? 'manual',
|
|
4224
|
+
notes: r.notes ?? null,
|
|
4225
|
+
createdAt: r.createdAt ?? null,
|
|
4226
|
+
updatedAt: r.updatedAt ?? null,
|
|
4227
|
+
};
|
|
4228
|
+
}
|
|
4229
|
+
// Lazily-loaded pure graph helpers (sync after first load is fine because
|
|
4230
|
+
// the module is tiny and the dynamic import resolves before any route runs
|
|
4231
|
+
// that needs it; we cache the namespace).
|
|
4232
|
+
let _graph = null;
|
|
4233
|
+
function requireRedirectGraph() {
|
|
4234
|
+
if (!_graph)
|
|
4235
|
+
throw new Error('redirect graph not loaded');
|
|
4236
|
+
return _graph;
|
|
4237
|
+
}
|
|
4238
|
+
async function ensureRedirectGraph() {
|
|
4239
|
+
if (!_graph)
|
|
4240
|
+
_graph = await import('../redirects/graph.js');
|
|
4241
|
+
return _graph;
|
|
4242
|
+
}
|
|
4243
|
+
/** Validate a redirect destination (open-redirect defence). */
|
|
4244
|
+
function validateRedirectDestination(destination, statusCode) {
|
|
4245
|
+
if (statusCode === 410)
|
|
4246
|
+
return null; // Gone: destination is informational
|
|
4247
|
+
if (destination.startsWith('http://') || destination.startsWith('https://')) {
|
|
4248
|
+
let destUrl;
|
|
4249
|
+
try {
|
|
4250
|
+
destUrl = new URL(destination);
|
|
4251
|
+
}
|
|
4252
|
+
catch {
|
|
4253
|
+
return 'Invalid destination URL';
|
|
4254
|
+
}
|
|
4255
|
+
if (!['http:', 'https:'].includes(destUrl.protocol))
|
|
4256
|
+
return 'Invalid destination URL';
|
|
4257
|
+
const cmsConfig = getActuateConfig();
|
|
4258
|
+
const allowed = new Set(Array.isArray(cmsConfig?.redirects?.allowedExternalHosts)
|
|
4259
|
+
? cmsConfig.redirects.allowedExternalHosts.map((h) => h.toLowerCase())
|
|
4260
|
+
: []);
|
|
4261
|
+
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL;
|
|
4262
|
+
if (siteUrl) {
|
|
4263
|
+
try {
|
|
4264
|
+
allowed.add(new URL(siteUrl).hostname.toLowerCase());
|
|
4265
|
+
}
|
|
4266
|
+
catch {
|
|
4267
|
+
/* noop */
|
|
4268
|
+
}
|
|
4269
|
+
}
|
|
4270
|
+
if (!allowed.has(destUrl.hostname.toLowerCase())) {
|
|
4271
|
+
return 'External redirect destinations must be to an allowlisted host.';
|
|
4272
|
+
}
|
|
4273
|
+
}
|
|
4274
|
+
else if (!destination.startsWith('/')) {
|
|
4275
|
+
return 'Destination must be an absolute URL or a path beginning with /';
|
|
4276
|
+
}
|
|
4277
|
+
return null;
|
|
4278
|
+
}
|
|
4279
|
+
router.get('/seo/redirects', async (request) => {
|
|
4064
4280
|
try {
|
|
4065
4281
|
const auth = await requireAuth(request);
|
|
4066
4282
|
if (auth.error)
|
|
4067
4283
|
return auth.error;
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4284
|
+
await ensureRedirectGraph();
|
|
4285
|
+
const rows = await db().redirect.findMany({
|
|
4286
|
+
orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }],
|
|
4287
|
+
});
|
|
4288
|
+
return json({ data: { rules: rows.map(redirectToRule) } });
|
|
4289
|
+
}
|
|
4290
|
+
catch (err) {
|
|
4291
|
+
return internalError(err, 'seo/redirects');
|
|
4292
|
+
}
|
|
4293
|
+
});
|
|
4294
|
+
router.get('/seo/redirects/overview', async (request) => {
|
|
4295
|
+
try {
|
|
4296
|
+
const auth = await requireAuth(request);
|
|
4297
|
+
if (auth.error)
|
|
4298
|
+
return auth.error;
|
|
4299
|
+
const graph = await ensureRedirectGraph();
|
|
4300
|
+
const rows = await db().redirect.findMany({});
|
|
4301
|
+
const active = rows.filter((r) => r.enabled !== false);
|
|
4302
|
+
const stale = active.filter((r) => graph.isRedirectStale(r.lastHitAt)).length;
|
|
4303
|
+
const chains = graph.detectChainsAndLoops(rows.map((r) => ({ id: r.id, from: r.source, to: r.destination, enabled: r.enabled })));
|
|
4304
|
+
let notFoundCount = 0;
|
|
4305
|
+
let notFoundHits = 0;
|
|
4306
|
+
if (hasModel(db(), 'redirect404Hit')) {
|
|
4307
|
+
const hits = await db().redirect404Hit.findMany({ where: { resolvedAt: null } });
|
|
4308
|
+
notFoundCount = hits.length;
|
|
4309
|
+
notFoundHits = hits.reduce((s, h) => s + Number(h.hits ?? 0), 0);
|
|
4310
|
+
}
|
|
4311
|
+
const trafficRecovered = active.reduce((s, r) => s + Number(r.hits ?? 0), 0);
|
|
4312
|
+
return json({
|
|
4313
|
+
data: {
|
|
4314
|
+
activeRedirects: active.length,
|
|
4315
|
+
staleRedirects: stale,
|
|
4316
|
+
notFoundCount,
|
|
4317
|
+
notFoundHits,
|
|
4318
|
+
chainsAndLoops: chains.length,
|
|
4319
|
+
trafficRecovered,
|
|
4076
4320
|
},
|
|
4077
|
-
orderBy: { updatedAt: 'desc' },
|
|
4078
4321
|
});
|
|
4079
|
-
return json({ data: pages.map(normalizeSeoPage) });
|
|
4080
4322
|
}
|
|
4081
4323
|
catch (err) {
|
|
4082
|
-
return internalError(err);
|
|
4324
|
+
return internalError(err, 'seo/redirects/overview');
|
|
4083
4325
|
}
|
|
4084
4326
|
});
|
|
4085
|
-
router.get('/seo/
|
|
4327
|
+
router.get('/seo/redirects/chains', async (request) => {
|
|
4086
4328
|
try {
|
|
4087
4329
|
const auth = await requireAuth(request);
|
|
4088
4330
|
if (auth.error)
|
|
4089
4331
|
return auth.error;
|
|
4090
|
-
|
|
4091
|
-
const
|
|
4332
|
+
const graph = await ensureRedirectGraph();
|
|
4333
|
+
const rows = await db().redirect.findMany({});
|
|
4334
|
+
const chains = graph.detectChainsAndLoops(rows.map((r) => ({ id: r.id, from: r.source, to: r.destination, enabled: r.enabled })));
|
|
4335
|
+
return json({ data: { chains } });
|
|
4336
|
+
}
|
|
4337
|
+
catch (err) {
|
|
4338
|
+
return internalError(err, 'seo/redirects/chains');
|
|
4339
|
+
}
|
|
4340
|
+
});
|
|
4341
|
+
router.post('/seo/redirects/flatten', async (request) => {
|
|
4342
|
+
try {
|
|
4343
|
+
const auth = await requireAuth(request);
|
|
4344
|
+
if (auth.error)
|
|
4345
|
+
return auth.error;
|
|
4346
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
4092
4347
|
if (roleErr)
|
|
4093
4348
|
return roleErr;
|
|
4094
|
-
|
|
4095
|
-
const
|
|
4096
|
-
const
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
if (!
|
|
4100
|
-
return errorResponse('
|
|
4349
|
+
const graph = await ensureRedirectGraph();
|
|
4350
|
+
const body = (await request.json().catch(() => ({})));
|
|
4351
|
+
const rows = await db().redirect.findMany({});
|
|
4352
|
+
const chains = graph.detectChainsAndLoops(rows.map((r) => ({ id: r.id, from: r.source, to: r.destination, enabled: r.enabled })));
|
|
4353
|
+
const chain = chains.find((c) => c.id === body.chainId);
|
|
4354
|
+
if (!chain)
|
|
4355
|
+
return errorResponse('Chain not found', 404);
|
|
4356
|
+
if (chain.kind === 'loop') {
|
|
4357
|
+
return errorResponse('Cannot auto-flatten a loop — break the cycle manually.', 400);
|
|
4101
4358
|
}
|
|
4102
|
-
|
|
4103
|
-
const
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
const
|
|
4107
|
-
where: {
|
|
4108
|
-
|
|
4359
|
+
// Point the chain source directly at the terminal destination.
|
|
4360
|
+
const sourceRow = rows.find((r) => graph.normalizeRedirectPath(r.source) === chain.suggestedFrom);
|
|
4361
|
+
if (!sourceRow)
|
|
4362
|
+
return errorResponse('Chain source rule not found', 404);
|
|
4363
|
+
const updated = await db().redirect.update({
|
|
4364
|
+
where: { id: sourceRow.id },
|
|
4365
|
+
data: { destination: chain.suggestedTo, source_kind: 'chain-flatten' },
|
|
4109
4366
|
});
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
break outer;
|
|
4132
|
-
const isInternal = !!siteUrl && clean.startsWith(siteUrl);
|
|
4133
|
-
queue.push({ docId: doc.id, pageTitle, clean, isInternal });
|
|
4134
|
-
countInPage++;
|
|
4135
|
-
}
|
|
4367
|
+
return json({ data: redirectToRule(updated) });
|
|
4368
|
+
}
|
|
4369
|
+
catch (err) {
|
|
4370
|
+
return internalError(err, 'seo/redirects/flatten');
|
|
4371
|
+
}
|
|
4372
|
+
});
|
|
4373
|
+
router.get('/seo/redirects/suggestions', async (request) => {
|
|
4374
|
+
try {
|
|
4375
|
+
const auth = await requireAuth(request);
|
|
4376
|
+
if (auth.error)
|
|
4377
|
+
return auth.error;
|
|
4378
|
+
if (!hasModel(db(), 'redirectSuggestion'))
|
|
4379
|
+
return json({ data: { suggestions: [] } });
|
|
4380
|
+
const rows = await db().redirectSuggestion.findMany({
|
|
4381
|
+
where: { status: 'open' },
|
|
4382
|
+
orderBy: { confidence: 'desc' },
|
|
4383
|
+
});
|
|
4384
|
+
let hitsByPath = new Map();
|
|
4385
|
+
if (hasModel(db(), 'redirect404Hit')) {
|
|
4386
|
+
const hits = await db().redirect404Hit.findMany({});
|
|
4387
|
+
hitsByPath = new Map(hits.map((h) => [h.path, Number(h.hits ?? 0)]));
|
|
4136
4388
|
}
|
|
4137
|
-
const linkResults = [];
|
|
4138
|
-
let cursor = 0;
|
|
4139
|
-
const worker = async () => {
|
|
4140
|
-
while (cursor < queue.length) {
|
|
4141
|
-
const idx = cursor++;
|
|
4142
|
-
const job = queue[idx];
|
|
4143
|
-
let status = 0;
|
|
4144
|
-
try {
|
|
4145
|
-
// safeFetch rejects private/loopback IPs and disables redirect
|
|
4146
|
-
// following so 302->internal can't smuggle the scanner past SSRF.
|
|
4147
|
-
const resp = await safeFetch(job.clean, {
|
|
4148
|
-
method: 'HEAD',
|
|
4149
|
-
timeoutMs: PER_LINK_TIMEOUT_MS,
|
|
4150
|
-
});
|
|
4151
|
-
status = resp.status;
|
|
4152
|
-
// Drain the body so the connection can be reused.
|
|
4153
|
-
try {
|
|
4154
|
-
await resp.body?.cancel();
|
|
4155
|
-
}
|
|
4156
|
-
catch {
|
|
4157
|
-
/* noop */
|
|
4158
|
-
}
|
|
4159
|
-
}
|
|
4160
|
-
catch (err) {
|
|
4161
|
-
status = err instanceof SsrfBlockedError ? -1 : 0;
|
|
4162
|
-
}
|
|
4163
|
-
if (status === -1 || status === 0 || status >= 300) {
|
|
4164
|
-
linkResults.push({
|
|
4165
|
-
id: `${job.docId}-${idx}`,
|
|
4166
|
-
page: job.pageTitle,
|
|
4167
|
-
url: job.clean,
|
|
4168
|
-
status: status === -1 ? 0 : status,
|
|
4169
|
-
type: job.isInternal ? 'internal' : 'external',
|
|
4170
|
-
});
|
|
4171
|
-
}
|
|
4172
|
-
}
|
|
4173
|
-
};
|
|
4174
|
-
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, worker));
|
|
4175
4389
|
return json({
|
|
4176
4390
|
data: {
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4391
|
+
suggestions: rows.map((s) => ({
|
|
4392
|
+
id: s.id,
|
|
4393
|
+
fromPath: s.fromPath,
|
|
4394
|
+
toPath: s.toPath,
|
|
4395
|
+
// Stored as a 0-100 Int; the UI contract is a 0-1 fraction.
|
|
4396
|
+
confidence: Number(s.confidence ?? 0) / 100,
|
|
4397
|
+
reason: s.reason ?? null,
|
|
4398
|
+
status: s.status,
|
|
4399
|
+
hits: hitsByPath.get(s.fromPath) ?? 0,
|
|
4400
|
+
trend: null,
|
|
4401
|
+
})),
|
|
4180
4402
|
},
|
|
4181
4403
|
});
|
|
4182
4404
|
}
|
|
4183
4405
|
catch (err) {
|
|
4184
|
-
return internalError(err);
|
|
4406
|
+
return internalError(err, 'seo/redirects/suggestions');
|
|
4185
4407
|
}
|
|
4186
4408
|
});
|
|
4187
|
-
router.post('/seo/
|
|
4409
|
+
router.post('/seo/redirects/suggest', async (request) => {
|
|
4188
4410
|
try {
|
|
4189
4411
|
const auth = await requireAuth(request);
|
|
4190
4412
|
if (auth.error)
|
|
@@ -4192,75 +4414,1144 @@ export function registerCMSRoutes(router) {
|
|
|
4192
4414
|
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4193
4415
|
if (roleErr)
|
|
4194
4416
|
return roleErr;
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
if (!
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
const imgMatches = content.match(/<img\b[^>]*>/gi) ?? [];
|
|
4228
|
-
const missingAlt = imgMatches.filter((img) => !img.includes('alt=')).length;
|
|
4229
|
-
if (missingAlt > 0)
|
|
4230
|
-
problems.push(`${missingAlt} image(s) missing alt text`);
|
|
4231
|
-
if (!content.includes('<h1') && !content.includes('<h1>'))
|
|
4232
|
-
problems.push('No H1 heading found in content');
|
|
4233
|
-
}
|
|
4234
|
-
if (problems.length > 0) {
|
|
4235
|
-
issues.push({
|
|
4236
|
-
documentId: doc.id,
|
|
4237
|
-
title: doc.title ?? 'Untitled',
|
|
4238
|
-
slug: doc.slug ?? '',
|
|
4239
|
-
problems,
|
|
4240
|
-
});
|
|
4241
|
-
}
|
|
4417
|
+
if (!hasModel(db(), 'redirectSuggestion') || !hasModel(db(), 'redirect404Hit')) {
|
|
4418
|
+
return modelNotAvailable('RedirectSuggestion');
|
|
4419
|
+
}
|
|
4420
|
+
const { suggestRedirectTarget } = await import('../redirects/suggest.js');
|
|
4421
|
+
// Candidate set: published page/post URLs.
|
|
4422
|
+
const { gatherAuditEntities } = await import('../seo/audit-runner.js');
|
|
4423
|
+
const entities = await gatherAuditEntities(db());
|
|
4424
|
+
const candidates = entities.map((e) => ({ url: e.url, title: e.title }));
|
|
4425
|
+
const unresolved = await db().redirect404Hit.findMany({ where: { resolvedAt: null } });
|
|
4426
|
+
let created = 0;
|
|
4427
|
+
for (const hit of unresolved) {
|
|
4428
|
+
const existing = await db().redirectSuggestion.findFirst({
|
|
4429
|
+
where: { fromPath: hit.path, status: 'open' },
|
|
4430
|
+
});
|
|
4431
|
+
if (existing)
|
|
4432
|
+
continue;
|
|
4433
|
+
const suggestion = suggestRedirectTarget(hit.path, candidates);
|
|
4434
|
+
if (!suggestion)
|
|
4435
|
+
continue;
|
|
4436
|
+
await db().redirectSuggestion.create({
|
|
4437
|
+
data: {
|
|
4438
|
+
fromPath: hit.path,
|
|
4439
|
+
toPath: suggestion.toPath,
|
|
4440
|
+
// DB column is an Int — store confidence as a 0-100 percentage.
|
|
4441
|
+
// The GET endpoint converts back to the 0-1 fraction the UI expects.
|
|
4442
|
+
confidence: Math.round(suggestion.confidence * 100),
|
|
4443
|
+
reason: suggestion.reason,
|
|
4444
|
+
status: 'open',
|
|
4445
|
+
source: 'ai-404-recovery',
|
|
4446
|
+
},
|
|
4447
|
+
});
|
|
4448
|
+
created++;
|
|
4242
4449
|
}
|
|
4243
|
-
|
|
4244
|
-
const pagesWithIssues = issues.length;
|
|
4245
|
-
const totalProblems = issues.reduce((sum, i) => sum + i.problems.length, 0);
|
|
4246
|
-
return json({ data: { total, pagesWithIssues, totalProblems, issues } });
|
|
4450
|
+
return json({ data: { created } });
|
|
4247
4451
|
}
|
|
4248
4452
|
catch (err) {
|
|
4249
|
-
return internalError(err);
|
|
4453
|
+
return internalError(err, 'seo/redirects/suggest');
|
|
4250
4454
|
}
|
|
4251
4455
|
});
|
|
4252
|
-
|
|
4253
|
-
// SEO analysis, readability, internal links, schema, meta
|
|
4254
|
-
// ---------------------------------------------------------------------------
|
|
4255
|
-
router.get('/seo/analysis/:documentId', async (request, params) => {
|
|
4456
|
+
router.post('/seo/redirects/suggestions/:id/accept', async (request, params) => {
|
|
4256
4457
|
try {
|
|
4257
4458
|
const auth = await requireAuth(request);
|
|
4258
4459
|
if (auth.error)
|
|
4259
4460
|
return auth.error;
|
|
4260
|
-
const
|
|
4261
|
-
|
|
4461
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
4462
|
+
if (roleErr)
|
|
4463
|
+
return roleErr;
|
|
4464
|
+
if (!hasModel(db(), 'redirectSuggestion'))
|
|
4465
|
+
return modelNotAvailable('RedirectSuggestion');
|
|
4466
|
+
const suggestion = await db().redirectSuggestion.findUnique({ where: { id: params.id } });
|
|
4467
|
+
if (!suggestion)
|
|
4468
|
+
return errorResponse('Suggestion not found', 404);
|
|
4469
|
+
const graph = await ensureRedirectGraph();
|
|
4470
|
+
const rows = await db().redirect.findMany({});
|
|
4471
|
+
if (graph.wouldCreateLoop(rows.map((r) => ({
|
|
4472
|
+
id: r.id,
|
|
4473
|
+
from: r.source,
|
|
4474
|
+
to: r.destination,
|
|
4475
|
+
enabled: r.enabled,
|
|
4476
|
+
})), suggestion.fromPath, suggestion.toPath)) {
|
|
4477
|
+
return errorResponse('Accepting this suggestion would create a redirect loop.', 400);
|
|
4478
|
+
}
|
|
4479
|
+
await db().redirect.create({
|
|
4480
|
+
data: {
|
|
4481
|
+
source: suggestion.fromPath,
|
|
4482
|
+
destination: suggestion.toPath,
|
|
4483
|
+
statusCode: 301,
|
|
4484
|
+
enabled: true,
|
|
4485
|
+
source_kind: 'ai-404-recovery',
|
|
4486
|
+
createdById: auth.session.userId,
|
|
4487
|
+
},
|
|
4262
4488
|
});
|
|
4263
|
-
|
|
4489
|
+
await db().redirectSuggestion.update({
|
|
4490
|
+
where: { id: suggestion.id },
|
|
4491
|
+
data: { status: 'accepted' },
|
|
4492
|
+
});
|
|
4493
|
+
// Mark the 404 resolved.
|
|
4494
|
+
if (hasModel(db(), 'redirect404Hit')) {
|
|
4495
|
+
await db()
|
|
4496
|
+
.redirect404Hit.updateMany({
|
|
4497
|
+
where: { path: suggestion.fromPath },
|
|
4498
|
+
data: { resolvedAt: new Date() },
|
|
4499
|
+
})
|
|
4500
|
+
.catch(() => { });
|
|
4501
|
+
}
|
|
4502
|
+
return json({ data: { success: true } });
|
|
4503
|
+
}
|
|
4504
|
+
catch (err) {
|
|
4505
|
+
return internalError(err, 'seo/redirects/suggestions/accept');
|
|
4506
|
+
}
|
|
4507
|
+
});
|
|
4508
|
+
router.post('/seo/redirects/suggestions/:id/dismiss', async (request, params) => {
|
|
4509
|
+
try {
|
|
4510
|
+
const auth = await requireAuth(request);
|
|
4511
|
+
if (auth.error)
|
|
4512
|
+
return auth.error;
|
|
4513
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4514
|
+
if (roleErr)
|
|
4515
|
+
return roleErr;
|
|
4516
|
+
if (!hasModel(db(), 'redirectSuggestion'))
|
|
4517
|
+
return modelNotAvailable('RedirectSuggestion');
|
|
4518
|
+
await db().redirectSuggestion.update({
|
|
4519
|
+
where: { id: params.id },
|
|
4520
|
+
data: { status: 'dismissed' },
|
|
4521
|
+
});
|
|
4522
|
+
return json({ data: { success: true } });
|
|
4523
|
+
}
|
|
4524
|
+
catch (err) {
|
|
4525
|
+
return internalError(err, 'seo/redirects/suggestions/dismiss');
|
|
4526
|
+
}
|
|
4527
|
+
});
|
|
4528
|
+
router.get('/seo/redirects/export', async (request) => {
|
|
4529
|
+
try {
|
|
4530
|
+
const auth = await requireAuth(request);
|
|
4531
|
+
if (auth.error)
|
|
4532
|
+
return auth.error;
|
|
4533
|
+
const rows = await db().redirect.findMany({ orderBy: { createdAt: 'desc' } });
|
|
4534
|
+
const header = 'from,to,type,enabled';
|
|
4535
|
+
const lines = rows.map((r) => {
|
|
4536
|
+
const esc = (v) => (/[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v);
|
|
4537
|
+
return [
|
|
4538
|
+
esc(String(r.source ?? '')),
|
|
4539
|
+
esc(String(r.destination ?? '')),
|
|
4540
|
+
String(r.statusCode ?? 301),
|
|
4541
|
+
r.enabled === false ? 'false' : 'true',
|
|
4542
|
+
].join(',');
|
|
4543
|
+
});
|
|
4544
|
+
return json({ data: { csv: [header, ...lines].join('\n') } });
|
|
4545
|
+
}
|
|
4546
|
+
catch (err) {
|
|
4547
|
+
return internalError(err, 'seo/redirects/export');
|
|
4548
|
+
}
|
|
4549
|
+
});
|
|
4550
|
+
router.post('/seo/redirects/import', async (request) => {
|
|
4551
|
+
try {
|
|
4552
|
+
const auth = await requireAuth(request);
|
|
4553
|
+
if (auth.error)
|
|
4554
|
+
return auth.error;
|
|
4555
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
4556
|
+
if (roleErr)
|
|
4557
|
+
return roleErr;
|
|
4558
|
+
const graph = await ensureRedirectGraph();
|
|
4559
|
+
const body = (await request.json().catch(() => ({})));
|
|
4560
|
+
const csv = String(body.csv ?? '').trim();
|
|
4561
|
+
if (!csv)
|
|
4562
|
+
return errorResponse('CSV body is required', 400);
|
|
4563
|
+
const lines = csv.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
4564
|
+
// Skip an optional header row.
|
|
4565
|
+
if (lines[0] && /from\s*,\s*to/i.test(lines[0]))
|
|
4566
|
+
lines.shift();
|
|
4567
|
+
const errors = [];
|
|
4568
|
+
let imported = 0;
|
|
4569
|
+
const existing = await db().redirect.findMany({});
|
|
4570
|
+
const working = existing.map((r) => ({
|
|
4571
|
+
id: r.id,
|
|
4572
|
+
from: r.source,
|
|
4573
|
+
to: r.destination,
|
|
4574
|
+
enabled: r.enabled,
|
|
4575
|
+
}));
|
|
4576
|
+
for (let idx = 0; idx < lines.length; idx++) {
|
|
4577
|
+
const cols = parseCsvLine(lines[idx]);
|
|
4578
|
+
const from = (cols[0] ?? '').trim();
|
|
4579
|
+
const to = (cols[1] ?? '').trim();
|
|
4580
|
+
const type = Number(cols[2] ?? 301);
|
|
4581
|
+
if (!from || !to) {
|
|
4582
|
+
errors.push(`Row ${idx + 1}: missing from/to`);
|
|
4583
|
+
continue;
|
|
4584
|
+
}
|
|
4585
|
+
const destErr = validateRedirectDestination(to, type);
|
|
4586
|
+
if (destErr) {
|
|
4587
|
+
errors.push(`Row ${idx + 1}: ${destErr}`);
|
|
4588
|
+
continue;
|
|
4589
|
+
}
|
|
4590
|
+
if (graph.wouldCreateLoop(working, from, to)) {
|
|
4591
|
+
errors.push(`Row ${idx + 1}: would create a redirect loop`);
|
|
4592
|
+
continue;
|
|
4593
|
+
}
|
|
4594
|
+
try {
|
|
4595
|
+
await db().redirect.upsert({
|
|
4596
|
+
where: { source: from },
|
|
4597
|
+
update: {
|
|
4598
|
+
destination: to,
|
|
4599
|
+
statusCode: REDIRECT_STATUS_CODES.includes(type) ? type : 301,
|
|
4600
|
+
},
|
|
4601
|
+
create: {
|
|
4602
|
+
source: from,
|
|
4603
|
+
destination: to,
|
|
4604
|
+
statusCode: REDIRECT_STATUS_CODES.includes(type) ? type : 301,
|
|
4605
|
+
source_kind: 'import',
|
|
4606
|
+
createdById: auth.session.userId,
|
|
4607
|
+
},
|
|
4608
|
+
});
|
|
4609
|
+
working.push({ id: `imp-${idx}`, from, to, enabled: true });
|
|
4610
|
+
imported++;
|
|
4611
|
+
}
|
|
4612
|
+
catch (e) {
|
|
4613
|
+
errors.push(`Row ${idx + 1}: ${e instanceof Error ? e.message : 'insert failed'}`);
|
|
4614
|
+
}
|
|
4615
|
+
}
|
|
4616
|
+
return json({ data: { imported, errors } });
|
|
4617
|
+
}
|
|
4618
|
+
catch (err) {
|
|
4619
|
+
return internalError(err, 'seo/redirects/import');
|
|
4620
|
+
}
|
|
4621
|
+
});
|
|
4622
|
+
router.post('/seo/redirects', async (request) => {
|
|
4623
|
+
try {
|
|
4624
|
+
const auth = await requireAuth(request);
|
|
4625
|
+
if (auth.error)
|
|
4626
|
+
return auth.error;
|
|
4627
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
4628
|
+
if (roleErr)
|
|
4629
|
+
return roleErr;
|
|
4630
|
+
const graph = await ensureRedirectGraph();
|
|
4631
|
+
const body = (await request.json().catch(() => ({})));
|
|
4632
|
+
const source = String(body.fromPath ?? body.source ?? '').trim();
|
|
4633
|
+
const destination = String(body.toPath ?? body.destination ?? '').trim();
|
|
4634
|
+
const statusCode = Number(body.type ?? body.statusCode ?? 301);
|
|
4635
|
+
if (!source)
|
|
4636
|
+
return errorResponse('fromPath is required', 400);
|
|
4637
|
+
if (!destination && statusCode !== 410)
|
|
4638
|
+
return errorResponse('toPath is required', 400);
|
|
4639
|
+
if (!REDIRECT_STATUS_CODES.includes(statusCode)) {
|
|
4640
|
+
return errorResponse('type must be one of 301, 302, 307, 308, 410', 400);
|
|
4641
|
+
}
|
|
4642
|
+
const destErr = validateRedirectDestination(destination, statusCode);
|
|
4643
|
+
if (destErr)
|
|
4644
|
+
return errorResponse(destErr, 400);
|
|
4645
|
+
const rows = await db().redirect.findMany({});
|
|
4646
|
+
if (rows.some((r) => graph.normalizeRedirectPath(r.source) === graph.normalizeRedirectPath(source))) {
|
|
4647
|
+
return errorResponse('A redirect with this source already exists.', 409);
|
|
4648
|
+
}
|
|
4649
|
+
if (graph.wouldCreateLoop(rows.map((r) => ({
|
|
4650
|
+
id: r.id,
|
|
4651
|
+
from: r.source,
|
|
4652
|
+
to: r.destination,
|
|
4653
|
+
enabled: r.enabled,
|
|
4654
|
+
})), source, destination || source)) {
|
|
4655
|
+
return errorResponse('This redirect would create a loop.', 400);
|
|
4656
|
+
}
|
|
4657
|
+
const created = await db().redirect.create({
|
|
4658
|
+
data: {
|
|
4659
|
+
source,
|
|
4660
|
+
destination: destination || source,
|
|
4661
|
+
statusCode,
|
|
4662
|
+
enabled: body.enabled !== false,
|
|
4663
|
+
preserveQuery: body.preserveQuery === true,
|
|
4664
|
+
notes: typeof body.notes === 'string' ? body.notes : null,
|
|
4665
|
+
source_kind: 'manual',
|
|
4666
|
+
createdById: auth.session.userId,
|
|
4667
|
+
},
|
|
4668
|
+
});
|
|
4669
|
+
return json({ data: redirectToRule(created) }, 201);
|
|
4670
|
+
}
|
|
4671
|
+
catch (err) {
|
|
4672
|
+
return internalError(err, 'seo/redirects POST');
|
|
4673
|
+
}
|
|
4674
|
+
});
|
|
4675
|
+
router.get('/seo/redirects/:id', async (request, params) => {
|
|
4676
|
+
try {
|
|
4677
|
+
const auth = await requireAuth(request);
|
|
4678
|
+
if (auth.error)
|
|
4679
|
+
return auth.error;
|
|
4680
|
+
await ensureRedirectGraph();
|
|
4681
|
+
const row = await db().redirect.findUnique({ where: { id: params.id } });
|
|
4682
|
+
if (!row)
|
|
4683
|
+
return errorResponse('Redirect not found', 404);
|
|
4684
|
+
return json({ data: redirectToRule(row) });
|
|
4685
|
+
}
|
|
4686
|
+
catch (err) {
|
|
4687
|
+
return internalError(err, 'seo/redirects/:id GET');
|
|
4688
|
+
}
|
|
4689
|
+
});
|
|
4690
|
+
router.put('/seo/redirects/:id', async (request, params) => {
|
|
4691
|
+
try {
|
|
4692
|
+
const auth = await requireAuth(request);
|
|
4693
|
+
if (auth.error)
|
|
4694
|
+
return auth.error;
|
|
4695
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
4696
|
+
if (roleErr)
|
|
4697
|
+
return roleErr;
|
|
4698
|
+
const graph = await ensureRedirectGraph();
|
|
4699
|
+
const existing = await db().redirect.findUnique({ where: { id: params.id } });
|
|
4700
|
+
if (!existing)
|
|
4701
|
+
return errorResponse('Redirect not found', 404);
|
|
4702
|
+
const body = (await request.json().catch(() => ({})));
|
|
4703
|
+
const source = body.fromPath !== undefined || body.source !== undefined
|
|
4704
|
+
? String(body.fromPath ?? body.source ?? '').trim()
|
|
4705
|
+
: existing.source;
|
|
4706
|
+
const destination = body.toPath !== undefined || body.destination !== undefined
|
|
4707
|
+
? String(body.toPath ?? body.destination ?? '').trim()
|
|
4708
|
+
: existing.destination;
|
|
4709
|
+
const statusCode = body.type !== undefined || body.statusCode !== undefined
|
|
4710
|
+
? Number(body.type ?? body.statusCode)
|
|
4711
|
+
: existing.statusCode;
|
|
4712
|
+
if (!source)
|
|
4713
|
+
return errorResponse('fromPath is required', 400);
|
|
4714
|
+
if (!REDIRECT_STATUS_CODES.includes(statusCode)) {
|
|
4715
|
+
return errorResponse('type must be one of 301, 302, 307, 308, 410', 400);
|
|
4716
|
+
}
|
|
4717
|
+
const destErr = validateRedirectDestination(destination, statusCode);
|
|
4718
|
+
if (destErr)
|
|
4719
|
+
return errorResponse(destErr, 400);
|
|
4720
|
+
const rows = await db().redirect.findMany({});
|
|
4721
|
+
if (graph.wouldCreateLoop(rows.map((r) => ({
|
|
4722
|
+
id: r.id,
|
|
4723
|
+
from: r.source,
|
|
4724
|
+
to: r.destination,
|
|
4725
|
+
enabled: r.enabled,
|
|
4726
|
+
})), source, destination || source, existing.id)) {
|
|
4727
|
+
return errorResponse('This change would create a redirect loop.', 400);
|
|
4728
|
+
}
|
|
4729
|
+
const updated = await db().redirect.update({
|
|
4730
|
+
where: { id: existing.id },
|
|
4731
|
+
data: {
|
|
4732
|
+
source,
|
|
4733
|
+
destination: destination || source,
|
|
4734
|
+
statusCode,
|
|
4735
|
+
...(body.enabled !== undefined ? { enabled: body.enabled === true } : {}),
|
|
4736
|
+
...(body.preserveQuery !== undefined
|
|
4737
|
+
? { preserveQuery: body.preserveQuery === true }
|
|
4738
|
+
: {}),
|
|
4739
|
+
...(body.notes !== undefined
|
|
4740
|
+
? { notes: typeof body.notes === 'string' ? body.notes : null }
|
|
4741
|
+
: {}),
|
|
4742
|
+
},
|
|
4743
|
+
});
|
|
4744
|
+
return json({ data: redirectToRule(updated) });
|
|
4745
|
+
}
|
|
4746
|
+
catch (err) {
|
|
4747
|
+
return internalError(err, 'seo/redirects/:id PUT');
|
|
4748
|
+
}
|
|
4749
|
+
});
|
|
4750
|
+
router.delete('/seo/redirects/:id', async (request, params) => {
|
|
4751
|
+
try {
|
|
4752
|
+
const auth = await requireAuth(request);
|
|
4753
|
+
if (auth.error)
|
|
4754
|
+
return auth.error;
|
|
4755
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
4756
|
+
if (roleErr)
|
|
4757
|
+
return roleErr;
|
|
4758
|
+
await db().redirect.delete({ where: { id: params.id } });
|
|
4759
|
+
return json({ data: { success: true } });
|
|
4760
|
+
}
|
|
4761
|
+
catch (err) {
|
|
4762
|
+
return internalError(err, 'seo/redirects/:id DELETE');
|
|
4763
|
+
}
|
|
4764
|
+
});
|
|
4765
|
+
// Public: resolve a path against enabled redirects. Used by the redirect
|
|
4766
|
+
// middleware. Increments hit counters on a match; logs a 404 otherwise.
|
|
4767
|
+
router.get('/redirects/resolve', async (request) => {
|
|
4768
|
+
try {
|
|
4769
|
+
const graph = await ensureRedirectGraph();
|
|
4770
|
+
const url = new URL(request.url);
|
|
4771
|
+
const path = url.searchParams.get('path') ?? '';
|
|
4772
|
+
if (!path)
|
|
4773
|
+
return json({ data: { match: null } });
|
|
4774
|
+
const norm = graph.normalizeRedirectPath(path);
|
|
4775
|
+
const rows = await db().redirect.findMany({
|
|
4776
|
+
where: { enabled: true },
|
|
4777
|
+
orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }],
|
|
4778
|
+
});
|
|
4779
|
+
const match = rows.find((r) => graph.normalizeRedirectPath(r.source) === norm);
|
|
4780
|
+
if (!match) {
|
|
4781
|
+
return json({ data: { match: null } });
|
|
4782
|
+
}
|
|
4783
|
+
db()
|
|
4784
|
+
.redirect.update({
|
|
4785
|
+
where: { id: match.id },
|
|
4786
|
+
data: { hits: { increment: 1 }, lastHitAt: new Date() },
|
|
4787
|
+
})
|
|
4788
|
+
.catch(() => { });
|
|
4789
|
+
return json({
|
|
4790
|
+
data: {
|
|
4791
|
+
match: {
|
|
4792
|
+
to: match.destination,
|
|
4793
|
+
statusCode: Number(match.statusCode ?? 301),
|
|
4794
|
+
preserveQuery: match.preserveQuery ?? false,
|
|
4795
|
+
},
|
|
4796
|
+
},
|
|
4797
|
+
});
|
|
4798
|
+
}
|
|
4799
|
+
catch (err) {
|
|
4800
|
+
return internalError(err, 'redirects/resolve');
|
|
4801
|
+
}
|
|
4802
|
+
});
|
|
4803
|
+
// Public: enabled redirects in the plugin-redirects middleware shape. Backs
|
|
4804
|
+
// `createRedirectMiddleware`'s `getRedirects` (cached by the factory).
|
|
4805
|
+
router.get('/redirects/public', async () => {
|
|
4806
|
+
try {
|
|
4807
|
+
const rows = await db().redirect.findMany({
|
|
4808
|
+
where: { enabled: true },
|
|
4809
|
+
orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }],
|
|
4810
|
+
select: {
|
|
4811
|
+
id: true,
|
|
4812
|
+
source: true,
|
|
4813
|
+
destination: true,
|
|
4814
|
+
statusCode: true,
|
|
4815
|
+
preserveQuery: true,
|
|
4816
|
+
},
|
|
4817
|
+
});
|
|
4818
|
+
return json({
|
|
4819
|
+
data: {
|
|
4820
|
+
redirects: rows.map((r) => ({
|
|
4821
|
+
id: r.id,
|
|
4822
|
+
fromUrl: r.source,
|
|
4823
|
+
toUrl: r.destination,
|
|
4824
|
+
type: String([301, 302].includes(Number(r.statusCode)) ? r.statusCode : 301),
|
|
4825
|
+
statusCode: Number(r.statusCode ?? 301),
|
|
4826
|
+
preserveQuery: r.preserveQuery ?? false,
|
|
4827
|
+
isActive: true,
|
|
4828
|
+
})),
|
|
4829
|
+
},
|
|
4830
|
+
});
|
|
4831
|
+
}
|
|
4832
|
+
catch (err) {
|
|
4833
|
+
return internalError(err, 'redirects/public');
|
|
4834
|
+
}
|
|
4835
|
+
});
|
|
4836
|
+
// Public: increment hit counters for a matched source path. Fire-and-forget
|
|
4837
|
+
// from the middleware so request latency is unaffected.
|
|
4838
|
+
router.post('/redirects/record-hit', async (request) => {
|
|
4839
|
+
try {
|
|
4840
|
+
const graph = await ensureRedirectGraph();
|
|
4841
|
+
const body = (await request.json().catch(() => ({})));
|
|
4842
|
+
const norm = graph.normalizeRedirectPath(String(body.path ?? ''));
|
|
4843
|
+
if (!norm)
|
|
4844
|
+
return json({ data: { ok: false } });
|
|
4845
|
+
const rows = await db().redirect.findMany({
|
|
4846
|
+
where: { enabled: true },
|
|
4847
|
+
select: { id: true, source: true },
|
|
4848
|
+
});
|
|
4849
|
+
const match = rows.find((r) => graph.normalizeRedirectPath(r.source) === norm);
|
|
4850
|
+
if (!match)
|
|
4851
|
+
return json({ data: { ok: false } });
|
|
4852
|
+
await db()
|
|
4853
|
+
.redirect.update({
|
|
4854
|
+
where: { id: match.id },
|
|
4855
|
+
data: { hits: { increment: 1 }, lastHitAt: new Date() },
|
|
4856
|
+
})
|
|
4857
|
+
.catch(() => { });
|
|
4858
|
+
return json({ data: { ok: true } });
|
|
4859
|
+
}
|
|
4860
|
+
catch (err) {
|
|
4861
|
+
return internalError(err, 'redirects/record-hit');
|
|
4862
|
+
}
|
|
4863
|
+
});
|
|
4864
|
+
// Public: record a 404 for a path (called by the site when it renders
|
|
4865
|
+
// notFound). Aggregates per-path so the Redirects tab can surface dead URLs.
|
|
4866
|
+
router.post('/redirects/record-404', async (request) => {
|
|
4867
|
+
try {
|
|
4868
|
+
if (!hasModel(db(), 'redirect404Hit'))
|
|
4869
|
+
return json({ data: { ok: false } });
|
|
4870
|
+
const graph = await ensureRedirectGraph();
|
|
4871
|
+
const body = (await request.json().catch(() => ({})));
|
|
4872
|
+
const path = graph.normalizeRedirectPath(String(body.path ?? ''));
|
|
4873
|
+
if (!path || path === '/')
|
|
4874
|
+
return json({ data: { ok: false } });
|
|
4875
|
+
await db().redirect404Hit.upsert({
|
|
4876
|
+
where: { path },
|
|
4877
|
+
update: { hits: { increment: 1 }, lastSeenAt: new Date() },
|
|
4878
|
+
create: { path, hits: 1, referrer: body.referrer ?? null },
|
|
4879
|
+
});
|
|
4880
|
+
return json({ data: { ok: true } });
|
|
4881
|
+
}
|
|
4882
|
+
catch (err) {
|
|
4883
|
+
return internalError(err, 'redirects/record-404');
|
|
4884
|
+
}
|
|
4885
|
+
});
|
|
4886
|
+
// ---------------------------------------------------------------------------
|
|
4887
|
+
// SEO routes
|
|
4888
|
+
// ---------------------------------------------------------------------------
|
|
4889
|
+
router.get('/seo/pages', async (request) => {
|
|
4890
|
+
try {
|
|
4891
|
+
const auth = await requireAuth(request);
|
|
4892
|
+
if (auth.error)
|
|
4893
|
+
return auth.error;
|
|
4894
|
+
const pages = await db().document.findMany({
|
|
4895
|
+
where: { deletedAt: null, status: 'PUBLISHED' },
|
|
4896
|
+
select: {
|
|
4897
|
+
id: true,
|
|
4898
|
+
collection: true,
|
|
4899
|
+
data: true,
|
|
4900
|
+
updatedAt: true,
|
|
4901
|
+
structuredData: true,
|
|
4902
|
+
},
|
|
4903
|
+
orderBy: { updatedAt: 'desc' },
|
|
4904
|
+
});
|
|
4905
|
+
return json({ data: pages.map(normalizeSeoPage) });
|
|
4906
|
+
}
|
|
4907
|
+
catch (err) {
|
|
4908
|
+
return internalError(err);
|
|
4909
|
+
}
|
|
4910
|
+
});
|
|
4911
|
+
router.get('/seo/link-health', async (request) => {
|
|
4912
|
+
try {
|
|
4913
|
+
const auth = await requireAuth(request);
|
|
4914
|
+
if (auth.error)
|
|
4915
|
+
return auth.error;
|
|
4916
|
+
// EDITOR+ only — this endpoint hits arbitrary URLs and can be expensive.
|
|
4917
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4918
|
+
if (roleErr)
|
|
4919
|
+
return roleErr;
|
|
4920
|
+
// Tight rate limit because each call fans out to many outbound requests.
|
|
4921
|
+
const ip = clientIp(request);
|
|
4922
|
+
const rateKey = isResolvedIp(ip)
|
|
4923
|
+
? `link-health:${ip}`
|
|
4924
|
+
: `link-health-user:${auth.session.userId}`;
|
|
4925
|
+
if (!(await checkRateLimitAsync(linkHealthLimiter, rateKey))) {
|
|
4926
|
+
return errorResponse('Too many link-health scans. Please try again later.', 429);
|
|
4927
|
+
}
|
|
4928
|
+
const MAX_LINKS_PER_PAGE = 50;
|
|
4929
|
+
const MAX_TOTAL_LINKS = 500;
|
|
4930
|
+
const PER_LINK_TIMEOUT_MS = 4000;
|
|
4931
|
+
const CONCURRENCY = 8;
|
|
4932
|
+
const docs = await db().document.findMany({
|
|
4933
|
+
where: { deletedAt: null, status: 'PUBLISHED' },
|
|
4934
|
+
select: { id: true, title: true, data: true, collection: true },
|
|
4935
|
+
});
|
|
4936
|
+
const urlRegex = /https?:\/\/[^\s"'<>]+/g;
|
|
4937
|
+
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? '';
|
|
4938
|
+
const queue = [];
|
|
4939
|
+
const seenGlobal = new Set();
|
|
4940
|
+
outer: for (const doc of docs) {
|
|
4941
|
+
const pageTitle = doc.title ?? doc.data?.title ?? doc.id;
|
|
4942
|
+
const content = JSON.stringify(doc.data ?? {});
|
|
4943
|
+
const urls = content.match(urlRegex) ?? [];
|
|
4944
|
+
const seenInPage = new Set();
|
|
4945
|
+
let countInPage = 0;
|
|
4946
|
+
for (const url of urls) {
|
|
4947
|
+
const clean = url.replace(/[",;)}\]]+$/, '');
|
|
4948
|
+
if (seenInPage.has(clean))
|
|
4949
|
+
continue;
|
|
4950
|
+
seenInPage.add(clean);
|
|
4951
|
+
if (seenGlobal.has(clean))
|
|
4952
|
+
continue;
|
|
4953
|
+
seenGlobal.add(clean);
|
|
4954
|
+
if (countInPage >= MAX_LINKS_PER_PAGE)
|
|
4955
|
+
break;
|
|
4956
|
+
if (queue.length >= MAX_TOTAL_LINKS)
|
|
4957
|
+
break outer;
|
|
4958
|
+
const isInternal = !!siteUrl && clean.startsWith(siteUrl);
|
|
4959
|
+
queue.push({ docId: doc.id, pageTitle, clean, isInternal });
|
|
4960
|
+
countInPage++;
|
|
4961
|
+
}
|
|
4962
|
+
}
|
|
4963
|
+
const linkResults = [];
|
|
4964
|
+
let cursor = 0;
|
|
4965
|
+
const worker = async () => {
|
|
4966
|
+
while (cursor < queue.length) {
|
|
4967
|
+
const idx = cursor++;
|
|
4968
|
+
const job = queue[idx];
|
|
4969
|
+
let status = 0;
|
|
4970
|
+
try {
|
|
4971
|
+
// safeFetch rejects private/loopback IPs and disables redirect
|
|
4972
|
+
// following so 302->internal can't smuggle the scanner past SSRF.
|
|
4973
|
+
const resp = await safeFetch(job.clean, {
|
|
4974
|
+
method: 'HEAD',
|
|
4975
|
+
timeoutMs: PER_LINK_TIMEOUT_MS,
|
|
4976
|
+
});
|
|
4977
|
+
status = resp.status;
|
|
4978
|
+
// Drain the body so the connection can be reused.
|
|
4979
|
+
try {
|
|
4980
|
+
await resp.body?.cancel();
|
|
4981
|
+
}
|
|
4982
|
+
catch {
|
|
4983
|
+
/* noop */
|
|
4984
|
+
}
|
|
4985
|
+
}
|
|
4986
|
+
catch (err) {
|
|
4987
|
+
status = err instanceof SsrfBlockedError ? -1 : 0;
|
|
4988
|
+
}
|
|
4989
|
+
if (status === -1 || status === 0 || status >= 300) {
|
|
4990
|
+
linkResults.push({
|
|
4991
|
+
id: `${job.docId}-${idx}`,
|
|
4992
|
+
page: job.pageTitle,
|
|
4993
|
+
url: job.clean,
|
|
4994
|
+
status: status === -1 ? 0 : status,
|
|
4995
|
+
type: job.isInternal ? 'internal' : 'external',
|
|
4996
|
+
});
|
|
4997
|
+
}
|
|
4998
|
+
}
|
|
4999
|
+
};
|
|
5000
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, worker));
|
|
5001
|
+
return json({
|
|
5002
|
+
data: {
|
|
5003
|
+
truncated: queue.length >= MAX_TOTAL_LINKS,
|
|
5004
|
+
checked: queue.length,
|
|
5005
|
+
issues: linkResults,
|
|
5006
|
+
},
|
|
5007
|
+
});
|
|
5008
|
+
}
|
|
5009
|
+
catch (err) {
|
|
5010
|
+
return internalError(err);
|
|
5011
|
+
}
|
|
5012
|
+
});
|
|
5013
|
+
router.post('/seo/scan', async (request) => {
|
|
5014
|
+
try {
|
|
5015
|
+
const auth = await requireAuth(request);
|
|
5016
|
+
if (auth.error)
|
|
5017
|
+
return auth.error;
|
|
5018
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
5019
|
+
if (roleErr)
|
|
5020
|
+
return roleErr;
|
|
5021
|
+
const documents = await db().document.findMany({
|
|
5022
|
+
where: { status: 'PUBLISHED', deletedAt: null },
|
|
5023
|
+
select: {
|
|
5024
|
+
id: true,
|
|
5025
|
+
title: true,
|
|
5026
|
+
slug: true,
|
|
5027
|
+
collection: true,
|
|
5028
|
+
data: true,
|
|
5029
|
+
plainText: true,
|
|
5030
|
+
},
|
|
5031
|
+
});
|
|
5032
|
+
const issues = [];
|
|
5033
|
+
for (const doc of documents) {
|
|
5034
|
+
const data = (doc.data ?? {});
|
|
5035
|
+
const problems = [];
|
|
5036
|
+
if (!data.metaTitle && !data.seoTitle)
|
|
5037
|
+
problems.push('Missing meta title');
|
|
5038
|
+
if (!data.metaDescription && !data.seoDescription)
|
|
5039
|
+
problems.push('Missing meta description');
|
|
5040
|
+
if (!data.canonical)
|
|
5041
|
+
problems.push('No canonical URL set');
|
|
5042
|
+
if (!data.schemaType)
|
|
5043
|
+
problems.push('No Schema.org type');
|
|
5044
|
+
const plainText = (doc.plainText ?? '');
|
|
5045
|
+
if (plainText.length > 0 && plainText.length < 300)
|
|
5046
|
+
problems.push('Content is too short (< 300 characters)');
|
|
5047
|
+
const content = typeof data.body === 'string'
|
|
5048
|
+
? data.body
|
|
5049
|
+
: typeof data.content === 'string'
|
|
5050
|
+
? data.content
|
|
5051
|
+
: '';
|
|
5052
|
+
if (content) {
|
|
5053
|
+
const imgMatches = content.match(/<img\b[^>]*>/gi) ?? [];
|
|
5054
|
+
const missingAlt = imgMatches.filter((img) => !img.includes('alt=')).length;
|
|
5055
|
+
if (missingAlt > 0)
|
|
5056
|
+
problems.push(`${missingAlt} image(s) missing alt text`);
|
|
5057
|
+
if (!content.includes('<h1') && !content.includes('<h1>'))
|
|
5058
|
+
problems.push('No H1 heading found in content');
|
|
5059
|
+
}
|
|
5060
|
+
if (problems.length > 0) {
|
|
5061
|
+
issues.push({
|
|
5062
|
+
documentId: doc.id,
|
|
5063
|
+
title: doc.title ?? 'Untitled',
|
|
5064
|
+
slug: doc.slug ?? '',
|
|
5065
|
+
problems,
|
|
5066
|
+
});
|
|
5067
|
+
}
|
|
5068
|
+
}
|
|
5069
|
+
const total = documents.length;
|
|
5070
|
+
const pagesWithIssues = issues.length;
|
|
5071
|
+
const totalProblems = issues.reduce((sum, i) => sum + i.problems.length, 0);
|
|
5072
|
+
return json({ data: { total, pagesWithIssues, totalProblems, issues } });
|
|
5073
|
+
}
|
|
5074
|
+
catch (err) {
|
|
5075
|
+
return internalError(err);
|
|
5076
|
+
}
|
|
5077
|
+
});
|
|
5078
|
+
// ---------------------------------------------------------------------------
|
|
5079
|
+
// SEO Operations Center — audit engine, issues, overview, content records
|
|
5080
|
+
// ---------------------------------------------------------------------------
|
|
5081
|
+
function seoIssueToApi(i) {
|
|
5082
|
+
return {
|
|
5083
|
+
id: i.id,
|
|
5084
|
+
auditRunId: i.auditRunId ?? null,
|
|
5085
|
+
entityType: i.entityType ?? null,
|
|
5086
|
+
entityId: i.entityId ?? null,
|
|
5087
|
+
entityTitle: i.entityTitle ?? null,
|
|
5088
|
+
url: i.url ?? null,
|
|
5089
|
+
severity: i.severity,
|
|
5090
|
+
category: i.category,
|
|
5091
|
+
type: i.type,
|
|
5092
|
+
title: i.title,
|
|
5093
|
+
description: i.description ?? null,
|
|
5094
|
+
impact: i.impact ?? null,
|
|
5095
|
+
recommendation: i.recommendation ?? null,
|
|
5096
|
+
status: i.status,
|
|
5097
|
+
fixable: i.fixable ?? false,
|
|
5098
|
+
fixActionType: i.fixActionType ?? null,
|
|
5099
|
+
relatedEntityIds: Array.isArray(i.relatedEntityIds) ? i.relatedEntityIds : [],
|
|
5100
|
+
source: i.source ?? 'audit',
|
|
5101
|
+
firstDetectedAt: i.firstDetectedAt ?? null,
|
|
5102
|
+
lastDetectedAt: i.lastDetectedAt ?? null,
|
|
5103
|
+
resolvedAt: i.resolvedAt ?? null,
|
|
5104
|
+
};
|
|
5105
|
+
}
|
|
5106
|
+
async function buildSeoContentRecords() {
|
|
5107
|
+
const { gatherAuditEntities } = await import('../seo/audit-runner.js');
|
|
5108
|
+
const { calculatePageSeoScore } = await import('../seo/score.js');
|
|
5109
|
+
const entities = await gatherAuditEntities(db());
|
|
5110
|
+
return entities.map((e) => {
|
|
5111
|
+
const score = calculatePageSeoScore(e);
|
|
5112
|
+
return {
|
|
5113
|
+
id: `${e.entityType}:${e.entityId}`,
|
|
5114
|
+
entityType: e.entityType,
|
|
5115
|
+
entityId: e.entityId,
|
|
5116
|
+
title: e.title,
|
|
5117
|
+
url: e.url,
|
|
5118
|
+
canonicalUrl: e.canonicalUrl ?? null,
|
|
5119
|
+
metaTitle: e.metaTitle ?? null,
|
|
5120
|
+
metaDescription: e.metaDescription ?? null,
|
|
5121
|
+
focusKeyword: e.focusKeyword ?? null,
|
|
5122
|
+
ogTitle: e.ogTitle ?? null,
|
|
5123
|
+
ogDescription: e.ogDescription ?? null,
|
|
5124
|
+
ogImageId: null,
|
|
5125
|
+
ogImage: e.ogImage ?? null,
|
|
5126
|
+
twitterTitle: null,
|
|
5127
|
+
twitterDescription: null,
|
|
5128
|
+
twitterImageId: null,
|
|
5129
|
+
noindex: e.noindex ?? false,
|
|
5130
|
+
nofollow: false,
|
|
5131
|
+
structuredDataType: e.structuredDataType ?? null,
|
|
5132
|
+
structuredData: null,
|
|
5133
|
+
seoScore: score.score,
|
|
5134
|
+
seoGrade: score.grade,
|
|
5135
|
+
updatedAt: null,
|
|
5136
|
+
};
|
|
5137
|
+
});
|
|
5138
|
+
}
|
|
5139
|
+
// Demo/seed search-performance rows live in a hidden collection so they can
|
|
5140
|
+
// be inserted by the dev seed script and never ship to npm. In production
|
|
5141
|
+
// with a connected Search Console these would come from the GSC API.
|
|
5142
|
+
const SEO_PERF_COLLECTION = '__seo_perf__';
|
|
5143
|
+
async function loadSearchPerformance(limit = 10) {
|
|
5144
|
+
try {
|
|
5145
|
+
const docs = await db().document.findMany({
|
|
5146
|
+
where: { collection: SEO_PERF_COLLECTION, deletedAt: null },
|
|
5147
|
+
select: { data: true },
|
|
5148
|
+
});
|
|
5149
|
+
const rows = docs
|
|
5150
|
+
.map((d) => {
|
|
5151
|
+
const data = asRecord(d.data);
|
|
5152
|
+
return {
|
|
5153
|
+
url: String(data.url ?? ''),
|
|
5154
|
+
title: String(data.title ?? ''),
|
|
5155
|
+
impressions: Number(data.impressions ?? 0),
|
|
5156
|
+
clicks: Number(data.clicks ?? 0),
|
|
5157
|
+
ctr: Number(data.ctr ?? 0),
|
|
5158
|
+
averagePosition: Number(data.averagePosition ?? 0),
|
|
5159
|
+
};
|
|
5160
|
+
})
|
|
5161
|
+
.filter((r) => r.url);
|
|
5162
|
+
rows.sort((a, b) => b.impressions - a.impressions);
|
|
5163
|
+
return rows.slice(0, limit);
|
|
5164
|
+
}
|
|
5165
|
+
catch {
|
|
5166
|
+
return [];
|
|
5167
|
+
}
|
|
5168
|
+
}
|
|
5169
|
+
router.post('/seo/audit', async (request) => {
|
|
5170
|
+
try {
|
|
5171
|
+
const auth = await requireAuth(request);
|
|
5172
|
+
if (auth.error)
|
|
5173
|
+
return auth.error;
|
|
5174
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
5175
|
+
if (roleErr)
|
|
5176
|
+
return roleErr;
|
|
5177
|
+
if (!hasModel(db(), 'seoAuditRun') || !hasModel(db(), 'seoIssue')) {
|
|
5178
|
+
return modelNotAvailable('SeoAuditRun');
|
|
5179
|
+
}
|
|
5180
|
+
const ip = clientIp(request);
|
|
5181
|
+
const rateKey = isResolvedIp(ip) ? `seo-audit:${ip}` : `seo-audit-user:${auth.session.userId}`;
|
|
5182
|
+
if (!(await checkRateLimitAsync(seoAuditLimiter, rateKey))) {
|
|
5183
|
+
return errorResponse('Too many audits. Please try again later.', 429);
|
|
5184
|
+
}
|
|
5185
|
+
const body = (await request.json().catch(() => ({})));
|
|
5186
|
+
const scope = body?.scope ?? 'full';
|
|
5187
|
+
const { runAndPersistAudit } = await import('../seo/audit-runner.js');
|
|
5188
|
+
const run = await runAndPersistAudit(db(), { scope, startedById: auth.session.userId });
|
|
5189
|
+
try {
|
|
5190
|
+
await logEvent({
|
|
5191
|
+
event: 'settings_changed',
|
|
5192
|
+
userId: auth.session.userId,
|
|
5193
|
+
details: { action: 'seo_audit', auditRunId: run.id, scope },
|
|
5194
|
+
});
|
|
5195
|
+
}
|
|
5196
|
+
catch {
|
|
5197
|
+
/* audit-log failures never block */
|
|
5198
|
+
}
|
|
5199
|
+
return json({ data: run });
|
|
5200
|
+
}
|
|
5201
|
+
catch (err) {
|
|
5202
|
+
return internalError(err, 'seo/audit');
|
|
5203
|
+
}
|
|
5204
|
+
});
|
|
5205
|
+
router.get('/seo/audits', async (request) => {
|
|
5206
|
+
try {
|
|
5207
|
+
const auth = await requireAuth(request);
|
|
5208
|
+
if (auth.error)
|
|
5209
|
+
return auth.error;
|
|
5210
|
+
if (!hasModel(db(), 'seoAuditRun'))
|
|
5211
|
+
return json({ data: { runs: [] } });
|
|
5212
|
+
const runs = await db().seoAuditRun.findMany({
|
|
5213
|
+
orderBy: { startedAt: 'desc' },
|
|
5214
|
+
take: 50,
|
|
5215
|
+
});
|
|
5216
|
+
return json({ data: { runs } });
|
|
5217
|
+
}
|
|
5218
|
+
catch (err) {
|
|
5219
|
+
return internalError(err, 'seo/audits');
|
|
5220
|
+
}
|
|
5221
|
+
});
|
|
5222
|
+
router.get('/seo/audits/:id', async (request, params) => {
|
|
5223
|
+
try {
|
|
5224
|
+
const auth = await requireAuth(request);
|
|
5225
|
+
if (auth.error)
|
|
5226
|
+
return auth.error;
|
|
5227
|
+
if (!hasModel(db(), 'seoAuditRun'))
|
|
5228
|
+
return modelNotAvailable('SeoAuditRun');
|
|
5229
|
+
const run = await db().seoAuditRun.findUnique({ where: { id: params.id } });
|
|
5230
|
+
if (!run)
|
|
5231
|
+
return errorResponse('Audit run not found', 404);
|
|
5232
|
+
return json({ data: run });
|
|
5233
|
+
}
|
|
5234
|
+
catch (err) {
|
|
5235
|
+
return internalError(err, 'seo/audits/:id');
|
|
5236
|
+
}
|
|
5237
|
+
});
|
|
5238
|
+
router.get('/seo/issues', async (request) => {
|
|
5239
|
+
try {
|
|
5240
|
+
const auth = await requireAuth(request);
|
|
5241
|
+
if (auth.error)
|
|
5242
|
+
return auth.error;
|
|
5243
|
+
if (!hasModel(db(), 'seoIssue'))
|
|
5244
|
+
return json({ data: { issues: [] } });
|
|
5245
|
+
const url = new URL(request.url);
|
|
5246
|
+
const where = {};
|
|
5247
|
+
const severity = url.searchParams.get('severity');
|
|
5248
|
+
const category = url.searchParams.get('category');
|
|
5249
|
+
const status = url.searchParams.get('status');
|
|
5250
|
+
const auditRunId = url.searchParams.get('auditRunId');
|
|
5251
|
+
if (severity)
|
|
5252
|
+
where.severity = severity;
|
|
5253
|
+
if (category)
|
|
5254
|
+
where.category = category;
|
|
5255
|
+
if (status)
|
|
5256
|
+
where.status = status;
|
|
5257
|
+
else
|
|
5258
|
+
where.status = { in: ['open', 'recurring'] };
|
|
5259
|
+
if (auditRunId)
|
|
5260
|
+
where.auditRunId = auditRunId;
|
|
5261
|
+
const issues = await db().seoIssue.findMany({
|
|
5262
|
+
where,
|
|
5263
|
+
orderBy: [{ severity: 'asc' }, { lastDetectedAt: 'desc' }],
|
|
5264
|
+
take: 500,
|
|
5265
|
+
});
|
|
5266
|
+
return json({ data: { issues: issues.map(seoIssueToApi) } });
|
|
5267
|
+
}
|
|
5268
|
+
catch (err) {
|
|
5269
|
+
return internalError(err, 'seo/issues');
|
|
5270
|
+
}
|
|
5271
|
+
});
|
|
5272
|
+
router.get('/seo/issues/:id', async (request, params) => {
|
|
5273
|
+
try {
|
|
5274
|
+
const auth = await requireAuth(request);
|
|
5275
|
+
if (auth.error)
|
|
5276
|
+
return auth.error;
|
|
5277
|
+
if (!hasModel(db(), 'seoIssue'))
|
|
5278
|
+
return modelNotAvailable('SeoIssue');
|
|
5279
|
+
const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
|
|
5280
|
+
if (!issue)
|
|
5281
|
+
return errorResponse('Issue not found', 404);
|
|
5282
|
+
return json({ data: seoIssueToApi(issue) });
|
|
5283
|
+
}
|
|
5284
|
+
catch (err) {
|
|
5285
|
+
return internalError(err, 'seo/issues/:id');
|
|
5286
|
+
}
|
|
5287
|
+
});
|
|
5288
|
+
async function setIssueStatus(request, issueId, status) {
|
|
5289
|
+
const auth = await requireAuth(request);
|
|
5290
|
+
if (auth.error)
|
|
5291
|
+
return auth.error;
|
|
5292
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
5293
|
+
if (roleErr)
|
|
5294
|
+
return roleErr;
|
|
5295
|
+
if (!hasModel(db(), 'seoIssue'))
|
|
5296
|
+
return modelNotAvailable('SeoIssue');
|
|
5297
|
+
const existing = await db().seoIssue.findUnique({ where: { id: issueId } });
|
|
5298
|
+
if (!existing)
|
|
5299
|
+
return errorResponse('Issue not found', 404);
|
|
5300
|
+
const issue = await db().seoIssue.update({
|
|
5301
|
+
where: { id: issueId },
|
|
5302
|
+
data: {
|
|
5303
|
+
status,
|
|
5304
|
+
resolvedAt: status === 'resolved' ? new Date() : null,
|
|
5305
|
+
resolvedById: status === 'resolved' ? auth.session.userId : null,
|
|
5306
|
+
},
|
|
5307
|
+
});
|
|
5308
|
+
try {
|
|
5309
|
+
await logEvent({
|
|
5310
|
+
event: 'settings_changed',
|
|
5311
|
+
userId: auth.session.userId,
|
|
5312
|
+
details: { action: 'seo_issue_status', issueId, status },
|
|
5313
|
+
});
|
|
5314
|
+
}
|
|
5315
|
+
catch {
|
|
5316
|
+
/* noop */
|
|
5317
|
+
}
|
|
5318
|
+
return json({ data: seoIssueToApi(issue) });
|
|
5319
|
+
}
|
|
5320
|
+
router.post('/seo/issues/:id/resolve', (request, params) => setIssueStatus(request, params.id, 'resolved').catch((e) => internalError(e)));
|
|
5321
|
+
router.post('/seo/issues/:id/ignore', (request, params) => setIssueStatus(request, params.id, 'ignored').catch((e) => internalError(e)));
|
|
5322
|
+
router.post('/seo/issues/:id/reopen', (request, params) => setIssueStatus(request, params.id, 'open').catch((e) => internalError(e)));
|
|
5323
|
+
router.get('/seo/overview', async (request) => {
|
|
5324
|
+
try {
|
|
5325
|
+
const auth = await requireAuth(request);
|
|
5326
|
+
if (auth.error)
|
|
5327
|
+
return auth.error;
|
|
5328
|
+
const { resolveSeoModuleSettings, getSeoOverrides } = await import('../seo/config-store.js');
|
|
5329
|
+
const { buildQuickWins } = await import('../seo/audit-engine.js');
|
|
5330
|
+
const overrides = await getSeoOverrides(db()).catch(() => null);
|
|
5331
|
+
const moduleSettings = resolveSeoModuleSettings(overrides);
|
|
5332
|
+
let lastRun = null;
|
|
5333
|
+
let openIssues = [];
|
|
5334
|
+
if (hasModel(db(), 'seoAuditRun')) {
|
|
5335
|
+
lastRun = await db().seoAuditRun.findFirst({
|
|
5336
|
+
where: { status: 'COMPLETED' },
|
|
5337
|
+
orderBy: { startedAt: 'desc' },
|
|
5338
|
+
});
|
|
5339
|
+
}
|
|
5340
|
+
if (hasModel(db(), 'seoIssue')) {
|
|
5341
|
+
openIssues = await db().seoIssue.findMany({
|
|
5342
|
+
where: { status: { in: ['open', 'recurring'] } },
|
|
5343
|
+
orderBy: [{ severity: 'asc' }, { lastDetectedAt: 'desc' }],
|
|
5344
|
+
take: 200,
|
|
5345
|
+
});
|
|
5346
|
+
}
|
|
5347
|
+
const records = await buildSeoContentRecords().catch(() => []);
|
|
5348
|
+
const indexable = records.filter((r) => !r.noindex).length;
|
|
5349
|
+
const perf = await loadSearchPerformance(10);
|
|
5350
|
+
const summary = {
|
|
5351
|
+
total: openIssues.length,
|
|
5352
|
+
critical: openIssues.filter((i) => i.severity === 'critical').length,
|
|
5353
|
+
warning: openIssues.filter((i) => i.severity === 'warning').length,
|
|
5354
|
+
info: openIssues.filter((i) => i.severity === 'info').length,
|
|
5355
|
+
};
|
|
5356
|
+
const scoreAfter = lastRun?.siteScoreAfter ?? null;
|
|
5357
|
+
const scoreBefore = lastRun?.siteScoreBefore ?? null;
|
|
5358
|
+
const breakdown = lastRun?.metadata?.breakdown ?? [];
|
|
5359
|
+
const avgPosition = perf.length > 0
|
|
5360
|
+
? perf.reduce((s, r) => s + r.averagePosition, 0) / perf.length
|
|
5361
|
+
: null;
|
|
5362
|
+
const weeklyImpressions = perf.reduce((s, r) => s + r.impressions, 0);
|
|
5363
|
+
const metrics = [
|
|
5364
|
+
{
|
|
5365
|
+
label: 'Pages Indexed',
|
|
5366
|
+
value: String(indexable),
|
|
5367
|
+
helper: `${records.length} total pages`,
|
|
5368
|
+
trend: 'flat',
|
|
5369
|
+
},
|
|
5370
|
+
{
|
|
5371
|
+
label: 'Open Issues',
|
|
5372
|
+
value: String(summary.total),
|
|
5373
|
+
helper: `${summary.critical} critical`,
|
|
5374
|
+
trend: summary.critical > 0 ? 'down' : 'flat',
|
|
5375
|
+
},
|
|
5376
|
+
{
|
|
5377
|
+
label: 'Avg. Position',
|
|
5378
|
+
value: avgPosition !== null ? avgPosition.toFixed(1) : '—',
|
|
5379
|
+
helper: moduleSettings.searchConsoleConnected ? 'last 28 days' : 'Connect Search Console',
|
|
5380
|
+
trend: 'flat',
|
|
5381
|
+
},
|
|
5382
|
+
{
|
|
5383
|
+
label: 'Weekly Impressions',
|
|
5384
|
+
value: weeklyImpressions > 0 ? formatCompactNumber(weeklyImpressions) : '—',
|
|
5385
|
+
helper: moduleSettings.searchConsoleConnected ? 'last 28 days' : 'Connect Search Console',
|
|
5386
|
+
trend: 'flat',
|
|
5387
|
+
},
|
|
5388
|
+
];
|
|
5389
|
+
const quickWins = buildQuickWins(openIssues.map((i) => ({
|
|
5390
|
+
issueKey: i.issueKey,
|
|
5391
|
+
entityType: i.entityType,
|
|
5392
|
+
entityId: i.entityId,
|
|
5393
|
+
entityTitle: i.entityTitle,
|
|
5394
|
+
url: i.url,
|
|
5395
|
+
severity: i.severity,
|
|
5396
|
+
category: i.category,
|
|
5397
|
+
type: i.type,
|
|
5398
|
+
title: i.title,
|
|
5399
|
+
description: i.description ?? '',
|
|
5400
|
+
impact: i.impact ?? 'Low',
|
|
5401
|
+
recommendation: i.recommendation ?? '',
|
|
5402
|
+
fixable: i.fixable ?? false,
|
|
5403
|
+
fixActionType: i.fixActionType ?? null,
|
|
5404
|
+
relatedEntityIds: Array.isArray(i.relatedEntityIds) ? i.relatedEntityIds : [],
|
|
5405
|
+
}))).map((w) => ({
|
|
5406
|
+
id: w.id,
|
|
5407
|
+
title: w.title,
|
|
5408
|
+
explanation: w.explanation,
|
|
5409
|
+
impact: w.impact,
|
|
5410
|
+
issueId: openIssues.find((i) => i.type === w.issueType)?.id ?? null,
|
|
5411
|
+
fixActionType: w.fixActionType,
|
|
5412
|
+
}));
|
|
5413
|
+
return json({
|
|
5414
|
+
data: {
|
|
5415
|
+
siteScore: {
|
|
5416
|
+
score: scoreAfter ?? 0,
|
|
5417
|
+
grade: gradeForScoreBand(scoreAfter ?? 0),
|
|
5418
|
+
delta: scoreAfter !== null && scoreBefore !== null ? scoreAfter - scoreBefore : null,
|
|
5419
|
+
breakdown,
|
|
5420
|
+
},
|
|
5421
|
+
metrics,
|
|
5422
|
+
issues: openIssues.slice(0, 7).map(seoIssueToApi),
|
|
5423
|
+
issuesSummary: summary,
|
|
5424
|
+
quickWins,
|
|
5425
|
+
searchPerformance: perf,
|
|
5426
|
+
searchConsoleConnected: moduleSettings.searchConsoleConnected,
|
|
5427
|
+
lastAuditRunId: lastRun?.id ?? null,
|
|
5428
|
+
},
|
|
5429
|
+
});
|
|
5430
|
+
}
|
|
5431
|
+
catch (err) {
|
|
5432
|
+
return internalError(err, 'seo/overview');
|
|
5433
|
+
}
|
|
5434
|
+
});
|
|
5435
|
+
router.get('/seo/search-performance', async (request) => {
|
|
5436
|
+
try {
|
|
5437
|
+
const auth = await requireAuth(request);
|
|
5438
|
+
if (auth.error)
|
|
5439
|
+
return auth.error;
|
|
5440
|
+
const url = new URL(request.url);
|
|
5441
|
+
const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') ?? 10)));
|
|
5442
|
+
const rows = await loadSearchPerformance(limit);
|
|
5443
|
+
return json({ data: { rows } });
|
|
5444
|
+
}
|
|
5445
|
+
catch (err) {
|
|
5446
|
+
return internalError(err, 'seo/search-performance');
|
|
5447
|
+
}
|
|
5448
|
+
});
|
|
5449
|
+
router.get('/seo/content', async (request) => {
|
|
5450
|
+
try {
|
|
5451
|
+
const auth = await requireAuth(request);
|
|
5452
|
+
if (auth.error)
|
|
5453
|
+
return auth.error;
|
|
5454
|
+
const records = await buildSeoContentRecords();
|
|
5455
|
+
return json({ data: { records } });
|
|
5456
|
+
}
|
|
5457
|
+
catch (err) {
|
|
5458
|
+
return internalError(err, 'seo/content');
|
|
5459
|
+
}
|
|
5460
|
+
});
|
|
5461
|
+
router.get('/seo/content/:entityType/:entityId', async (request, params) => {
|
|
5462
|
+
try {
|
|
5463
|
+
const auth = await requireAuth(request);
|
|
5464
|
+
if (auth.error)
|
|
5465
|
+
return auth.error;
|
|
5466
|
+
const records = await buildSeoContentRecords();
|
|
5467
|
+
const record = records.find((r) => r.entityType === params.entityType && r.entityId === params.entityId);
|
|
5468
|
+
if (!record)
|
|
5469
|
+
return errorResponse('Content record not found', 404);
|
|
5470
|
+
return json({ data: record });
|
|
5471
|
+
}
|
|
5472
|
+
catch (err) {
|
|
5473
|
+
return internalError(err, 'seo/content/:id');
|
|
5474
|
+
}
|
|
5475
|
+
});
|
|
5476
|
+
router.put('/seo/content/:entityType/:entityId', async (request, params) => {
|
|
5477
|
+
try {
|
|
5478
|
+
const auth = await requireAuth(request);
|
|
5479
|
+
if (auth.error)
|
|
5480
|
+
return auth.error;
|
|
5481
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
5482
|
+
if (roleErr)
|
|
5483
|
+
return roleErr;
|
|
5484
|
+
const body = (await request.json().catch(() => null));
|
|
5485
|
+
if (!body || typeof body !== 'object') {
|
|
5486
|
+
return errorResponse('Request body must be an object', 400);
|
|
5487
|
+
}
|
|
5488
|
+
const doc = await db().document.findFirst({
|
|
5489
|
+
where: { id: params.entityId, deletedAt: null },
|
|
5490
|
+
select: { id: true, data: true },
|
|
5491
|
+
});
|
|
5492
|
+
if (!doc)
|
|
5493
|
+
return errorResponse('Document not found', 404);
|
|
5494
|
+
const data = asRecord(doc.data);
|
|
5495
|
+
// Map SEO field payload onto the document data JSON. Canonical is stored
|
|
5496
|
+
// as `canonical` to match the existing reader in normalizeSeoPage.
|
|
5497
|
+
const fieldMap = {
|
|
5498
|
+
metaTitle: 'metaTitle',
|
|
5499
|
+
metaDescription: 'metaDescription',
|
|
5500
|
+
canonicalUrl: 'canonical',
|
|
5501
|
+
focusKeyword: 'focusKeyword',
|
|
5502
|
+
ogTitle: 'ogTitle',
|
|
5503
|
+
ogDescription: 'ogDescription',
|
|
5504
|
+
ogImage: 'ogImage',
|
|
5505
|
+
twitterTitle: 'twitterTitle',
|
|
5506
|
+
twitterDescription: 'twitterDescription',
|
|
5507
|
+
twitterImage: 'twitterImage',
|
|
5508
|
+
structuredDataType: 'structuredDataType',
|
|
5509
|
+
};
|
|
5510
|
+
const next = { ...data };
|
|
5511
|
+
for (const [key, target] of Object.entries(fieldMap)) {
|
|
5512
|
+
if (key in body)
|
|
5513
|
+
next[target] = body[key];
|
|
5514
|
+
}
|
|
5515
|
+
if ('noindex' in body)
|
|
5516
|
+
next.noindex = body.noindex === true;
|
|
5517
|
+
if ('nofollow' in body)
|
|
5518
|
+
next.nofollow = body.nofollow === true;
|
|
5519
|
+
if ('structuredData' in body)
|
|
5520
|
+
next.structuredData = body.structuredData;
|
|
5521
|
+
await db().document.update({
|
|
5522
|
+
where: { id: doc.id },
|
|
5523
|
+
data: { data: next, updatedById: auth.session.userId },
|
|
5524
|
+
});
|
|
5525
|
+
try {
|
|
5526
|
+
await logEvent({
|
|
5527
|
+
event: 'document_updated',
|
|
5528
|
+
userId: auth.session.userId,
|
|
5529
|
+
details: { action: 'seo_fields_update', documentId: doc.id },
|
|
5530
|
+
});
|
|
5531
|
+
}
|
|
5532
|
+
catch {
|
|
5533
|
+
/* noop */
|
|
5534
|
+
}
|
|
5535
|
+
const records = await buildSeoContentRecords();
|
|
5536
|
+
const record = records.find((r) => r.entityId === doc.id);
|
|
5537
|
+
return json({ data: record ?? { ok: true } });
|
|
5538
|
+
}
|
|
5539
|
+
catch (err) {
|
|
5540
|
+
return internalError(err, 'seo/content PUT');
|
|
5541
|
+
}
|
|
5542
|
+
});
|
|
5543
|
+
// ---------------------------------------------------------------------------
|
|
5544
|
+
// SEO analysis, readability, internal links, schema, meta
|
|
5545
|
+
// ---------------------------------------------------------------------------
|
|
5546
|
+
router.get('/seo/analysis/:documentId', async (request, params) => {
|
|
5547
|
+
try {
|
|
5548
|
+
const auth = await requireAuth(request);
|
|
5549
|
+
if (auth.error)
|
|
5550
|
+
return auth.error;
|
|
5551
|
+
const doc = await db().document.findFirst({
|
|
5552
|
+
where: { id: params.documentId, deletedAt: null },
|
|
5553
|
+
});
|
|
5554
|
+
if (!doc)
|
|
4264
5555
|
return errorResponse('Not found', 404);
|
|
4265
5556
|
const { analyzeContent } = await import('../seo/analysis.js');
|
|
4266
5557
|
const data = doc.data || {};
|
|
@@ -4562,6 +5853,28 @@ export function registerCMSRoutes(router) {
|
|
|
4562
5853
|
const seo = cfg?.seo;
|
|
4563
5854
|
if (seo?.robots?.disabled)
|
|
4564
5855
|
return errorResponse('robots.txt disabled', 404);
|
|
5856
|
+
// Admin-edited custom robots.txt body (Technical tab) wins when set —
|
|
5857
|
+
// the editor is the single source of truth for operators who want full
|
|
5858
|
+
// control over directives.
|
|
5859
|
+
try {
|
|
5860
|
+
const { getSeoOverrides, resolveSeoModuleSettings } = await import('../seo/config-store.js');
|
|
5861
|
+
const settings = resolveSeoModuleSettings(await getSeoOverrides(db()));
|
|
5862
|
+
if (settings.robotsEnabled === false)
|
|
5863
|
+
return errorResponse('robots.txt disabled', 404);
|
|
5864
|
+
const custom = settings.robotsTxt?.trim();
|
|
5865
|
+
if (custom) {
|
|
5866
|
+
return new Response(settings.robotsTxt, {
|
|
5867
|
+
status: 200,
|
|
5868
|
+
headers: {
|
|
5869
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
5870
|
+
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
|
5871
|
+
},
|
|
5872
|
+
});
|
|
5873
|
+
}
|
|
5874
|
+
}
|
|
5875
|
+
catch {
|
|
5876
|
+
// Fall through to generated defaults on any override-read failure.
|
|
5877
|
+
}
|
|
4565
5878
|
const base = siteUrlFromRequest(request, cfg);
|
|
4566
5879
|
const sitemapUrl = seo?.sitemap?.disabled ? undefined : `${base}/api/cms/sitemap.xml`;
|
|
4567
5880
|
const lines = [
|
|
@@ -4812,23 +6125,231 @@ export function registerCMSRoutes(router) {
|
|
|
4812
6125
|
});
|
|
4813
6126
|
}
|
|
4814
6127
|
catch (err) {
|
|
4815
|
-
return internalError(err);
|
|
6128
|
+
return internalError(err);
|
|
6129
|
+
}
|
|
6130
|
+
});
|
|
6131
|
+
// ---------------------------------------------------------------------------
|
|
6132
|
+
// SEO config (admin-editable overrides for `actuate.config.ts` seo defaults)
|
|
6133
|
+
// ---------------------------------------------------------------------------
|
|
6134
|
+
/**
|
|
6135
|
+
* Returns:
|
|
6136
|
+
* - `static` → the SEO config from `actuate.config.ts` (read-only, code-level)
|
|
6137
|
+
* - `overrides` → the user-edited DB overrides (or null on first boot)
|
|
6138
|
+
* - `effective` → static + overrides merged (what the runtime actually uses)
|
|
6139
|
+
* - `collections` → list of collection slugs + labels the UI should iterate
|
|
6140
|
+
*
|
|
6141
|
+
* The UI uses `static` to show grey "default" placeholders, `overrides` to
|
|
6142
|
+
* populate the form inputs, and writes back just the changed fields via PUT.
|
|
6143
|
+
*/
|
|
6144
|
+
router.get('/seo/config', async (request) => {
|
|
6145
|
+
try {
|
|
6146
|
+
const auth = await requireAuth(request);
|
|
6147
|
+
if (auth.error)
|
|
6148
|
+
return auth.error;
|
|
6149
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
6150
|
+
if (roleErr)
|
|
6151
|
+
return roleErr;
|
|
6152
|
+
const { getSeoOverrides, applySeoOverrides } = await import('../seo/config-store.js');
|
|
6153
|
+
const staticCfg = getActuateConfig();
|
|
6154
|
+
const overrides = await getSeoOverrides(db());
|
|
6155
|
+
const effective = applySeoOverrides(staticCfg, overrides);
|
|
6156
|
+
const collections = Object.entries(staticCfg?.collections ?? {})
|
|
6157
|
+
.map(([slug, col]) => ({
|
|
6158
|
+
slug,
|
|
6159
|
+
label: col.labels?.plural ?? slug,
|
|
6160
|
+
type: col.type ?? 'page',
|
|
6161
|
+
urlPrefix: col.urlPrefix,
|
|
6162
|
+
staticSeo: col.seo ?? null,
|
|
6163
|
+
effectiveSeo: effective?.collections?.[slug]?.seo ?? null,
|
|
6164
|
+
}))
|
|
6165
|
+
.sort((a, b) => a.label.localeCompare(b.label));
|
|
6166
|
+
return json({
|
|
6167
|
+
data: {
|
|
6168
|
+
static: { site: staticCfg?.seo ?? null },
|
|
6169
|
+
overrides: overrides ?? null,
|
|
6170
|
+
effective: { site: effective?.seo ?? null },
|
|
6171
|
+
collections,
|
|
6172
|
+
},
|
|
6173
|
+
});
|
|
6174
|
+
}
|
|
6175
|
+
catch (err) {
|
|
6176
|
+
return internalError(err, 'seo/config GET');
|
|
6177
|
+
}
|
|
6178
|
+
});
|
|
6179
|
+
router.put('/seo/config', async (request) => {
|
|
6180
|
+
try {
|
|
6181
|
+
const auth = await requireAuth(request);
|
|
6182
|
+
if (auth.error)
|
|
6183
|
+
return auth.error;
|
|
6184
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
6185
|
+
if (roleErr)
|
|
6186
|
+
return roleErr;
|
|
6187
|
+
const body = (await request.json().catch(() => null));
|
|
6188
|
+
if (!body || typeof body !== 'object') {
|
|
6189
|
+
return errorResponse('Request body must be an object', 400);
|
|
6190
|
+
}
|
|
6191
|
+
// Shallow shape validation — we don't want to mirror the entire type
|
|
6192
|
+
// here (the config-store merger ignores unknown keys anyway), but we do
|
|
6193
|
+
// want to reject obviously malformed payloads early so the UI gets a
|
|
6194
|
+
// clear 400 instead of a silent no-op.
|
|
6195
|
+
const site = body.site && typeof body.site === 'object' ? body.site : undefined;
|
|
6196
|
+
const collections = body.collections && typeof body.collections === 'object' ? body.collections : undefined;
|
|
6197
|
+
if (site === undefined && collections === undefined) {
|
|
6198
|
+
return errorResponse('Body must include at least one of `site` or `collections`', 400);
|
|
6199
|
+
}
|
|
6200
|
+
const { putSeoOverrides, getSeoOverrides } = await import('../seo/config-store.js');
|
|
6201
|
+
// Merge with whatever's currently stored so the UI can PATCH a single
|
|
6202
|
+
// section without wiping the other — `PUT /seo/config { site: {...} }`
|
|
6203
|
+
// shouldn't blow away per-collection settings.
|
|
6204
|
+
const current = (await getSeoOverrides(db())) ?? {};
|
|
6205
|
+
const next = {
|
|
6206
|
+
site: site !== undefined ? site : current.site,
|
|
6207
|
+
collections: collections !== undefined
|
|
6208
|
+
? { ...(current.collections ?? {}), ...collections }
|
|
6209
|
+
: current.collections,
|
|
6210
|
+
// Preserve Technical-tab module settings so editing site/collection
|
|
6211
|
+
// defaults never silently wipes crawl/robots/sitemap configuration.
|
|
6212
|
+
module: current.module,
|
|
6213
|
+
};
|
|
6214
|
+
const saved = await putSeoOverrides(db(), next, auth.session.userId);
|
|
6215
|
+
// Audit so changes to public SEO surface are traceable.
|
|
6216
|
+
try {
|
|
6217
|
+
await db().auditLog?.create?.({
|
|
6218
|
+
data: {
|
|
6219
|
+
action: 'update_seo_config',
|
|
6220
|
+
actorId: auth.session.userId,
|
|
6221
|
+
metadata: {
|
|
6222
|
+
siteKeys: Object.keys(site ?? {}),
|
|
6223
|
+
collectionKeys: Object.keys(collections ?? {}),
|
|
6224
|
+
},
|
|
6225
|
+
},
|
|
6226
|
+
});
|
|
6227
|
+
}
|
|
6228
|
+
catch {
|
|
6229
|
+
// Audit failures must never block the write.
|
|
6230
|
+
}
|
|
6231
|
+
return json({ data: saved });
|
|
6232
|
+
}
|
|
6233
|
+
catch (err) {
|
|
6234
|
+
return internalError(err, 'seo/config PUT');
|
|
6235
|
+
}
|
|
6236
|
+
});
|
|
6237
|
+
// ---------------------------------------------------------------------------
|
|
6238
|
+
// Technical SEO — sitemap status, crawl settings, robots.txt editor.
|
|
6239
|
+
// All backed by the `module` block of the SEO overrides document.
|
|
6240
|
+
// ---------------------------------------------------------------------------
|
|
6241
|
+
/** Persist a partial patch onto the SEO module settings, preserving the rest. */
|
|
6242
|
+
async function patchSeoModule(patch, userId) {
|
|
6243
|
+
const { getSeoOverrides, putSeoOverrides } = await import('../seo/config-store.js');
|
|
6244
|
+
const current = (await getSeoOverrides(db())) ?? {};
|
|
6245
|
+
await putSeoOverrides(db(), {
|
|
6246
|
+
site: current.site,
|
|
6247
|
+
collections: current.collections,
|
|
6248
|
+
module: { ...(current.module ?? {}), ...patch },
|
|
6249
|
+
}, userId);
|
|
6250
|
+
}
|
|
6251
|
+
/** Build the default robots.txt body (mirrors the public /robots.txt route). */
|
|
6252
|
+
function buildDefaultRobots(request, cfg) {
|
|
6253
|
+
const base = siteUrlFromRequest(request, cfg);
|
|
6254
|
+
const sitemapUrl = cfg?.seo?.sitemap?.disabled ? undefined : `${base}/api/cms/sitemap.xml`;
|
|
6255
|
+
const lines = ['User-agent: *', 'Allow: /', 'Disallow: /admin', 'Disallow: /api/', ''];
|
|
6256
|
+
if (sitemapUrl)
|
|
6257
|
+
lines.push(`Sitemap: ${sitemapUrl}`, '');
|
|
6258
|
+
return lines.join('\n');
|
|
6259
|
+
}
|
|
6260
|
+
router.get('/seo/sitemap/status', async (request) => {
|
|
6261
|
+
try {
|
|
6262
|
+
const auth = await requireAuth(request);
|
|
6263
|
+
if (auth.error)
|
|
6264
|
+
return auth.error;
|
|
6265
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
6266
|
+
if (roleErr)
|
|
6267
|
+
return roleErr;
|
|
6268
|
+
const { getSeoOverrides, resolveSeoModuleSettings } = await import('../seo/config-store.js');
|
|
6269
|
+
const cfg = await loadEffectiveConfig();
|
|
6270
|
+
const settings = resolveSeoModuleSettings(await getSeoOverrides(db()));
|
|
6271
|
+
const base = siteUrlFromRequest(request, cfg);
|
|
6272
|
+
const cols = sitemapEligibleCollections(cfg);
|
|
6273
|
+
let pagesIncluded = 0;
|
|
6274
|
+
let pagesExcluded = 0;
|
|
6275
|
+
for (const col of cols) {
|
|
6276
|
+
const count = await db().document.count({
|
|
6277
|
+
where: { collection: col.slug, deletedAt: null, status: 'PUBLISHED' },
|
|
6278
|
+
});
|
|
6279
|
+
pagesIncluded += count;
|
|
6280
|
+
}
|
|
6281
|
+
// Best-effort: count published docs explicitly flagged noindex.
|
|
6282
|
+
try {
|
|
6283
|
+
pagesExcluded = await db().document.count({
|
|
6284
|
+
where: {
|
|
6285
|
+
deletedAt: null,
|
|
6286
|
+
status: 'PUBLISHED',
|
|
6287
|
+
data: { path: ['noindex'], equals: true },
|
|
6288
|
+
},
|
|
6289
|
+
});
|
|
6290
|
+
}
|
|
6291
|
+
catch {
|
|
6292
|
+
pagesExcluded = 0;
|
|
6293
|
+
}
|
|
6294
|
+
const warnings = [];
|
|
6295
|
+
if (!settings.sitemapEnabled)
|
|
6296
|
+
warnings.push('Sitemap generation is disabled in crawl settings.');
|
|
6297
|
+
if (pagesIncluded === 0)
|
|
6298
|
+
warnings.push('No published pages are eligible for the sitemap.');
|
|
6299
|
+
return json({
|
|
6300
|
+
data: {
|
|
6301
|
+
sitemapUrl: `${base}/api/cms/sitemap.xml`,
|
|
6302
|
+
lastGeneratedAt: settings.sitemapLastGeneratedAt,
|
|
6303
|
+
pagesIncluded,
|
|
6304
|
+
pagesExcluded,
|
|
6305
|
+
autoRegenerateOnPublish: settings.autoRegenerateSitemapOnPublish,
|
|
6306
|
+
errors: [],
|
|
6307
|
+
warnings,
|
|
6308
|
+
},
|
|
6309
|
+
});
|
|
6310
|
+
}
|
|
6311
|
+
catch (err) {
|
|
6312
|
+
return internalError(err, 'seo/sitemap/status');
|
|
6313
|
+
}
|
|
6314
|
+
});
|
|
6315
|
+
router.post('/seo/sitemap/regenerate', async (request) => {
|
|
6316
|
+
try {
|
|
6317
|
+
const auth = await requireAuth(request);
|
|
6318
|
+
if (auth.error)
|
|
6319
|
+
return auth.error;
|
|
6320
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
6321
|
+
if (roleErr)
|
|
6322
|
+
return roleErr;
|
|
6323
|
+
const now = new Date().toISOString();
|
|
6324
|
+
await patchSeoModule({ sitemapLastGeneratedAt: now }, auth.session.userId);
|
|
6325
|
+
const { getSeoOverrides, resolveSeoModuleSettings } = await import('../seo/config-store.js');
|
|
6326
|
+
const cfg = await loadEffectiveConfig();
|
|
6327
|
+
const settings = resolveSeoModuleSettings(await getSeoOverrides(db()));
|
|
6328
|
+
const base = siteUrlFromRequest(request, cfg);
|
|
6329
|
+
const cols = sitemapEligibleCollections(cfg);
|
|
6330
|
+
let pagesIncluded = 0;
|
|
6331
|
+
for (const col of cols) {
|
|
6332
|
+
pagesIncluded += await db().document.count({
|
|
6333
|
+
where: { collection: col.slug, deletedAt: null, status: 'PUBLISHED' },
|
|
6334
|
+
});
|
|
6335
|
+
}
|
|
6336
|
+
return json({
|
|
6337
|
+
data: {
|
|
6338
|
+
sitemapUrl: `${base}/api/cms/sitemap.xml`,
|
|
6339
|
+
lastGeneratedAt: now,
|
|
6340
|
+
pagesIncluded,
|
|
6341
|
+
pagesExcluded: 0,
|
|
6342
|
+
autoRegenerateOnPublish: settings.autoRegenerateSitemapOnPublish,
|
|
6343
|
+
errors: [],
|
|
6344
|
+
warnings: [],
|
|
6345
|
+
},
|
|
6346
|
+
});
|
|
6347
|
+
}
|
|
6348
|
+
catch (err) {
|
|
6349
|
+
return internalError(err, 'seo/sitemap/regenerate');
|
|
4816
6350
|
}
|
|
4817
6351
|
});
|
|
4818
|
-
|
|
4819
|
-
// SEO config (admin-editable overrides for `actuate.config.ts` seo defaults)
|
|
4820
|
-
// ---------------------------------------------------------------------------
|
|
4821
|
-
/**
|
|
4822
|
-
* Returns:
|
|
4823
|
-
* - `static` → the SEO config from `actuate.config.ts` (read-only, code-level)
|
|
4824
|
-
* - `overrides` → the user-edited DB overrides (or null on first boot)
|
|
4825
|
-
* - `effective` → static + overrides merged (what the runtime actually uses)
|
|
4826
|
-
* - `collections` → list of collection slugs + labels the UI should iterate
|
|
4827
|
-
*
|
|
4828
|
-
* The UI uses `static` to show grey "default" placeholders, `overrides` to
|
|
4829
|
-
* populate the form inputs, and writes back just the changed fields via PUT.
|
|
4830
|
-
*/
|
|
4831
|
-
router.get('/seo/config', async (request) => {
|
|
6352
|
+
router.get('/seo/crawl-settings', async (request) => {
|
|
4832
6353
|
try {
|
|
4833
6354
|
const auth = await requireAuth(request);
|
|
4834
6355
|
if (auth.error)
|
|
@@ -4836,34 +6357,23 @@ export function registerCMSRoutes(router) {
|
|
|
4836
6357
|
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
4837
6358
|
if (roleErr)
|
|
4838
6359
|
return roleErr;
|
|
4839
|
-
const { getSeoOverrides,
|
|
4840
|
-
const
|
|
4841
|
-
const overrides = await getSeoOverrides(db());
|
|
4842
|
-
const effective = applySeoOverrides(staticCfg, overrides);
|
|
4843
|
-
const collections = Object.entries(staticCfg?.collections ?? {})
|
|
4844
|
-
.map(([slug, col]) => ({
|
|
4845
|
-
slug,
|
|
4846
|
-
label: col.labels?.plural ?? slug,
|
|
4847
|
-
type: col.type ?? 'page',
|
|
4848
|
-
urlPrefix: col.urlPrefix,
|
|
4849
|
-
staticSeo: col.seo ?? null,
|
|
4850
|
-
effectiveSeo: effective?.collections?.[slug]?.seo ?? null,
|
|
4851
|
-
}))
|
|
4852
|
-
.sort((a, b) => a.label.localeCompare(b.label));
|
|
6360
|
+
const { getSeoOverrides, resolveSeoModuleSettings } = await import('../seo/config-store.js');
|
|
6361
|
+
const s = resolveSeoModuleSettings(await getSeoOverrides(db()));
|
|
4853
6362
|
return json({
|
|
4854
6363
|
data: {
|
|
4855
|
-
|
|
4856
|
-
|
|
4857
|
-
|
|
4858
|
-
|
|
6364
|
+
indexEntireSite: s.indexEntireSite,
|
|
6365
|
+
includeImagesInSitemap: s.includeImagesInSitemap,
|
|
6366
|
+
includePostsInSitemap: s.includePostsInSitemap,
|
|
6367
|
+
noindexPaginatedArchives: s.noindexPaginatedArchives,
|
|
6368
|
+
noindexTagPages: s.noindexTagPages,
|
|
4859
6369
|
},
|
|
4860
6370
|
});
|
|
4861
6371
|
}
|
|
4862
6372
|
catch (err) {
|
|
4863
|
-
return internalError(err, 'seo/
|
|
6373
|
+
return internalError(err, 'seo/crawl-settings GET');
|
|
4864
6374
|
}
|
|
4865
6375
|
});
|
|
4866
|
-
router.put('/seo/
|
|
6376
|
+
router.put('/seo/crawl-settings', async (request) => {
|
|
4867
6377
|
try {
|
|
4868
6378
|
const auth = await requireAuth(request);
|
|
4869
6379
|
if (auth.error)
|
|
@@ -4875,47 +6385,87 @@ export function registerCMSRoutes(router) {
|
|
|
4875
6385
|
if (!body || typeof body !== 'object') {
|
|
4876
6386
|
return errorResponse('Request body must be an object', 400);
|
|
4877
6387
|
}
|
|
4878
|
-
|
|
4879
|
-
|
|
4880
|
-
|
|
4881
|
-
|
|
4882
|
-
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
|
|
6388
|
+
const allowed = [
|
|
6389
|
+
'indexEntireSite',
|
|
6390
|
+
'includeImagesInSitemap',
|
|
6391
|
+
'includePostsInSitemap',
|
|
6392
|
+
'noindexPaginatedArchives',
|
|
6393
|
+
'noindexTagPages',
|
|
6394
|
+
];
|
|
6395
|
+
const patch = {};
|
|
6396
|
+
for (const key of allowed) {
|
|
6397
|
+
if (typeof body[key] === 'boolean')
|
|
6398
|
+
patch[key] = body[key];
|
|
4886
6399
|
}
|
|
4887
|
-
|
|
4888
|
-
|
|
4889
|
-
|
|
4890
|
-
|
|
4891
|
-
const current = (await getSeoOverrides(db())) ?? {};
|
|
4892
|
-
const next = {
|
|
4893
|
-
site: site !== undefined ? site : current.site,
|
|
4894
|
-
collections: collections !== undefined
|
|
4895
|
-
? { ...(current.collections ?? {}), ...collections }
|
|
4896
|
-
: current.collections,
|
|
4897
|
-
};
|
|
4898
|
-
const saved = await putSeoOverrides(db(), next, auth.session.userId);
|
|
4899
|
-
// Audit so changes to public SEO surface are traceable.
|
|
6400
|
+
if (Object.keys(patch).length === 0) {
|
|
6401
|
+
return errorResponse('No valid crawl settings provided', 400);
|
|
6402
|
+
}
|
|
6403
|
+
await patchSeoModule(patch, auth.session.userId);
|
|
4900
6404
|
try {
|
|
4901
|
-
await
|
|
4902
|
-
|
|
4903
|
-
|
|
4904
|
-
|
|
4905
|
-
metadata: {
|
|
4906
|
-
siteKeys: Object.keys(site ?? {}),
|
|
4907
|
-
collectionKeys: Object.keys(collections ?? {}),
|
|
4908
|
-
},
|
|
4909
|
-
},
|
|
6405
|
+
await logEvent({
|
|
6406
|
+
event: 'update_seo_config',
|
|
6407
|
+
userId: auth.session.userId,
|
|
6408
|
+
details: { section: 'crawl-settings', keys: Object.keys(patch) },
|
|
4910
6409
|
});
|
|
4911
6410
|
}
|
|
4912
6411
|
catch {
|
|
4913
|
-
|
|
6412
|
+
/* audit best-effort */
|
|
4914
6413
|
}
|
|
4915
|
-
return json({ data:
|
|
6414
|
+
return json({ data: { ok: true } });
|
|
4916
6415
|
}
|
|
4917
6416
|
catch (err) {
|
|
4918
|
-
return internalError(err, 'seo/
|
|
6417
|
+
return internalError(err, 'seo/crawl-settings PUT');
|
|
6418
|
+
}
|
|
6419
|
+
});
|
|
6420
|
+
router.get('/seo/robots', async (request) => {
|
|
6421
|
+
try {
|
|
6422
|
+
const auth = await requireAuth(request);
|
|
6423
|
+
if (auth.error)
|
|
6424
|
+
return auth.error;
|
|
6425
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
6426
|
+
if (roleErr)
|
|
6427
|
+
return roleErr;
|
|
6428
|
+
const { getSeoOverrides, resolveSeoModuleSettings } = await import('../seo/config-store.js');
|
|
6429
|
+
const cfg = await loadEffectiveConfig();
|
|
6430
|
+
const settings = resolveSeoModuleSettings(await getSeoOverrides(db()));
|
|
6431
|
+
const custom = settings.robotsTxt?.trim();
|
|
6432
|
+
const body = custom ? settings.robotsTxt : buildDefaultRobots(request, cfg);
|
|
6433
|
+
return json({ data: { body, generated: !custom } });
|
|
6434
|
+
}
|
|
6435
|
+
catch (err) {
|
|
6436
|
+
return internalError(err, 'seo/robots GET');
|
|
6437
|
+
}
|
|
6438
|
+
});
|
|
6439
|
+
router.put('/seo/robots', async (request) => {
|
|
6440
|
+
try {
|
|
6441
|
+
const auth = await requireAuth(request);
|
|
6442
|
+
if (auth.error)
|
|
6443
|
+
return auth.error;
|
|
6444
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
6445
|
+
if (roleErr)
|
|
6446
|
+
return roleErr;
|
|
6447
|
+
const body = (await request.json().catch(() => null));
|
|
6448
|
+
if (!body || typeof body.body !== 'string') {
|
|
6449
|
+
return errorResponse('`body` (string) is required', 400);
|
|
6450
|
+
}
|
|
6451
|
+
if (body.body.length > 50_000) {
|
|
6452
|
+
return errorResponse('robots.txt is too large (max 50KB)', 400);
|
|
6453
|
+
}
|
|
6454
|
+
await patchSeoModule({ robotsTxt: body.body }, auth.session.userId);
|
|
6455
|
+
try {
|
|
6456
|
+
await logEvent({
|
|
6457
|
+
event: 'update_seo_config',
|
|
6458
|
+
userId: auth.session.userId,
|
|
6459
|
+
details: { section: 'robots.txt' },
|
|
6460
|
+
});
|
|
6461
|
+
}
|
|
6462
|
+
catch {
|
|
6463
|
+
/* audit best-effort */
|
|
6464
|
+
}
|
|
6465
|
+
return json({ data: { ok: true } });
|
|
6466
|
+
}
|
|
6467
|
+
catch (err) {
|
|
6468
|
+
return internalError(err, 'seo/robots PUT');
|
|
4919
6469
|
}
|
|
4920
6470
|
});
|
|
4921
6471
|
// ---------------------------------------------------------------------------
|
|
@@ -5265,6 +6815,9 @@ export function registerCMSRoutes(router) {
|
|
|
5265
6815
|
tags: composed.meta,
|
|
5266
6816
|
html: composed.metaHtml,
|
|
5267
6817
|
},
|
|
6818
|
+
// Next.js `generateMetadata()`-compatible object so the public site
|
|
6819
|
+
// can spread it directly without re-deriving OG/Twitter/robots tags.
|
|
6820
|
+
metadata: composed.metadata,
|
|
5268
6821
|
jsonLd: composed.jsonLd,
|
|
5269
6822
|
jsonLdHtml: composed.jsonLdHtml,
|
|
5270
6823
|
...(templateHeader ? { template: { header: templateHeader } } : {}),
|
|
@@ -7940,5 +9493,319 @@ export function registerCMSRoutes(router) {
|
|
|
7940
9493
|
return internalError(err, 'ai suggest-internal-links');
|
|
7941
9494
|
}
|
|
7942
9495
|
});
|
|
9496
|
+
/**
|
|
9497
|
+
* Generate accessible alt text (and a derived title) for an image asset
|
|
9498
|
+
* using the AI vision provider. Replaces the old client-side filename mock.
|
|
9499
|
+
* Returns 501 when the AI plugin/provider isn't configured so the admin can
|
|
9500
|
+
* fall back gracefully instead of silently failing.
|
|
9501
|
+
*/
|
|
9502
|
+
router.post('/ai/media-metadata', async (request) => {
|
|
9503
|
+
try {
|
|
9504
|
+
const auth = await requireAuth(request);
|
|
9505
|
+
if (auth.error)
|
|
9506
|
+
return auth.error;
|
|
9507
|
+
const scopeErr = requireMediaScope(auth.session);
|
|
9508
|
+
if (scopeErr)
|
|
9509
|
+
return scopeErr;
|
|
9510
|
+
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-media-metadata:${auth.session.userId}`))) {
|
|
9511
|
+
return errorResponse('AI media metadata rate limit reached. Try again in an hour.', 429);
|
|
9512
|
+
}
|
|
9513
|
+
const body = (await request.json());
|
|
9514
|
+
// Resolve a safe image URL. Prefer a media id we own; only accept a raw
|
|
9515
|
+
// URL if it passes the storage allowlist (SSRF guard).
|
|
9516
|
+
let imageUrl = '';
|
|
9517
|
+
let mimeType = '';
|
|
9518
|
+
if (body.mediaId) {
|
|
9519
|
+
const media = await db().media.findUnique({ where: { id: body.mediaId } });
|
|
9520
|
+
if (!media)
|
|
9521
|
+
return errorResponse('Media not found', 404);
|
|
9522
|
+
mimeType = media.mimeType;
|
|
9523
|
+
imageUrl = media.publicUrl ?? mediaUrl(media.storageKey);
|
|
9524
|
+
}
|
|
9525
|
+
else if (body.imageUrl) {
|
|
9526
|
+
if (!isAllowedStorageUrl(body.imageUrl)) {
|
|
9527
|
+
return errorResponse('Invalid image URL', 400);
|
|
9528
|
+
}
|
|
9529
|
+
imageUrl = body.imageUrl;
|
|
9530
|
+
}
|
|
9531
|
+
else {
|
|
9532
|
+
return errorResponse('mediaId or imageUrl is required', 400);
|
|
9533
|
+
}
|
|
9534
|
+
if (mimeType && !mimeType.startsWith('image/')) {
|
|
9535
|
+
return errorResponse('AI metadata is only available for images', 400);
|
|
9536
|
+
}
|
|
9537
|
+
let aiModule;
|
|
9538
|
+
try {
|
|
9539
|
+
aiModule = await importAIPlugin();
|
|
9540
|
+
}
|
|
9541
|
+
catch {
|
|
9542
|
+
return errorResponse('AI plugin is not installed. Install @actuate-media/plugin-ai to generate media metadata.', 501);
|
|
9543
|
+
}
|
|
9544
|
+
if (typeof aiModule.generateAltText !== 'function') {
|
|
9545
|
+
return errorResponse('AI plugin missing `generateAltText`.', 501);
|
|
9546
|
+
}
|
|
9547
|
+
let alt = '';
|
|
9548
|
+
let alternatives = [];
|
|
9549
|
+
try {
|
|
9550
|
+
const r = (await aiModule.generateAltText(imageUrl, { pageTitle: body.pageTitle }));
|
|
9551
|
+
alt = (r?.altText ?? '').trim();
|
|
9552
|
+
alternatives = Array.isArray(r?.alternatives) ? r.alternatives : [];
|
|
9553
|
+
}
|
|
9554
|
+
catch (err) {
|
|
9555
|
+
const msg = err instanceof Error ? err.message : 'unknown';
|
|
9556
|
+
if (msg.toLowerCase().includes('not configured') ||
|
|
9557
|
+
msg.toLowerCase().includes('missing') ||
|
|
9558
|
+
msg.toLowerCase().includes('api key')) {
|
|
9559
|
+
return errorResponse(`AI provider is not configured: ${msg}`, 501);
|
|
9560
|
+
}
|
|
9561
|
+
throw err;
|
|
9562
|
+
}
|
|
9563
|
+
await logEvent({
|
|
9564
|
+
event: 'settings_changed',
|
|
9565
|
+
userId: auth.session.userId,
|
|
9566
|
+
details: { action: 'ai_media_metadata', mediaId: body.mediaId ?? null },
|
|
9567
|
+
});
|
|
9568
|
+
return json({ data: { alt, title: deriveMediaTitleFromAlt(alt), alternatives } });
|
|
9569
|
+
}
|
|
9570
|
+
catch (err) {
|
|
9571
|
+
return internalError(err, 'ai media-metadata');
|
|
9572
|
+
}
|
|
9573
|
+
});
|
|
9574
|
+
// ---------------------------------------------------------------------------
|
|
9575
|
+
// AI SEO Copilot — approval-gated assistance for the SEO Operations Center.
|
|
9576
|
+
// Every endpoint degrades gracefully to 501 when plugin-ai is missing or the
|
|
9577
|
+
// provider is unconfigured (the admin maps 501 -> "AI unavailable"). The AI
|
|
9578
|
+
// never writes directly: it returns suggestions the editor must approve.
|
|
9579
|
+
// ---------------------------------------------------------------------------
|
|
9580
|
+
/** Returns true for errors that mean "AI provider isn't set up" (=> 501). */
|
|
9581
|
+
function isAiUnconfigured(err) {
|
|
9582
|
+
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
|
9583
|
+
return msg.includes('not configured') || msg.includes('missing') || msg.includes('api key');
|
|
9584
|
+
}
|
|
9585
|
+
/** Load a page/post document for AI context. Returns null when not found. */
|
|
9586
|
+
async function loadSeoEntity(entityType, entityId) {
|
|
9587
|
+
const doc = await db().document.findFirst({
|
|
9588
|
+
where: { id: entityId, deletedAt: null },
|
|
9589
|
+
select: { title: true, data: true, plainText: true },
|
|
9590
|
+
});
|
|
9591
|
+
if (!doc)
|
|
9592
|
+
return null;
|
|
9593
|
+
const data = doc.data ?? {};
|
|
9594
|
+
const content = (typeof doc.plainText === 'string' && doc.plainText) ||
|
|
9595
|
+
(typeof data.body === 'string' ? data.body : '') ||
|
|
9596
|
+
(typeof data.content === 'string' ? data.content : '') ||
|
|
9597
|
+
(typeof data.excerpt === 'string' ? data.excerpt : '');
|
|
9598
|
+
const title = (typeof data.title === 'string' && data.title) || doc.title || 'Untitled';
|
|
9599
|
+
return { title, content: `${title}\n\n${content}`.trim() };
|
|
9600
|
+
}
|
|
9601
|
+
router.post('/ai/seo/generate-field', async (request) => {
|
|
9602
|
+
try {
|
|
9603
|
+
const auth = await requireAuth(request);
|
|
9604
|
+
if (auth.error)
|
|
9605
|
+
return auth.error;
|
|
9606
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
9607
|
+
if (roleErr)
|
|
9608
|
+
return roleErr;
|
|
9609
|
+
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-field:${auth.session.userId}`))) {
|
|
9610
|
+
return errorResponse('AI rate limit reached. Try again in an hour.', 429);
|
|
9611
|
+
}
|
|
9612
|
+
const body = (await request.json().catch(() => null));
|
|
9613
|
+
const field = body?.field;
|
|
9614
|
+
const ALLOWED = ['metaTitle', 'metaDescription', 'ogTitle', 'ogDescription', 'focusKeyword'];
|
|
9615
|
+
if (!field || !ALLOWED.includes(field)) {
|
|
9616
|
+
return errorResponse(`field must be one of: ${ALLOWED.join(', ')}`, 400);
|
|
9617
|
+
}
|
|
9618
|
+
if (!body?.entityType || !body?.entityId) {
|
|
9619
|
+
return errorResponse('entityType and entityId are required', 400);
|
|
9620
|
+
}
|
|
9621
|
+
const entity = await loadSeoEntity(body.entityType, body.entityId);
|
|
9622
|
+
if (!entity)
|
|
9623
|
+
return errorResponse('Content not found', 404);
|
|
9624
|
+
let ai;
|
|
9625
|
+
try {
|
|
9626
|
+
ai = await importAIPlugin();
|
|
9627
|
+
}
|
|
9628
|
+
catch {
|
|
9629
|
+
return errorResponse('AI plugin is not installed.', 501);
|
|
9630
|
+
}
|
|
9631
|
+
try {
|
|
9632
|
+
if (field === 'metaTitle' || field === 'ogTitle') {
|
|
9633
|
+
const titles = await ai.generateTitle(entity.content, {
|
|
9634
|
+
count: 4,
|
|
9635
|
+
style: 'seo-optimized',
|
|
9636
|
+
});
|
|
9637
|
+
const cleaned = titles.map((t) => t.trim()).filter(Boolean);
|
|
9638
|
+
if (cleaned.length === 0)
|
|
9639
|
+
return json({ data: {} });
|
|
9640
|
+
return json({ data: { text: cleaned[0], alternatives: cleaned.slice(1) } });
|
|
9641
|
+
}
|
|
9642
|
+
if (field === 'metaDescription' || field === 'ogDescription') {
|
|
9643
|
+
const text = await ai.generateMetaDescription(entity.content, 155);
|
|
9644
|
+
return json({ data: { text } });
|
|
9645
|
+
}
|
|
9646
|
+
// focusKeyword
|
|
9647
|
+
const text = await ai.generate(`Suggest a single primary SEO focus keyword phrase (2-4 words) for this content. Return only the phrase, no quotes or punctuation.\n\n${entity.content.slice(0, 2000)}`);
|
|
9648
|
+
return json({ data: { text: text.replace(/^["']|["']$/g, '').trim() } });
|
|
9649
|
+
}
|
|
9650
|
+
catch (err) {
|
|
9651
|
+
if (isAiUnconfigured(err))
|
|
9652
|
+
return errorResponse('AI provider is not configured.', 501);
|
|
9653
|
+
throw err;
|
|
9654
|
+
}
|
|
9655
|
+
}
|
|
9656
|
+
catch (err) {
|
|
9657
|
+
return internalError(err, 'ai seo generate-field');
|
|
9658
|
+
}
|
|
9659
|
+
});
|
|
9660
|
+
router.post('/ai/seo/explain-issue', async (request) => {
|
|
9661
|
+
try {
|
|
9662
|
+
const auth = await requireAuth(request);
|
|
9663
|
+
if (auth.error)
|
|
9664
|
+
return auth.error;
|
|
9665
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
9666
|
+
if (roleErr)
|
|
9667
|
+
return roleErr;
|
|
9668
|
+
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-explain:${auth.session.userId}`))) {
|
|
9669
|
+
return errorResponse('AI rate limit reached. Try again in an hour.', 429);
|
|
9670
|
+
}
|
|
9671
|
+
const body = (await request.json().catch(() => null));
|
|
9672
|
+
if (!body?.issueId)
|
|
9673
|
+
return errorResponse('issueId is required', 400);
|
|
9674
|
+
if (!hasModel(db(), 'seoIssue'))
|
|
9675
|
+
return modelNotAvailable('seoIssue');
|
|
9676
|
+
const issue = await db().seoIssue.findUnique({ where: { id: body.issueId } });
|
|
9677
|
+
if (!issue)
|
|
9678
|
+
return errorResponse('Issue not found', 404);
|
|
9679
|
+
let ai;
|
|
9680
|
+
try {
|
|
9681
|
+
ai = await importAIPlugin();
|
|
9682
|
+
}
|
|
9683
|
+
catch {
|
|
9684
|
+
return errorResponse('AI plugin is not installed.', 501);
|
|
9685
|
+
}
|
|
9686
|
+
try {
|
|
9687
|
+
const text = await ai.generate(`Explain this SEO issue to a non-technical content editor in 2-3 sentences, then give one concrete fix. Be specific and actionable.\n\nIssue: ${issue.title}\nCategory: ${issue.category}\nSeverity: ${issue.severity}\nPage: ${issue.entityTitle ?? issue.url ?? 'unknown'}\nDescription: ${issue.description ?? 'n/a'}`);
|
|
9688
|
+
return json({ data: { text: text.trim() } });
|
|
9689
|
+
}
|
|
9690
|
+
catch (err) {
|
|
9691
|
+
if (isAiUnconfigured(err))
|
|
9692
|
+
return errorResponse('AI provider is not configured.', 501);
|
|
9693
|
+
throw err;
|
|
9694
|
+
}
|
|
9695
|
+
}
|
|
9696
|
+
catch (err) {
|
|
9697
|
+
return internalError(err, 'ai seo explain-issue');
|
|
9698
|
+
}
|
|
9699
|
+
});
|
|
9700
|
+
router.post('/ai/seo/generate-schema', async (request) => {
|
|
9701
|
+
try {
|
|
9702
|
+
const auth = await requireAuth(request);
|
|
9703
|
+
if (auth.error)
|
|
9704
|
+
return auth.error;
|
|
9705
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
9706
|
+
if (roleErr)
|
|
9707
|
+
return roleErr;
|
|
9708
|
+
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-schema:${auth.session.userId}`))) {
|
|
9709
|
+
return errorResponse('AI rate limit reached. Try again in an hour.', 429);
|
|
9710
|
+
}
|
|
9711
|
+
const body = (await request.json().catch(() => null));
|
|
9712
|
+
if (!body?.entityType || !body?.entityId || !body?.schemaType) {
|
|
9713
|
+
return errorResponse('entityType, entityId and schemaType are required', 400);
|
|
9714
|
+
}
|
|
9715
|
+
const entity = await loadSeoEntity(body.entityType, body.entityId);
|
|
9716
|
+
if (!entity)
|
|
9717
|
+
return errorResponse('Content not found', 404);
|
|
9718
|
+
let ai;
|
|
9719
|
+
try {
|
|
9720
|
+
ai = await importAIPlugin();
|
|
9721
|
+
}
|
|
9722
|
+
catch {
|
|
9723
|
+
return errorResponse('AI plugin is not installed.', 501);
|
|
9724
|
+
}
|
|
9725
|
+
try {
|
|
9726
|
+
const raw = await ai.generate(`Generate valid Schema.org JSON-LD of type "${body.schemaType}" for this content. Return ONLY a JSON object (no markdown fences), starting with {"@context":"https://schema.org","@type":"${body.schemaType}"}.\n\nTitle: ${entity.title}\nContent: ${entity.content.slice(0, 2000)}`);
|
|
9727
|
+
const cleaned = raw
|
|
9728
|
+
.replace(/```json?\n?/g, '')
|
|
9729
|
+
.replace(/```/g, '')
|
|
9730
|
+
.trim();
|
|
9731
|
+
let structuredData;
|
|
9732
|
+
try {
|
|
9733
|
+
structuredData = JSON.parse(cleaned);
|
|
9734
|
+
}
|
|
9735
|
+
catch {
|
|
9736
|
+
return errorResponse('AI returned invalid JSON-LD. Please try again.', 422);
|
|
9737
|
+
}
|
|
9738
|
+
// Minimal validation: must be an object carrying an @type. Never trust
|
|
9739
|
+
// raw model output for structured data.
|
|
9740
|
+
if (!structuredData ||
|
|
9741
|
+
typeof structuredData !== 'object' ||
|
|
9742
|
+
Array.isArray(structuredData) ||
|
|
9743
|
+
!('@type' in structuredData)) {
|
|
9744
|
+
return errorResponse('AI output did not contain valid structured data.', 422);
|
|
9745
|
+
}
|
|
9746
|
+
if (!('@context' in structuredData)) {
|
|
9747
|
+
structuredData['@context'] = 'https://schema.org';
|
|
9748
|
+
}
|
|
9749
|
+
return json({ data: { structuredData } });
|
|
9750
|
+
}
|
|
9751
|
+
catch (err) {
|
|
9752
|
+
if (isAiUnconfigured(err))
|
|
9753
|
+
return errorResponse('AI provider is not configured.', 501);
|
|
9754
|
+
throw err;
|
|
9755
|
+
}
|
|
9756
|
+
}
|
|
9757
|
+
catch (err) {
|
|
9758
|
+
return internalError(err, 'ai seo generate-schema');
|
|
9759
|
+
}
|
|
9760
|
+
});
|
|
9761
|
+
router.post('/ai/seo/summarize', async (request) => {
|
|
9762
|
+
try {
|
|
9763
|
+
const auth = await requireAuth(request);
|
|
9764
|
+
if (auth.error)
|
|
9765
|
+
return auth.error;
|
|
9766
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
9767
|
+
if (roleErr)
|
|
9768
|
+
return roleErr;
|
|
9769
|
+
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-summary:${auth.session.userId}`))) {
|
|
9770
|
+
return errorResponse('AI rate limit reached. Try again in an hour.', 429);
|
|
9771
|
+
}
|
|
9772
|
+
let openIssues = [];
|
|
9773
|
+
if (hasModel(db(), 'seoIssue')) {
|
|
9774
|
+
openIssues = await db().seoIssue.findMany({
|
|
9775
|
+
where: { status: { in: ['open', 'recurring'] } },
|
|
9776
|
+
orderBy: [{ severity: 'asc' }, { lastDetectedAt: 'desc' }],
|
|
9777
|
+
take: 40,
|
|
9778
|
+
});
|
|
9779
|
+
}
|
|
9780
|
+
if (openIssues.length === 0) {
|
|
9781
|
+
return json({
|
|
9782
|
+
data: { text: 'No open SEO issues found. Run an audit to scan for new issues.' },
|
|
9783
|
+
});
|
|
9784
|
+
}
|
|
9785
|
+
let ai;
|
|
9786
|
+
try {
|
|
9787
|
+
ai = await importAIPlugin();
|
|
9788
|
+
}
|
|
9789
|
+
catch {
|
|
9790
|
+
return errorResponse('AI plugin is not installed.', 501);
|
|
9791
|
+
}
|
|
9792
|
+
const issueList = openIssues
|
|
9793
|
+
.slice(0, 25)
|
|
9794
|
+
.map((i) => `- [${i.severity}] ${i.title} (${i.entityTitle ?? i.url ?? 'site-wide'})`)
|
|
9795
|
+
.join('\n');
|
|
9796
|
+
try {
|
|
9797
|
+
const text = await ai.generate(`You are an SEO strategist. Summarize the state of this site's SEO from the open issues below in 3-4 sentences, then list the top 3 priorities to fix first.\n\nOpen issues:\n${issueList}`);
|
|
9798
|
+
return json({ data: { text: text.trim() } });
|
|
9799
|
+
}
|
|
9800
|
+
catch (err) {
|
|
9801
|
+
if (isAiUnconfigured(err))
|
|
9802
|
+
return errorResponse('AI provider is not configured.', 501);
|
|
9803
|
+
throw err;
|
|
9804
|
+
}
|
|
9805
|
+
}
|
|
9806
|
+
catch (err) {
|
|
9807
|
+
return internalError(err, 'ai seo summarize');
|
|
9808
|
+
}
|
|
9809
|
+
});
|
|
7943
9810
|
}
|
|
7944
9811
|
//# sourceMappingURL=handlers.js.map
|