@byline/core 4.0.0 → 4.2.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/@types/admin-types.d.ts +74 -2
- package/dist/@types/admin-types.js +29 -0
- package/dist/@types/collection-types.d.ts +29 -7
- package/dist/@types/db-types.d.ts +7 -7
- package/dist/@types/field-data-types.d.ts +1 -0
- package/dist/@types/field-types.d.ts +36 -1
- package/dist/@types/site-config.d.ts +28 -1
- package/dist/@types/storage-types.d.ts +14 -1
- package/dist/codegen/fixtures/all-fields.d.ts +23 -0
- package/dist/codegen/fixtures/all-fields.expected.d.ts +4 -0
- package/dist/codegen/fixtures/all-fields.js +2 -0
- package/dist/codegen/index.js +36 -3
- package/dist/codegen/index.test.node.js +20 -2
- package/dist/config/config.js +2 -1
- package/dist/config/validate-admin-configs.d.ts +17 -1
- package/dist/config/validate-admin-configs.js +84 -11
- package/dist/config/validate-admin-configs.test.node.js +114 -1
- package/dist/config/validate-collections.js +43 -0
- package/dist/config/validate-collections.test.node.js +60 -0
- package/dist/host/host-request-bridge.d.ts +62 -0
- package/dist/host/host-request-bridge.js +38 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/schemas/zod/builder.js +11 -0
- package/dist/services/build-search-document.js +5 -1
- package/dist/services/document-to-markdown.js +10 -0
- package/dist/services/field-upload.d.ts +7 -0
- package/dist/services/field-upload.js +15 -13
- package/dist/services/field-upload.test.node.js +1 -1
- package/dist/services/populate.d.ts +1 -1
- package/dist/services/populate.js +1 -1
- package/dist/storage/collection-fingerprint.js +9 -0
- package/dist/storage/field-store-map.js +1 -0
- package/dist/storage/field-store-map.test.node.js +1 -0
- package/dist/utils/slugify-filename.d.ts +50 -0
- package/dist/utils/slugify-filename.js +36 -0
- package/dist/utils/slugify-filename.test.node.d.ts +8 -0
- package/dist/utils/slugify-filename.test.node.js +50 -0
- package/package.json +2 -2
|
@@ -9,6 +9,7 @@ import { resolveUploadHooks } from '../@types/index.js';
|
|
|
9
9
|
import { assertActorCanPerform } from '../auth/assert-actor-can-perform.js';
|
|
10
10
|
import { ERR_DATABASE, ERR_STORAGE, ERR_VALIDATION } from '../lib/errors.js';
|
|
11
11
|
import { withLogContext } from '../lib/logger.js';
|
|
12
|
+
import { resolveUploadFilename } from '../utils/slugify-filename.js';
|
|
12
13
|
import { createDocument } from './document-lifecycle/index.js';
|
|
13
14
|
function isMimeTypeAllowed(mimeType, allowedTypes) {
|
|
14
15
|
return allowedTypes.some((allowed) => {
|
|
@@ -21,17 +22,6 @@ function isMimeTypeAllowed(mimeType, allowedTypes) {
|
|
|
21
22
|
return allowed === mimeType;
|
|
22
23
|
});
|
|
23
24
|
}
|
|
24
|
-
function sanitiseFilename(filename) {
|
|
25
|
-
const lastDot = filename.lastIndexOf('.');
|
|
26
|
-
const ext = lastDot !== -1 ? filename.slice(lastDot).toLowerCase() : '';
|
|
27
|
-
const base = lastDot !== -1 ? filename.slice(0, lastDot) : filename;
|
|
28
|
-
const safe = base
|
|
29
|
-
.toLowerCase()
|
|
30
|
-
.replace(/[^a-z0-9._-]/g, '-')
|
|
31
|
-
.replace(/-+/g, '-')
|
|
32
|
-
.replace(/^-|-$/g, '');
|
|
33
|
-
return `${safe || 'file'}${ext}`;
|
|
34
|
-
}
|
|
35
25
|
/**
|
|
36
26
|
* Walk a field set (recursing into `group` / `array` / `blocks`) and
|
|
37
27
|
* locate the `image | file` field with the given name. Returns the
|
|
@@ -73,7 +63,7 @@ function normalizeUploadHook(hook) {
|
|
|
73
63
|
*
|
|
74
64
|
* - return a string or `{ filename }` to substitute a new filename;
|
|
75
65
|
* - return `{ storagePath }` to take full control of the storage key
|
|
76
|
-
* (bypasses provider key derivation — no
|
|
66
|
+
* (bypasses provider key derivation — no entropy suffix). When set
|
|
77
67
|
* without `filename`, the filename defaults to the path's basename;
|
|
78
68
|
* - return `{ error }` to short-circuit with `ERR_VALIDATION`;
|
|
79
69
|
* - return `void` / `undefined` to leave current values unchanged.
|
|
@@ -182,7 +172,14 @@ export async function uploadField(ctx, params) {
|
|
|
182
172
|
// was supplied at the upload entry we hand them an empty one
|
|
183
173
|
// rather than throw — `assertActorCanPerform` is the auth gate
|
|
184
174
|
// for whether the upload should run at all, not the hook layer.
|
|
185
|
-
|
|
175
|
+
// The filename is slugified first (installation
|
|
176
|
+
// `uploads.filenameSlugifier`, else the default) so hooks observe
|
|
177
|
+
// the storage-ready base name.
|
|
178
|
+
const sanitised = resolveUploadFilename(originalFilename || 'upload', ctx.filenameSlugifier, {
|
|
179
|
+
collectionPath,
|
|
180
|
+
fieldName,
|
|
181
|
+
mimeType,
|
|
182
|
+
});
|
|
186
183
|
// Resolve the field's upload hooks once. The loader form
|
|
187
184
|
// (`hooks: () => import('./media.hooks.js')`) keeps server-only hook
|
|
188
185
|
// graphs out of the client bundle; the inline form returns as-is.
|
|
@@ -216,6 +213,11 @@ export async function uploadField(ctx, params) {
|
|
|
216
213
|
mimeType,
|
|
217
214
|
size: fileSize,
|
|
218
215
|
collection: collectionPath,
|
|
216
|
+
// Declarative storage-key scope (`upload.location`). Providers use
|
|
217
|
+
// it in place of the collection default while keeping their own
|
|
218
|
+
// entropy + sanitisation. An explicit hook `storagePath` still wins
|
|
219
|
+
// (targetStoragePath is written verbatim, below).
|
|
220
|
+
...(upload.location !== undefined && { location: upload.location }),
|
|
219
221
|
...(effectiveStoragePath !== undefined && { targetStoragePath: effectiveStoragePath }),
|
|
220
222
|
});
|
|
221
223
|
}
|
|
@@ -333,7 +333,7 @@ describe('uploadField service', () => {
|
|
|
333
333
|
fileSize: 3,
|
|
334
334
|
shouldCreateDocument: false,
|
|
335
335
|
});
|
|
336
|
-
// Explicit key is handed to the provider verbatim — no
|
|
336
|
+
// Explicit key is handed to the provider verbatim — no entropy suffix,
|
|
337
337
|
// no collection-derived key.
|
|
338
338
|
expect(upload).toHaveBeenCalledWith(expect.any(Buffer), expect.objectContaining({
|
|
339
339
|
targetStoragePath: 'publications/forru-0000447-0001-en.png',
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* documents. The guard is in place from day one so that the hook work
|
|
26
26
|
* in Phase 4+ cannot reintroduce the problem.
|
|
27
27
|
*
|
|
28
|
-
* See docs/04-collections/
|
|
28
|
+
* See docs/04-collections/03-relationships.md for the full design rationale.
|
|
29
29
|
*
|
|
30
30
|
* ---------------------------------------------------------------------
|
|
31
31
|
* DSL summary
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* documents. The guard is in place from day one so that the hook work
|
|
26
26
|
* in Phase 4+ cannot reintroduce the problem.
|
|
27
27
|
*
|
|
28
|
-
* See docs/04-collections/
|
|
28
|
+
* See docs/04-collections/03-relationships.md for the full design rationale.
|
|
29
29
|
*
|
|
30
30
|
* ---------------------------------------------------------------------
|
|
31
31
|
* DSL summary
|
|
@@ -62,6 +62,10 @@ function canonicalField(field) {
|
|
|
62
62
|
return base;
|
|
63
63
|
case 'text':
|
|
64
64
|
case 'textArea':
|
|
65
|
+
// `code` folds in `validation` only — `language` / `languageField` are
|
|
66
|
+
// presentation hints for the admin editor and never affect the stored
|
|
67
|
+
// value's shape, so (like labels) they stay out of the fingerprint.
|
|
68
|
+
case 'code':
|
|
65
69
|
case 'richText':
|
|
66
70
|
case 'float':
|
|
67
71
|
case 'integer':
|
|
@@ -86,6 +90,11 @@ function canonicalUpload(u) {
|
|
|
86
90
|
out.mimeTypes = [...u.mimeTypes].sort();
|
|
87
91
|
if (u.maxFileSize !== undefined)
|
|
88
92
|
out.maxFileSize = u.maxFileSize;
|
|
93
|
+
// Storage-key scope: where new uploads land is part of the field's
|
|
94
|
+
// storage contract (existing objects never move on change, but the
|
|
95
|
+
// declaration itself is schema-shape).
|
|
96
|
+
if (u.location !== undefined)
|
|
97
|
+
out.location = u.location;
|
|
89
98
|
if (u.sizes !== undefined) {
|
|
90
99
|
out.sizes = u.sizes
|
|
91
100
|
.map((s) => ({
|
|
@@ -28,6 +28,7 @@ export const fieldTypeToStore = {
|
|
|
28
28
|
// Text store
|
|
29
29
|
text: { storeType: 'text', valueColumn: 'value' },
|
|
30
30
|
textArea: { storeType: 'text', valueColumn: 'value' },
|
|
31
|
+
code: { storeType: 'text', valueColumn: 'value' },
|
|
31
32
|
select: { storeType: 'text', valueColumn: 'value' },
|
|
32
33
|
// Numeric store
|
|
33
34
|
integer: { storeType: 'numeric', valueColumn: 'value_integer' },
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Context passed to a `FilenameSlugifierFn`.
|
|
10
|
+
*
|
|
11
|
+
* `collectionPath` - The collection receiving the upload. Lets an
|
|
12
|
+
* installation-supplied slugifier branch by collection
|
|
13
|
+
* if filename policies differ.
|
|
14
|
+
* `fieldName` - The upload field's name within the collection.
|
|
15
|
+
* `mimeType` - The uploaded file's MIME type.
|
|
16
|
+
*/
|
|
17
|
+
export type FilenameSlugifyContext = {
|
|
18
|
+
collectionPath: string;
|
|
19
|
+
fieldName: string;
|
|
20
|
+
mimeType: string;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* A pure function that converts an uploaded file's **base name** (extension
|
|
24
|
+
* already split off) into a storage-safe slug. The extension is handled by
|
|
25
|
+
* the framework — lowercased and reattached after slugification — so
|
|
26
|
+
* implementations only see and return the base name.
|
|
27
|
+
*
|
|
28
|
+
* Registered installation-wide via `ServerConfig.uploads.filenameSlugifier`;
|
|
29
|
+
* the parallel of `ServerConfig.slugifier` for document paths. Must be
|
|
30
|
+
* synchronous and side-effect free. Return value is sanitised again by the
|
|
31
|
+
* storage provider as a safety net, but implementations should stay within
|
|
32
|
+
* `A–Z a–z 0–9 . _ -` to keep the slug authoritative.
|
|
33
|
+
*/
|
|
34
|
+
export type FilenameSlugifierFn = (basename: string, ctx: FilenameSlugifyContext) => string;
|
|
35
|
+
/**
|
|
36
|
+
* Default filename slugifier — lowercases, replaces unsafe characters with
|
|
37
|
+
* hyphens, collapses runs, trims leading/trailing hyphens, and falls back
|
|
38
|
+
* to `'file'` for empty results. Mirrors the storage providers' own
|
|
39
|
+
* sanitisation rules so the default round-trips them unchanged.
|
|
40
|
+
*/
|
|
41
|
+
export declare const slugifyFilename: FilenameSlugifierFn;
|
|
42
|
+
/**
|
|
43
|
+
* Resolve an uploaded file's stored filename: split the extension, apply the
|
|
44
|
+
* installation slugifier (or the default) to the base name, and reattach the
|
|
45
|
+
* lowercased extension. This runs in `uploadField` **before** the
|
|
46
|
+
* `beforeStore` hook chain, so hooks observe (and may override) the
|
|
47
|
+
* already-slugified name — the same ordering the previous fixed sanitiser
|
|
48
|
+
* had.
|
|
49
|
+
*/
|
|
50
|
+
export declare function resolveUploadFilename(originalFilename: string, slugifier: FilenameSlugifierFn | undefined, ctx: FilenameSlugifyContext): string;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Default filename slugifier — lowercases, replaces unsafe characters with
|
|
10
|
+
* hyphens, collapses runs, trims leading/trailing hyphens, and falls back
|
|
11
|
+
* to `'file'` for empty results. Mirrors the storage providers' own
|
|
12
|
+
* sanitisation rules so the default round-trips them unchanged.
|
|
13
|
+
*/
|
|
14
|
+
export const slugifyFilename = (basename) => {
|
|
15
|
+
const safe = basename
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/[^a-z0-9._-]/g, '-')
|
|
18
|
+
.replace(/-+/g, '-')
|
|
19
|
+
.replace(/^-|-$/g, '');
|
|
20
|
+
return safe || 'file';
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Resolve an uploaded file's stored filename: split the extension, apply the
|
|
24
|
+
* installation slugifier (or the default) to the base name, and reattach the
|
|
25
|
+
* lowercased extension. This runs in `uploadField` **before** the
|
|
26
|
+
* `beforeStore` hook chain, so hooks observe (and may override) the
|
|
27
|
+
* already-slugified name — the same ordering the previous fixed sanitiser
|
|
28
|
+
* had.
|
|
29
|
+
*/
|
|
30
|
+
export function resolveUploadFilename(originalFilename, slugifier, ctx) {
|
|
31
|
+
const lastDot = originalFilename.lastIndexOf('.');
|
|
32
|
+
const ext = lastDot > 0 ? originalFilename.slice(lastDot).toLowerCase() : '';
|
|
33
|
+
const base = lastDot > 0 ? originalFilename.slice(0, lastDot) : originalFilename;
|
|
34
|
+
const slug = (slugifier ?? slugifyFilename)(base, ctx);
|
|
35
|
+
return `${slug || 'file'}${ext}`;
|
|
36
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from 'vitest';
|
|
9
|
+
import { resolveUploadFilename, slugifyFilename } from './slugify-filename.js';
|
|
10
|
+
const ctx = { collectionPath: 'events', fieldName: 'attachment', mimeType: 'application/pdf' };
|
|
11
|
+
describe('slugifyFilename (default)', () => {
|
|
12
|
+
it('lowercases and hyphenates unsafe characters', () => {
|
|
13
|
+
expect(slugifyFilename('Meeting Agenda (Final)', ctx)).toBe('meeting-agenda-final');
|
|
14
|
+
});
|
|
15
|
+
it('collapses hyphen runs and trims leading/trailing hyphens', () => {
|
|
16
|
+
expect(slugifyFilename('--Board -- Notes--', ctx)).toBe('board-notes');
|
|
17
|
+
});
|
|
18
|
+
it('falls back to "file" for empty results', () => {
|
|
19
|
+
expect(slugifyFilename('***', ctx)).toBe('file');
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
describe('resolveUploadFilename', () => {
|
|
23
|
+
it('slugifies the base name and lowercases the extension', () => {
|
|
24
|
+
expect(resolveUploadFilename('Meeting Agenda.PDF', undefined, ctx)).toBe('meeting-agenda.pdf');
|
|
25
|
+
});
|
|
26
|
+
it('handles filenames without an extension', () => {
|
|
27
|
+
expect(resolveUploadFilename('README', undefined, ctx)).toBe('readme');
|
|
28
|
+
});
|
|
29
|
+
it('treats a leading dot as part of the base name, not an extension', () => {
|
|
30
|
+
expect(resolveUploadFilename('.gitignore', undefined, ctx)).toBe('.gitignore');
|
|
31
|
+
});
|
|
32
|
+
it('only splits at the last dot', () => {
|
|
33
|
+
expect(resolveUploadFilename('archive.tar.gz', undefined, ctx)).toBe('archive.tar.gz');
|
|
34
|
+
});
|
|
35
|
+
it('applies a custom slugifier to the base name only', () => {
|
|
36
|
+
const shouty = (base) => base.toUpperCase().replace(/[^A-Z0-9]/g, '_');
|
|
37
|
+
expect(resolveUploadFilename('Meeting Agenda.pdf', shouty, ctx)).toBe('MEETING_AGENDA.pdf');
|
|
38
|
+
});
|
|
39
|
+
it('passes the slugify context through to the custom slugifier', () => {
|
|
40
|
+
const seen = [];
|
|
41
|
+
resolveUploadFilename('photo.jpg', (base, c) => {
|
|
42
|
+
seen.push(c);
|
|
43
|
+
return base;
|
|
44
|
+
}, ctx);
|
|
45
|
+
expect(seen).toEqual([ctx]);
|
|
46
|
+
});
|
|
47
|
+
it('guards against a custom slugifier returning an empty string', () => {
|
|
48
|
+
expect(resolveUploadFilename('photo.jpg', () => '', ctx)).toBe('file.jpg');
|
|
49
|
+
});
|
|
50
|
+
});
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/core",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "4.
|
|
5
|
+
"version": "4.2.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"pino": "^10.3.1",
|
|
82
82
|
"sharp": "^0.35.3",
|
|
83
83
|
"zod": "^4.4.3",
|
|
84
|
-
"@byline/auth": "4.
|
|
84
|
+
"@byline/auth": "4.2.0"
|
|
85
85
|
},
|
|
86
86
|
"devDependencies": {
|
|
87
87
|
"@biomejs/biome": "2.5.2",
|