@prenta/core 7.7.1 → 7.8.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/CHANGELOG.md +12 -0
- package/dist/api/handler-factory.d.ts.map +1 -1
- package/dist/api/handler-factory.js +40 -14
- package/dist/api/handler-factory.js.map +1 -1
- package/dist/api/openapi.d.ts.map +1 -1
- package/dist/api/openapi.js +8 -2
- package/dist/api/openapi.js.map +1 -1
- package/dist/api/route-helpers/auth.d.ts +131 -0
- package/dist/api/route-helpers/auth.d.ts.map +1 -0
- package/dist/api/route-helpers/auth.js +505 -0
- package/dist/api/route-helpers/auth.js.map +1 -0
- package/dist/api/route-helpers/authoring.d.ts +32 -0
- package/dist/api/route-helpers/authoring.d.ts.map +1 -0
- package/dist/api/route-helpers/authoring.js +99 -0
- package/dist/api/route-helpers/authoring.js.map +1 -0
- package/dist/api/route-helpers/forms.d.ts +46 -0
- package/dist/api/route-helpers/forms.d.ts.map +1 -0
- package/dist/api/route-helpers/forms.js +435 -0
- package/dist/api/route-helpers/forms.js.map +1 -0
- package/dist/api/route-helpers/http.d.ts +87 -0
- package/dist/api/route-helpers/http.d.ts.map +1 -0
- package/dist/api/route-helpers/http.js +286 -0
- package/dist/api/route-helpers/http.js.map +1 -0
- package/dist/api/route-helpers/media.d.ts +37 -0
- package/dist/api/route-helpers/media.d.ts.map +1 -0
- package/dist/api/route-helpers/media.js +189 -0
- package/dist/api/route-helpers/media.js.map +1 -0
- package/dist/api/route-helpers/page-shape.d.ts +31 -0
- package/dist/api/route-helpers/page-shape.d.ts.map +1 -0
- package/dist/api/route-helpers/page-shape.js +98 -0
- package/dist/api/route-helpers/page-shape.js.map +1 -0
- package/dist/api/route-helpers.d.ts +8 -358
- package/dist/api/route-helpers.d.ts.map +1 -1
- package/dist/api/route-helpers.js +7 -1592
- package/dist/api/route-helpers.js.map +1 -1
- package/dist/api/routes/users/handlers/api-keys.d.ts +4 -0
- package/dist/api/routes/users/handlers/api-keys.d.ts.map +1 -0
- package/dist/api/routes/users/handlers/api-keys.js +164 -0
- package/dist/api/routes/users/handlers/api-keys.js.map +1 -0
- package/dist/api/routes/users/handlers/invites.d.ts +7 -0
- package/dist/api/routes/users/handlers/invites.d.ts.map +1 -0
- package/dist/api/routes/users/handlers/invites.js +201 -0
- package/dist/api/routes/users/handlers/invites.js.map +1 -0
- package/dist/api/routes/users/handlers/users-admin.d.ts +7 -0
- package/dist/api/routes/users/handlers/users-admin.d.ts.map +1 -0
- package/dist/api/routes/users/handlers/users-admin.js +310 -0
- package/dist/api/routes/users/handlers/users-admin.js.map +1 -0
- package/dist/api/routes/users/handlers/users-detail.d.ts +16 -0
- package/dist/api/routes/users/handlers/users-detail.d.ts.map +1 -0
- package/dist/api/routes/users/handlers/users-detail.js +525 -0
- package/dist/api/routes/users/handlers/users-detail.js.map +1 -0
- package/dist/api/routes/users.d.ts +4 -0
- package/dist/api/routes/users.d.ts.map +1 -1
- package/dist/api/routes/users.js +38 -1177
- package/dist/api/routes/users.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { publicReadCacheControl } from '../../cache/http.js';
|
|
2
|
+
import { createLogger } from '../../diagnostics/logger.js';
|
|
3
|
+
import { captureServerException } from '../../diagnostics/sentry.js';
|
|
4
|
+
import { getPrentaConfig } from '../../config/runtime.js';
|
|
5
|
+
import { ModelNotAvailableError } from '../guarded-db.js';
|
|
6
|
+
import { SECURITY_HEADERS } from '../security-headers.js';
|
|
7
|
+
export const logger = createLogger('api');
|
|
8
|
+
export function json(data, status = 200) {
|
|
9
|
+
return new Response(JSON.stringify(data), {
|
|
10
|
+
status,
|
|
11
|
+
headers: { ...SECURITY_HEADERS },
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Variant of `json()` that emits the Phase-5.5 public-read
|
|
16
|
+
* `Cache-Control` header when the active CMS config opts in.
|
|
17
|
+
*
|
|
18
|
+
* - Session-cookie requests always get `private, no-store`.
|
|
19
|
+
* - API-key / unauthenticated requests get
|
|
20
|
+
* `public, s-maxage=N, stale-while-revalidate=M` when
|
|
21
|
+
* `cache.publicReads.enabled` is true.
|
|
22
|
+
* - Otherwise no `Cache-Control` header is set (i.e. the response is
|
|
23
|
+
* uncacheable downstream, matching the pre-5.5 behaviour).
|
|
24
|
+
*
|
|
25
|
+
* Use this for the public read endpoints (`/collections/:slug`,
|
|
26
|
+
* `/collections/:slug/:id`, `/globals/:slug`, `/resolve`). Mutating
|
|
27
|
+
* endpoints stay on the plain `json()` so the CDN never caches a write
|
|
28
|
+
* response by accident.
|
|
29
|
+
*/
|
|
30
|
+
export function jsonWithCache(data, request, status = 200) {
|
|
31
|
+
const cacheControl = publicReadCacheControl(request, getPrentaConfig()?.cache);
|
|
32
|
+
const headers = { ...SECURITY_HEADERS };
|
|
33
|
+
if (cacheControl)
|
|
34
|
+
headers['Cache-Control'] = cacheControl;
|
|
35
|
+
return new Response(JSON.stringify(data), { status, headers });
|
|
36
|
+
}
|
|
37
|
+
export function errorResponse(message, status) {
|
|
38
|
+
return json({ error: message }, status);
|
|
39
|
+
}
|
|
40
|
+
export function internalError(err, context) {
|
|
41
|
+
if (err instanceof ModelNotAvailableError) {
|
|
42
|
+
return modelNotAvailable(err.model);
|
|
43
|
+
}
|
|
44
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
45
|
+
logger.error(msg, context ? { context } : undefined);
|
|
46
|
+
// Fire-and-forget: capture lazily imports the optional @sentry/node and never
|
|
47
|
+
// throws, so we don't await it before returning the error response.
|
|
48
|
+
void captureServerException(err, { scope: 'api', extra: context ? { context } : undefined });
|
|
49
|
+
if (process.env.PRENTA_DEBUG_ERRORS === '1') {
|
|
50
|
+
return errorResponse(msg, 500);
|
|
51
|
+
}
|
|
52
|
+
return errorResponse('Internal server error', 500);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Map a thrown action-layer error to the right HTTP status. The data layer
|
|
56
|
+
* throws plain `Error`s with stable, human-readable prefixes for client-fixable
|
|
57
|
+
* problems (publish validation, access/permission, not-found); those are 4xx
|
|
58
|
+
* and the message is safe to return verbatim. Anything else falls through to
|
|
59
|
+
* {@link internalError} (500, generic message, logged).
|
|
60
|
+
*/
|
|
61
|
+
export function actionError(err, context) {
|
|
62
|
+
if (err instanceof ModelNotAvailableError)
|
|
63
|
+
return modelNotAvailable(err.model);
|
|
64
|
+
const conflict = uniqueConstraintConflict(err);
|
|
65
|
+
if (conflict)
|
|
66
|
+
return errorResponse(conflict, 409);
|
|
67
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
68
|
+
if (/^Cannot publish —/.test(msg))
|
|
69
|
+
return errorResponse(msg, 422);
|
|
70
|
+
if (/^Invalid media field/i.test(msg))
|
|
71
|
+
return errorResponse(msg, 400);
|
|
72
|
+
if (/insufficient permissions/i.test(msg) ||
|
|
73
|
+
/^Access denied/i.test(msg) ||
|
|
74
|
+
/submit for review before publishing/i.test(msg)) {
|
|
75
|
+
return errorResponse(msg, 403);
|
|
76
|
+
}
|
|
77
|
+
if (/not found/i.test(msg))
|
|
78
|
+
return errorResponse(msg, 404);
|
|
79
|
+
return internalError(err, context);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Detect a Prisma unique-constraint violation (P2002) via its stable `code`,
|
|
83
|
+
* without importing the heavy Prisma error class. Returns a client-safe 409
|
|
84
|
+
* message, or null when `err` is not a unique-constraint error. The usual
|
|
85
|
+
* trigger is a duplicate (collection, slug) on create/rename/publish — which
|
|
86
|
+
* previously fell through to a 500 (#560); a genuine live-slug clash is a
|
|
87
|
+
* client-fixable conflict, so it belongs at 409.
|
|
88
|
+
*/
|
|
89
|
+
function uniqueConstraintConflict(err) {
|
|
90
|
+
if (!err || typeof err !== 'object' || err.code !== 'P2002') {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const target = err.meta?.target;
|
|
94
|
+
const fields = Array.isArray(target)
|
|
95
|
+
? target.map(String)
|
|
96
|
+
: typeof target === 'string'
|
|
97
|
+
? [target]
|
|
98
|
+
: [];
|
|
99
|
+
if (fields.some((f) => f.toLowerCase().includes('slug'))) {
|
|
100
|
+
return 'A document with this slug already exists in this collection.';
|
|
101
|
+
}
|
|
102
|
+
return 'That value is already in use.';
|
|
103
|
+
}
|
|
104
|
+
export function clampPageSize(raw, max = 100, fallback = 20) {
|
|
105
|
+
const n = Number(raw) || fallback;
|
|
106
|
+
return Math.min(Math.max(1, n), max);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Clamp a 1-based list `page` query param. Negative / zero / NaN values must
|
|
110
|
+
* never reach Prisma `skip` (negative OFFSET → validation error → opaque 500).
|
|
111
|
+
* Mirrors forms/users/redirects (`Math.max(1, …)`).
|
|
112
|
+
*/
|
|
113
|
+
export function clampPage(raw, fallback = 1) {
|
|
114
|
+
const n = Number(raw);
|
|
115
|
+
if (!Number.isFinite(n) || n < 1)
|
|
116
|
+
return fallback;
|
|
117
|
+
return Math.min(Math.floor(n), 100_000);
|
|
118
|
+
}
|
|
119
|
+
/** Parse a JSON object body; invalid JSON or a non-object yields a 400. */
|
|
120
|
+
export async function readJsonBody(request) {
|
|
121
|
+
try {
|
|
122
|
+
const parsed = await request.json();
|
|
123
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
124
|
+
return { ok: false, error: errorResponse('Invalid JSON body', 400) };
|
|
125
|
+
}
|
|
126
|
+
return { ok: true, data: parsed };
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return { ok: false, error: errorResponse('Invalid JSON body', 400) };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export function asRecord(value) {
|
|
133
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
134
|
+
? value
|
|
135
|
+
: {};
|
|
136
|
+
}
|
|
137
|
+
export function hasModel(d, name) {
|
|
138
|
+
try {
|
|
139
|
+
return d[name] && typeof d[name].findMany === 'function';
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Format a number compactly, e.g. 8400 -> "8.4K", 1_200_000 -> "1.2M". */
|
|
146
|
+
export function formatCompactNumber(n) {
|
|
147
|
+
if (!Number.isFinite(n))
|
|
148
|
+
return '0';
|
|
149
|
+
if (Math.abs(n) >= 1_000_000)
|
|
150
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
151
|
+
if (Math.abs(n) >= 1_000)
|
|
152
|
+
return `${(n / 1_000).toFixed(1)}K`;
|
|
153
|
+
return String(Math.round(n));
|
|
154
|
+
}
|
|
155
|
+
/** Minimal CSV line parser supporting double-quoted fields with escaped quotes. */
|
|
156
|
+
export function parseCsvLine(line) {
|
|
157
|
+
const out = [];
|
|
158
|
+
let cur = '';
|
|
159
|
+
let inQuotes = false;
|
|
160
|
+
for (let i = 0; i < line.length; i++) {
|
|
161
|
+
const ch = line[i];
|
|
162
|
+
if (inQuotes) {
|
|
163
|
+
if (ch === '"') {
|
|
164
|
+
if (line[i + 1] === '"') {
|
|
165
|
+
cur += '"';
|
|
166
|
+
i++;
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
inQuotes = false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
cur += ch;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
else if (ch === '"') {
|
|
177
|
+
inQuotes = true;
|
|
178
|
+
}
|
|
179
|
+
else if (ch === ',') {
|
|
180
|
+
out.push(cur);
|
|
181
|
+
cur = '';
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
cur += ch;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
out.push(cur);
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
export function modelNotAvailable(name) {
|
|
191
|
+
return errorResponse(`The "${name}" model is not available in your Prisma schema. ` +
|
|
192
|
+
'Run `prenta db:init` for new schemas, or carefully update the existing Prenta block, create/apply a Prisma migration, then regenerate Prisma Client. ' +
|
|
193
|
+
'See https://prentacms.dev/docs/database-setup for required models.', 501);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* XML-escape a value for safe inclusion in sitemap content. Sitemaps reject
|
|
197
|
+
* unescaped ampersands and angle brackets; URLs frequently contain `&` query
|
|
198
|
+
* separators, so this is non-optional.
|
|
199
|
+
*/
|
|
200
|
+
export function escapeXml(value) {
|
|
201
|
+
return value
|
|
202
|
+
.replace(/&/g, '&')
|
|
203
|
+
.replace(/</g, '<')
|
|
204
|
+
.replace(/>/g, '>')
|
|
205
|
+
.replace(/"/g, '"')
|
|
206
|
+
.replace(/'/g, ''');
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* URLs per sitemap file. The sitemaps protocol caps a single file at 50,000
|
|
210
|
+
* URLs, but the child-sitemap route must load each row's full `data` JSON blob
|
|
211
|
+
* (for path/noindex/images) into memory to build the XML — at 45k rows that is
|
|
212
|
+
* hundreds of MB per request on an unauthenticated, crawler-hit endpoint. We
|
|
213
|
+
* cap well below the protocol limit to bound per-request memory; collections
|
|
214
|
+
* larger than this are split across multiple `?page=N` files referenced from
|
|
215
|
+
* the sitemap index, so no URLs are lost.
|
|
216
|
+
*/
|
|
217
|
+
export const SITEMAP_PAGE_SIZE = 10000;
|
|
218
|
+
/**
|
|
219
|
+
* Log unexpected query failures from the `safe*` wrappers.
|
|
220
|
+
*
|
|
221
|
+
* The wrappers exist so an optional Prisma model (e.g. a delegate
|
|
222
|
+
* that older schemas don't define) doesn't blow up the route — they
|
|
223
|
+
* return the empty value (`0` / `[]`) when the model isn't available.
|
|
224
|
+
* That defensive contract historically swallowed *every* error,
|
|
225
|
+
* including Prisma's "Unknown argument" rejections that flag a real
|
|
226
|
+
* application bug (see the `relationLoadStrategy` regression
|
|
227
|
+
* discovered while landing Phase 5.5 #5). Logging the underlying
|
|
228
|
+
* error preserves the defensive return semantics while making the
|
|
229
|
+
* actual class of bug visible in Vercel + Sentry logs.
|
|
230
|
+
*
|
|
231
|
+
* Kept private to this module — it's a logging contract, not a
|
|
232
|
+
* public API.
|
|
233
|
+
*/
|
|
234
|
+
export function logSafeQueryFailure(scope, err) {
|
|
235
|
+
// Defensive: never throw from a logger that wraps `safe*` queries
|
|
236
|
+
// — the whole point of those wrappers is to keep the request hot
|
|
237
|
+
// path alive.
|
|
238
|
+
try {
|
|
239
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
240
|
+
logger.warn(`${scope} query failed`, { error: message });
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
/* swallow */
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
export async function safeCount(model, where) {
|
|
247
|
+
if (!model || typeof model !== 'object')
|
|
248
|
+
return 0;
|
|
249
|
+
let countFn;
|
|
250
|
+
try {
|
|
251
|
+
countFn = model.count;
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return 0;
|
|
255
|
+
}
|
|
256
|
+
if (typeof countFn !== 'function')
|
|
257
|
+
return 0;
|
|
258
|
+
try {
|
|
259
|
+
return await countFn.call(model, where ? { where } : undefined);
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
logSafeQueryFailure('safeCount', err);
|
|
263
|
+
return 0;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
export async function safeFindMany(model, args) {
|
|
267
|
+
if (!model || typeof model !== 'object')
|
|
268
|
+
return [];
|
|
269
|
+
let findManyFn;
|
|
270
|
+
try {
|
|
271
|
+
findManyFn = model.findMany;
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return [];
|
|
275
|
+
}
|
|
276
|
+
if (typeof findManyFn !== 'function')
|
|
277
|
+
return [];
|
|
278
|
+
try {
|
|
279
|
+
return await findManyFn.call(model, args);
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
logSafeQueryFailure('safeFindMany', err);
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
//# sourceMappingURL=http.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http.js","sourceRoot":"","sources":["../../../src/api/route-helpers/http.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export declare function importBlobStorage(): Promise<any>;
|
|
2
|
+
export declare function mediaUrl(storageKey: unknown): string;
|
|
3
|
+
export declare function normalizeMediaItem(media: any): any;
|
|
4
|
+
/**
|
|
5
|
+
* Map the `MediaUsage` join rows (with their related document) into the
|
|
6
|
+
* `{ page, path }[]` shape the admin drawer renders. Soft-deleted documents
|
|
7
|
+
* are excluded so trashed pages don't keep an asset looking "in use".
|
|
8
|
+
*/
|
|
9
|
+
export declare function buildMediaUsedOn(mediaUsages: unknown): {
|
|
10
|
+
page: string;
|
|
11
|
+
path: string;
|
|
12
|
+
}[];
|
|
13
|
+
/**
|
|
14
|
+
* Derive a concise, Title-Cased title from AI-generated alt text. The alt
|
|
15
|
+
* text is already a model output describing the image, so the title stays
|
|
16
|
+
* grounded in real content rather than the filename.
|
|
17
|
+
*/
|
|
18
|
+
export declare function deriveMediaTitleFromAlt(alt: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* String-level gate before fetching a media `storageKey`. Delegates to
|
|
21
|
+
* {@link validateWebhookUrl} (IP canonicalization for decimal/octal/hex,
|
|
22
|
+
* CGNAT, metadata, etc.) so this allowlist cannot drift from the SSRF policy
|
|
23
|
+
* used by `safeFetch`. Also rejects `*.local` (mDNS) which webhook validation
|
|
24
|
+
* does not cover.
|
|
25
|
+
*/
|
|
26
|
+
export declare function isAllowedStorageUrl(url: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Fetch image bytes for multimodal (vision) analysis through {@link safeFetch}
|
|
29
|
+
* (SSRF-safe: re-validates the host, DNS-resolves to defeat rebinding, no
|
|
30
|
+
* redirects). Returns `null` on any failure (non-image, oversize, blocked,
|
|
31
|
+
* network) so the caller transparently falls back to a context-only heuristic.
|
|
32
|
+
*/
|
|
33
|
+
export declare function fetchImageForVision(url: string, knownMimeType?: string): Promise<{
|
|
34
|
+
base64: string;
|
|
35
|
+
mimeType: string;
|
|
36
|
+
} | null>;
|
|
37
|
+
//# sourceMappingURL=media.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"media.d.ts","sourceRoot":"","sources":["../../../src/api/route-helpers/media.ts"],"names":[],"mappings":"AAQA,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,GAAG,CAAC,CAGtD;AAED,wBAAgB,QAAQ,CAAC,UAAU,EAAE,OAAO,GAAG,MAAM,CAMpD;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,GAAG,OA8C5C;AAeD;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,WAAW,EAAE,OAAO,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,CAcvF;AAsBD;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAa3D;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CASxD;AAKD;;;;;GAKG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,MAAM,EACX,aAAa,CAAC,EAAE,MAAM,GACrB,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAgBtD"}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { formatBytes } from '../../media/optimize.js';
|
|
2
|
+
import { calculateMediaSeoScore } from '../../media/seo.js';
|
|
3
|
+
import { safeFetch } from '../../security/safe-fetch.js';
|
|
4
|
+
import { validateWebhookUrl } from '../../security/webhook.js';
|
|
5
|
+
import { getPrentaConfig } from '../../config/runtime.js';
|
|
6
|
+
// Opaque dynamic import so Turbopack/webpack won't statically analyze the specifier.
|
|
7
|
+
// Returns { put, del, ... } from @vercel/blob when available.
|
|
8
|
+
export async function importBlobStorage() {
|
|
9
|
+
const mod = '@vercel/' + 'blob';
|
|
10
|
+
return import(/* webpackIgnore: true */ /* turbopackIgnore: true */ /* @vite-ignore */ mod);
|
|
11
|
+
}
|
|
12
|
+
export function mediaUrl(storageKey) {
|
|
13
|
+
const value = String(storageKey ?? '');
|
|
14
|
+
if (!value)
|
|
15
|
+
return '';
|
|
16
|
+
return value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/')
|
|
17
|
+
? value
|
|
18
|
+
: '';
|
|
19
|
+
}
|
|
20
|
+
export function normalizeMediaItem(media) {
|
|
21
|
+
const width = typeof media.width === 'number' ? media.width : null;
|
|
22
|
+
const height = typeof media.height === 'number' ? media.height : null;
|
|
23
|
+
const sizeBytes = Number(media.fileSize ?? media.sizeBytes ?? 0);
|
|
24
|
+
const isDecorative = media.isDecorative === true;
|
|
25
|
+
const usedOnTitles = Array.isArray(media.mediaUsages)
|
|
26
|
+
? media.mediaUsages
|
|
27
|
+
.map((usage) => usage?.document?.title)
|
|
28
|
+
.filter((title) => typeof title === 'string' && title.trim().length > 0)
|
|
29
|
+
: [];
|
|
30
|
+
// Derived on every read (never persisted) so the score can't drift from the
|
|
31
|
+
// metadata — see `media/seo.ts` for the weight contract.
|
|
32
|
+
const seo = calculateMediaSeoScore({
|
|
33
|
+
fileName: media.filename ?? media.name ?? null,
|
|
34
|
+
mimeType: media.mimeType ?? media.type ?? null,
|
|
35
|
+
altText: media.altText ?? media.altTag ?? null,
|
|
36
|
+
title: media.title ?? null,
|
|
37
|
+
caption: media.caption ?? null,
|
|
38
|
+
description: media.description ?? null,
|
|
39
|
+
focusKeyword: media.focusKeyword ?? null,
|
|
40
|
+
sizeBytes,
|
|
41
|
+
width,
|
|
42
|
+
height,
|
|
43
|
+
isDecorative,
|
|
44
|
+
usedOnTitles,
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
...media,
|
|
48
|
+
name: media.filename ?? media.name ?? '',
|
|
49
|
+
type: media.mimeType ?? media.type ?? '',
|
|
50
|
+
size: formatBytes(sizeBytes),
|
|
51
|
+
sizeBytes,
|
|
52
|
+
date: media.createdAt ?? media.updatedAt ?? null,
|
|
53
|
+
url: mediaUrl(media.storageKey ?? media.url),
|
|
54
|
+
dimensions: width && height ? `${width}x${height}` : undefined,
|
|
55
|
+
format: typeof media.mimeType === 'string' ? media.mimeType.split('/')[1] : undefined,
|
|
56
|
+
altTag: media.altText ?? media.altTag ?? '',
|
|
57
|
+
isDecorative,
|
|
58
|
+
...(media.aiAltText != null ? { aiAltSuggestion: media.aiAltText } : {}),
|
|
59
|
+
title: media.title ?? '',
|
|
60
|
+
seoScore: seo.score,
|
|
61
|
+
seoGrade: seo.grade,
|
|
62
|
+
seoIssues: seo.issues,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Build the admin editor route for a document that references a media asset.
|
|
67
|
+
* Pages own the `/pages/*` namespace; post-type collections use
|
|
68
|
+
* `/posts/:type/:id/edit`; everything else falls back to the generic
|
|
69
|
+
* collection editor. Kept in sync with the route table in `AdminRoot.tsx`.
|
|
70
|
+
*/
|
|
71
|
+
function mediaUsageEditPath(collection, id) {
|
|
72
|
+
if (collection === 'pages')
|
|
73
|
+
return `/pages/${id}/edit`;
|
|
74
|
+
const def = getPrentaConfig()?.collections?.[collection];
|
|
75
|
+
if (def?.type === 'post')
|
|
76
|
+
return `/posts/${collection}/${id}/edit`;
|
|
77
|
+
return `/collections/${collection}/${id}`;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Map the `MediaUsage` join rows (with their related document) into the
|
|
81
|
+
* `{ page, path }[]` shape the admin drawer renders. Soft-deleted documents
|
|
82
|
+
* are excluded so trashed pages don't keep an asset looking "in use".
|
|
83
|
+
*/
|
|
84
|
+
export function buildMediaUsedOn(mediaUsages) {
|
|
85
|
+
if (!Array.isArray(mediaUsages))
|
|
86
|
+
return [];
|
|
87
|
+
const seen = new Set();
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const usage of mediaUsages) {
|
|
90
|
+
const doc = usage?.document;
|
|
91
|
+
if (!doc || doc.deletedAt || seen.has(doc.id))
|
|
92
|
+
continue;
|
|
93
|
+
seen.add(doc.id);
|
|
94
|
+
out.push({
|
|
95
|
+
page: doc.title || '(untitled)',
|
|
96
|
+
path: mediaUsageEditPath(doc.collection, doc.id),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
const MEDIA_TITLE_STOPWORDS = new Set([
|
|
102
|
+
'a',
|
|
103
|
+
'an',
|
|
104
|
+
'the',
|
|
105
|
+
'of',
|
|
106
|
+
'and',
|
|
107
|
+
'or',
|
|
108
|
+
'to',
|
|
109
|
+
'in',
|
|
110
|
+
'on',
|
|
111
|
+
'for',
|
|
112
|
+
'with',
|
|
113
|
+
'at',
|
|
114
|
+
'by',
|
|
115
|
+
'from',
|
|
116
|
+
'is',
|
|
117
|
+
'are',
|
|
118
|
+
'as',
|
|
119
|
+
]);
|
|
120
|
+
/**
|
|
121
|
+
* Derive a concise, Title-Cased title from AI-generated alt text. The alt
|
|
122
|
+
* text is already a model output describing the image, so the title stays
|
|
123
|
+
* grounded in real content rather than the filename.
|
|
124
|
+
*/
|
|
125
|
+
export function deriveMediaTitleFromAlt(alt) {
|
|
126
|
+
if (!alt)
|
|
127
|
+
return '';
|
|
128
|
+
const firstClause = (alt.split(/[.;:\n]/)[0] ?? '').trim();
|
|
129
|
+
const titled = firstClause
|
|
130
|
+
.split(/\s+/)
|
|
131
|
+
.slice(0, 9)
|
|
132
|
+
.map((word, i) => {
|
|
133
|
+
const lower = word.toLowerCase();
|
|
134
|
+
if (i > 0 && MEDIA_TITLE_STOPWORDS.has(lower))
|
|
135
|
+
return lower;
|
|
136
|
+
return word.charAt(0).toUpperCase() + word.slice(1);
|
|
137
|
+
})
|
|
138
|
+
.join(' ');
|
|
139
|
+
return titled.slice(0, 80).replace(/[\s,]+$/, '');
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* String-level gate before fetching a media `storageKey`. Delegates to
|
|
143
|
+
* {@link validateWebhookUrl} (IP canonicalization for decimal/octal/hex,
|
|
144
|
+
* CGNAT, metadata, etc.) so this allowlist cannot drift from the SSRF policy
|
|
145
|
+
* used by `safeFetch`. Also rejects `*.local` (mDNS) which webhook validation
|
|
146
|
+
* does not cover.
|
|
147
|
+
*/
|
|
148
|
+
export function isAllowedStorageUrl(url) {
|
|
149
|
+
if (!validateWebhookUrl(url).valid)
|
|
150
|
+
return false;
|
|
151
|
+
try {
|
|
152
|
+
const h = new URL(url).hostname.toLowerCase();
|
|
153
|
+
if (h.endsWith('.local'))
|
|
154
|
+
return false;
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/** Max image size (bytes) we'll pull into memory for vision analysis. */
|
|
162
|
+
const MAX_VISION_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
163
|
+
/**
|
|
164
|
+
* Fetch image bytes for multimodal (vision) analysis through {@link safeFetch}
|
|
165
|
+
* (SSRF-safe: re-validates the host, DNS-resolves to defeat rebinding, no
|
|
166
|
+
* redirects). Returns `null` on any failure (non-image, oversize, blocked,
|
|
167
|
+
* network) so the caller transparently falls back to a context-only heuristic.
|
|
168
|
+
*/
|
|
169
|
+
export async function fetchImageForVision(url, knownMimeType) {
|
|
170
|
+
try {
|
|
171
|
+
const resp = await safeFetch(url, { method: 'GET', timeoutMs: 5000 });
|
|
172
|
+
if (!resp.ok)
|
|
173
|
+
return null;
|
|
174
|
+
const contentType = ((resp.headers.get('content-type') ?? knownMimeType ?? '').split(';')[0] ?? '').trim();
|
|
175
|
+
if (!contentType.startsWith('image/'))
|
|
176
|
+
return null;
|
|
177
|
+
const declaredLength = Number(resp.headers.get('content-length') ?? '0');
|
|
178
|
+
if (declaredLength > MAX_VISION_IMAGE_BYTES)
|
|
179
|
+
return null;
|
|
180
|
+
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
181
|
+
if (buffer.byteLength === 0 || buffer.byteLength > MAX_VISION_IMAGE_BYTES)
|
|
182
|
+
return null;
|
|
183
|
+
return { base64: buffer.toString('base64'), mimeType: contentType };
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
//# sourceMappingURL=media.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"media.js","sourceRoot":"","sources":["../../../src/api/route-helpers/media.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export declare function normalizeRedirect(redirect: any): any;
|
|
2
|
+
export declare function normalizeSeoPage(page: any): {
|
|
3
|
+
id: any;
|
|
4
|
+
collection: any;
|
|
5
|
+
updatedAt: any;
|
|
6
|
+
url: {};
|
|
7
|
+
title: any;
|
|
8
|
+
score: number;
|
|
9
|
+
issues: number;
|
|
10
|
+
metaTitle: {};
|
|
11
|
+
metaDescription: {};
|
|
12
|
+
canonical: {};
|
|
13
|
+
schemaType: {};
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Renders a 1200x630 SVG suitable for og:image. We don't pull in Satori/resvg
|
|
17
|
+
* because most integrators don't need PNG — major crawlers (Facebook, Twitter,
|
|
18
|
+
* LinkedIn, Slack, Discord) handle image/svg+xml correctly. Sites that
|
|
19
|
+
* specifically need PNG can override with their own /og endpoint via
|
|
20
|
+
* @vercel/og and point `seo.defaultOgImage` at it.
|
|
21
|
+
*/
|
|
22
|
+
export declare function renderOgSvg(opts: {
|
|
23
|
+
title: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
siteName?: string | null;
|
|
26
|
+
theme: 'light' | 'dark';
|
|
27
|
+
bg: string;
|
|
28
|
+
fg: string;
|
|
29
|
+
muted: string;
|
|
30
|
+
}): string;
|
|
31
|
+
//# sourceMappingURL=page-shape.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"page-shape.d.ts","sourceRoot":"","sources":["../../../src/api/route-helpers/page-shape.ts"],"names":[],"mappings":"AAEA,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,GAAG,OAS9C;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,GAAG;;;;;;;;;;;;EAyBzC;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE;IAChC,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,KAAK,EAAE,OAAO,GAAG,MAAM,CAAA;IACvB,EAAE,EAAE,MAAM,CAAA;IACV,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAA;CACd,GAAG,MAAM,CAyDT"}
|