@bunbraco/core 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +28 -4
- package/src/api-paths.ts +22 -0
- package/src/content-types.ts +157 -0
- package/src/documents.ts +124 -0
- package/src/index.ts +13 -0
- package/src/notifications.ts +28 -0
- package/src/object-types.ts +122 -0
- package/src/paging.ts +27 -0
- package/src/permissions.ts +165 -0
- package/src/problem-details.ts +57 -0
- package/src/property-values.ts +129 -0
- package/src/return-urls.ts +42 -0
- package/src/sections.ts +50 -0
- package/src/uploads.ts +41 -0
- package/src/users.ts +51 -0
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kineco
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
bunbraco
|
|
2
|
+
Copyright (c) 2026 Kineco
|
|
3
|
+
|
|
4
|
+
This product includes third-party software. bunbraco is an independent project.
|
|
5
|
+
It is not affiliated with, endorsed by, or sponsored by Umbraco A/S, which owns
|
|
6
|
+
the Umbraco trademark.
|
|
7
|
+
|
|
8
|
+
There are three places third-party material is redistributed, rather than merely
|
|
9
|
+
depended on:
|
|
10
|
+
|
|
11
|
+
@bunbraco/backoffice-dist the built Umbraco backoffice and the browser
|
|
12
|
+
packages bundled into it, produced by
|
|
13
|
+
`bun run vendor:backoffice`
|
|
14
|
+
@bunbraco/backoffice-dist three CSS files copied unmodified from the Umbraco
|
|
15
|
+
client source tree
|
|
16
|
+
@bunbraco/contracts the Umbraco Management API OpenAPI document,
|
|
17
|
+
copied unmodified
|
|
18
|
+
|
|
19
|
+
No Umbraco trademark is redistributed. The Umbraco wordmark, the "U" roundel
|
|
20
|
+
favicon and the installer illustration are deliberately not carried in this
|
|
21
|
+
repository: MIT grants copyright permission and says nothing about trademarks, so
|
|
22
|
+
bunbraco's own marks are served at those paths instead. See `BRANDED_ASSETS` in
|
|
23
|
+
`@bunbraco/backoffice-host`.
|
|
24
|
+
|
|
25
|
+
The Umbraco backoffice
|
|
26
|
+
----------------------
|
|
27
|
+
@umbraco-cms/backoffice 18.2.0 — MIT — https://github.com/umbraco/Umbraco-CMS
|
|
28
|
+
@umbraco-ui/uui 2.0.2 — MIT — https://github.com/umbraco/Umbraco.UI
|
|
29
|
+
|
|
30
|
+
Static files from the Umbraco client source tree
|
|
31
|
+
------------------------------------------------
|
|
32
|
+
css/umb-css.css, css/rte-content.css, css/umbraco-blockgridlayout.css
|
|
33
|
+
— copied unmodified from Umbraco-CMS release-18.2.0 — MIT
|
|
34
|
+
— https://github.com/umbraco/Umbraco-CMS
|
|
35
|
+
Shipped in @bunbraco/backoffice-dist under `upstream-static/`.
|
|
36
|
+
|
|
37
|
+
`rte-content.css` itself carries Tiptap's default editor styles, copied from
|
|
38
|
+
tiptap v2.11.7 (MIT); the file records this in its own header.
|
|
39
|
+
|
|
40
|
+
The Umbraco Management API contract
|
|
41
|
+
-----------------------------------
|
|
42
|
+
OpenApi.json — copied unmodified from Umbraco-CMS release-18.2.0
|
|
43
|
+
(src/Umbraco.Cms.Api.Management/OpenApi.json) — MIT
|
|
44
|
+
— https://github.com/umbraco/Umbraco-CMS
|
|
45
|
+
Shipped in @bunbraco/contracts, which also ships the TypeScript types generated
|
|
46
|
+
from it.
|
|
47
|
+
|
|
48
|
+
Browser runtime dependencies bundled into the backoffice build
|
|
49
|
+
--------------------------------------------------------------
|
|
50
|
+
@heximal/expressions 0.1.5 — BSD-3-Clause
|
|
51
|
+
@microsoft/signalr 10.0.11 — MIT
|
|
52
|
+
@tiptap/* 3.31.3 — MIT
|
|
53
|
+
diff 9.0.0 — BSD-3-Clause
|
|
54
|
+
dompurify 3.4.16 — Apache-2.0, elected from "MPL-2.0 OR Apache-2.0"
|
|
55
|
+
element-internals-polyfill 3.0.2 — MIT
|
|
56
|
+
lit 3.3.3 — BSD-3-Clause
|
|
57
|
+
luxon 3.7.2 — MIT
|
|
58
|
+
marked 18.0.14 — MIT
|
|
59
|
+
monaco-editor 0.57.0 — MIT
|
|
60
|
+
rxjs 7.8.2 — Apache-2.0
|
|
61
|
+
uuid 14.0.2 — MIT
|
|
62
|
+
|
|
63
|
+
dompurify is offered under either licence; Apache-2.0 is elected here, so no
|
|
64
|
+
MPL-2.0 obligation attaches to the copy bundled into the backoffice build. Of the
|
|
65
|
+
Apache-2.0 packages above, none ships a NOTICE file of its own, so Apache-2.0
|
|
66
|
+
section 4(d) adds nothing to this file.
|
|
67
|
+
|
|
68
|
+
Each package's own licence text ships inside its npm distribution and applies
|
|
69
|
+
unchanged to the copies bundled here.
|
package/package.json
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bunbraco/core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Domain model, entity keys and shared contracts for bunbraco. No I/O.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Kineco",
|
|
7
|
+
"homepage": "https://github.com/kineco-au/bunbraco-cms#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/kineco-au/bunbraco-cms.git",
|
|
11
|
+
"directory": "packages/core"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/kineco-au/bunbraco-cms/issues"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"bun": ">=1.3.0"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./src/index.ts"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"NOTICE"
|
|
26
|
+
],
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/api-paths.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The paths the vendored client hard-codes.
|
|
3
|
+
*
|
|
4
|
+
* Umbraco's `UmbracoPath` moves the editor, not its API: the Management API is
|
|
5
|
+
* declared by the contract and the client's generated SDK calls it at
|
|
6
|
+
* `/umbraco/management/api/v1/...` however the backoffice is mounted. The two
|
|
7
|
+
* SignalR hubs are built the same way in the client's own source. So these stay
|
|
8
|
+
* put while `backOfficePath` moves the shell, the login page, the static assets
|
|
9
|
+
* and the SPA's own routes.
|
|
10
|
+
*/
|
|
11
|
+
export const MANAGEMENT_API_PREFIX = '/umbraco/management/api/'
|
|
12
|
+
|
|
13
|
+
export const MANAGEMENT_API_PATH = `${MANAGEMENT_API_PREFIX}v1`
|
|
14
|
+
|
|
15
|
+
/** `server-event.context.js`: `${serverURL}/umbraco/serverEventHub`. */
|
|
16
|
+
export const SERVER_EVENT_HUB_PATH = '/umbraco/serverEventHub'
|
|
17
|
+
|
|
18
|
+
/** `preview.context.js`: `${serverUrl}/umbraco/PreviewHub`. */
|
|
19
|
+
export const PREVIEW_HUB_PATH = '/umbraco/PreviewHub'
|
|
20
|
+
|
|
21
|
+
/** Where the editor is mounted unless a site overrides `backOfficePath`. */
|
|
22
|
+
export const DEFAULT_BACKOFFICE_PATH = '/bunbraco'
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The content-type aggregate: a content type plus its property groups, property
|
|
3
|
+
* types, compositions, allowed children and templates.
|
|
4
|
+
*
|
|
5
|
+
* Kept as one aggregate because the editor saves it as one document, and because
|
|
6
|
+
* the property set is only meaningful together with the compositions it inherits.
|
|
7
|
+
*/
|
|
8
|
+
export interface PropertyTypeModel {
|
|
9
|
+
key: string
|
|
10
|
+
alias: string
|
|
11
|
+
name: string
|
|
12
|
+
description: string | null
|
|
13
|
+
dataTypeKey: string
|
|
14
|
+
/** The group this property renders in, or null for the generic tab. */
|
|
15
|
+
containerKey: string | null
|
|
16
|
+
sortOrder: number
|
|
17
|
+
variesByCulture: boolean
|
|
18
|
+
variesBySegment: boolean
|
|
19
|
+
mandatory: boolean
|
|
20
|
+
mandatoryMessage: string | null
|
|
21
|
+
regEx: string | null
|
|
22
|
+
regExMessage: string | null
|
|
23
|
+
labelOnTop: boolean
|
|
24
|
+
/** The file's `since`, when written. */
|
|
25
|
+
sinceVersion?: string | null
|
|
26
|
+
/** Set when the element is not yet live on the reading node: the state it goes live in. */
|
|
27
|
+
pending?: { version: string; revision: string } | null
|
|
28
|
+
/** Member types only: shown to the member on their profile. */
|
|
29
|
+
memberCanView?: boolean
|
|
30
|
+
/** Member types only: editable by the member on their profile. */
|
|
31
|
+
memberCanEdit?: boolean
|
|
32
|
+
/** Member types only: hidden from users without sensitive-data access. */
|
|
33
|
+
isSensitive?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PropertyGroupModel {
|
|
37
|
+
key: string
|
|
38
|
+
name: string | null
|
|
39
|
+
alias: string | null
|
|
40
|
+
/** 'Tab' or 'Group'; the wire format is a string. */
|
|
41
|
+
type: string
|
|
42
|
+
sortOrder: number
|
|
43
|
+
parentKey: string | null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type CompositionType = 'Composition' | 'Inheritance'
|
|
47
|
+
|
|
48
|
+
export interface CompositionModel {
|
|
49
|
+
contentTypeKey: string
|
|
50
|
+
compositionType: CompositionType
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface AllowedChildModel {
|
|
54
|
+
contentTypeKey: string
|
|
55
|
+
sortOrder: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface CleanupModel {
|
|
59
|
+
preventCleanup: boolean
|
|
60
|
+
keepAllVersionsNewerThanDays: number | null
|
|
61
|
+
keepLatestVersionPerDayForDays: number | null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ContentTypeAggregate {
|
|
65
|
+
key: string
|
|
66
|
+
alias: string
|
|
67
|
+
name: string
|
|
68
|
+
description: string | null
|
|
69
|
+
icon: string
|
|
70
|
+
allowedAsRoot: boolean
|
|
71
|
+
variesByCulture: boolean
|
|
72
|
+
variesBySegment: boolean
|
|
73
|
+
isElement: boolean
|
|
74
|
+
allowedInLibrary: boolean
|
|
75
|
+
collectionKey: string | null
|
|
76
|
+
cleanup: CleanupModel
|
|
77
|
+
properties: PropertyTypeModel[]
|
|
78
|
+
containers: PropertyGroupModel[]
|
|
79
|
+
compositions: CompositionModel[]
|
|
80
|
+
allowedContentTypes: AllowedChildModel[]
|
|
81
|
+
allowedTemplateKeys: string[]
|
|
82
|
+
defaultTemplateKey: string | null
|
|
83
|
+
/** Folder placement in the tree, which is not the composition graph. */
|
|
84
|
+
parentKey: string | null
|
|
85
|
+
sinceVersion?: string | null
|
|
86
|
+
pending?: { version: string; revision: string } | null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Umbraco's system media types (`IsSystemMediaType`): always present, not
|
|
91
|
+
* deletable, alias fixed. Keys from its installer.
|
|
92
|
+
*/
|
|
93
|
+
export const SYSTEM_MEDIA_TYPE_KEYS: Readonly<Record<'Folder' | 'Image' | 'File', string>> = {
|
|
94
|
+
Folder: 'f38bd2d7-65d0-48e6-95dc-87ce06ec2d3d',
|
|
95
|
+
Image: 'cc07b313-0843-4aa8-bbda-871c8da728c8',
|
|
96
|
+
File: '4c52d8ab-54e6-40cd-999c-7a5f24903e4d',
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function isSystemMediaType(key: string): boolean {
|
|
100
|
+
return Object.values(SYSTEM_MEDIA_TYPE_KEYS).includes(key.toLowerCase())
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Umbraco's one built-in member type, "Member", by the key its installer uses:
|
|
105
|
+
* always present, so a fresh install can create a member without a schema file.
|
|
106
|
+
*/
|
|
107
|
+
export const SYSTEM_MEMBER_TYPE_KEY = 'd59be02f-1df9-4228-aa1e-01917d806cda'
|
|
108
|
+
|
|
109
|
+
export function isSystemMemberType(key: string): boolean {
|
|
110
|
+
return key.toLowerCase() === SYSTEM_MEMBER_TYPE_KEY
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** How the editor labels a pending element. */
|
|
114
|
+
export function pendingLabel(until: { version: string }): string {
|
|
115
|
+
return `goes live in ${until.version}`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface DataTypeModel {
|
|
119
|
+
key: string
|
|
120
|
+
/** Schema files reference a data type by this; Umbraco itself has only key and name. */
|
|
121
|
+
alias: string | null
|
|
122
|
+
name: string
|
|
123
|
+
editorAlias: string
|
|
124
|
+
editorUiAlias: string | null
|
|
125
|
+
/** One of ValueStorageType. */
|
|
126
|
+
dbType: string
|
|
127
|
+
values: Array<{ alias: string; value: unknown }>
|
|
128
|
+
parentKey: string | null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface TemplateModel {
|
|
132
|
+
key: string
|
|
133
|
+
name: string
|
|
134
|
+
alias: string
|
|
135
|
+
content: string | null
|
|
136
|
+
/** Derived from `export const layout = '…'` in the view; the tree hangs children under it. */
|
|
137
|
+
masterKey?: string | null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** One entry in a backoffice tree. */
|
|
141
|
+
export interface TreeItem {
|
|
142
|
+
key: string
|
|
143
|
+
name: string
|
|
144
|
+
hasChildren: boolean
|
|
145
|
+
parentKey: string | null
|
|
146
|
+
icon: string | null
|
|
147
|
+
isFolder: boolean
|
|
148
|
+
isElement?: boolean
|
|
149
|
+
editorUiAlias?: string | null
|
|
150
|
+
/** The document type of a blueprint in the blueprint tree. */
|
|
151
|
+
contentTypeKey?: string | null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface Page<T> {
|
|
155
|
+
total: number
|
|
156
|
+
items: T[]
|
|
157
|
+
}
|
package/src/documents.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The document aggregate.
|
|
3
|
+
*
|
|
4
|
+
* Values are a flat list keyed by (alias, culture, segment), matching the wire
|
|
5
|
+
* format, because that is also how they are stored: one `property_data` row per
|
|
6
|
+
* combination.
|
|
7
|
+
*/
|
|
8
|
+
export interface DocumentValue {
|
|
9
|
+
alias: string
|
|
10
|
+
culture: string | null
|
|
11
|
+
segment: string | null
|
|
12
|
+
value: unknown
|
|
13
|
+
/** Present on responses so the editor knows which editor rendered the value. */
|
|
14
|
+
editorAlias?: string
|
|
15
|
+
/** The data type's configuration, on reads; converters that depend on it use it. */
|
|
16
|
+
config?: Record<string, unknown>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Per-culture state. `NotCreated` means the culture has no name yet;
|
|
21
|
+
* `PublishedPendingChanges` means the draft differs from what is published.
|
|
22
|
+
*/
|
|
23
|
+
export type VariantState =
|
|
24
|
+
| 'NotCreated'
|
|
25
|
+
| 'Draft'
|
|
26
|
+
| 'Published'
|
|
27
|
+
| 'PublishedPendingChanges'
|
|
28
|
+
| 'Trashed'
|
|
29
|
+
|
|
30
|
+
export interface DocumentVariant {
|
|
31
|
+
culture: string | null
|
|
32
|
+
segment: string | null
|
|
33
|
+
name: string
|
|
34
|
+
state: VariantState
|
|
35
|
+
createDate: Date
|
|
36
|
+
updateDate: Date
|
|
37
|
+
publishDate: Date | null
|
|
38
|
+
/** When a scheduled publish of this variant will run. */
|
|
39
|
+
scheduledPublishDate?: Date | null
|
|
40
|
+
scheduledUnpublishDate?: Date | null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface DocumentAggregate {
|
|
44
|
+
key: string
|
|
45
|
+
contentTypeKey: string
|
|
46
|
+
contentTypeAlias: string
|
|
47
|
+
contentTypeIcon: string
|
|
48
|
+
/** The list view the type shows its children in, if any. */
|
|
49
|
+
contentTypeCollectionKey?: string | null
|
|
50
|
+
templateKey: string | null
|
|
51
|
+
parentKey: string | null
|
|
52
|
+
sortOrder: number
|
|
53
|
+
isTrashed: boolean
|
|
54
|
+
/** True when any culture is published. */
|
|
55
|
+
published: boolean
|
|
56
|
+
/** True when the draft differs from the published version. */
|
|
57
|
+
edited: boolean
|
|
58
|
+
values: DocumentValue[]
|
|
59
|
+
variants: DocumentVariant[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** One property that failed validation, in the terms the editor highlights. */
|
|
63
|
+
export interface DocumentValidationError {
|
|
64
|
+
alias: string
|
|
65
|
+
culture: string | null
|
|
66
|
+
segment: string | null
|
|
67
|
+
messages: string[]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What the content tree shows for a document, beyond the generic tree item. */
|
|
71
|
+
export interface DocumentTreeItem {
|
|
72
|
+
key: string
|
|
73
|
+
name: string
|
|
74
|
+
hasChildren: boolean
|
|
75
|
+
parentKey: string | null
|
|
76
|
+
contentTypeKey: string
|
|
77
|
+
contentTypeCollectionKey?: string | null
|
|
78
|
+
icon: string
|
|
79
|
+
isTrashed: boolean
|
|
80
|
+
createDate: Date
|
|
81
|
+
ancestorKeys: string[]
|
|
82
|
+
variants: Array<{ culture: string | null; name: string; state: VariantState }>
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A row in the Library tree, which mixes folders and elements: a folder carries
|
|
87
|
+
* the name and nothing else, so `documentType` is reported as null and its single
|
|
88
|
+
* variant as `NotCreated` — the contract's own way of saying there is no content
|
|
89
|
+
* here.
|
|
90
|
+
*/
|
|
91
|
+
export interface ElementTreeItem extends DocumentTreeItem {
|
|
92
|
+
isFolder: boolean
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface DocumentVersionSummary {
|
|
96
|
+
id: string
|
|
97
|
+
documentKey: string
|
|
98
|
+
versionDate: Date
|
|
99
|
+
/** The draft that is currently being edited. */
|
|
100
|
+
isCurrentDraft: boolean
|
|
101
|
+
isCurrentPublished: boolean
|
|
102
|
+
preventCleanup: boolean
|
|
103
|
+
userKey: string | null
|
|
104
|
+
culture: string | null
|
|
105
|
+
/** What made this version: a save, publish, rollback or schema migration. */
|
|
106
|
+
kind?: 'save' | 'publish' | 'rollback' | 'migrate'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A value keyed for lookup, so variance handling stays explicit. */
|
|
110
|
+
export function valueKey(alias: string, culture: string | null, segment: string | null): string {
|
|
111
|
+
return `${alias}|${culture ?? ''}|${segment ?? ''}`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The culture a value belongs to, honouring the effective variance: a value sent
|
|
116
|
+
* with a culture for an invariant property is stored invariant, which is what
|
|
117
|
+
* keeps a type change from orphaning data.
|
|
118
|
+
*/
|
|
119
|
+
export function normaliseValueCulture(
|
|
120
|
+
culture: string | null,
|
|
121
|
+
propertyVariesByCulture: boolean,
|
|
122
|
+
): string | null {
|
|
123
|
+
return propertyVariesByCulture ? culture : null
|
|
124
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from './api-paths.ts'
|
|
2
|
+
export * from './content-types.ts'
|
|
3
|
+
export * from './documents.ts'
|
|
4
|
+
export * from './notifications.ts'
|
|
5
|
+
export * from './object-types.ts'
|
|
6
|
+
export * from './paging.ts'
|
|
7
|
+
export * from './permissions.ts'
|
|
8
|
+
export * from './problem-details.ts'
|
|
9
|
+
export * from './property-values.ts'
|
|
10
|
+
export * from './return-urls.ts'
|
|
11
|
+
export * from './sections.ts'
|
|
12
|
+
export * from './uploads.ts'
|
|
13
|
+
export * from './users.ts'
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Umb-Notifications` response header. Note the name: it is NOT
|
|
3
|
+
* `Umbraco-Notifications`. The backoffice reads it, renders toasts, and strips
|
|
4
|
+
* it. Umbraco never sets it on GET.
|
|
5
|
+
*/
|
|
6
|
+
export const NOTIFICATIONS_HEADER = 'Umb-Notifications'
|
|
7
|
+
|
|
8
|
+
export type EventMessageType = 'Default' | 'Info' | 'Error' | 'Success' | 'Warning'
|
|
9
|
+
|
|
10
|
+
export interface EventMessage {
|
|
11
|
+
message: string
|
|
12
|
+
category: string
|
|
13
|
+
type: EventMessageType
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function notificationsHeaderValue(messages: readonly EventMessage[]): string {
|
|
17
|
+
return JSON.stringify(messages)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function applyNotifications(
|
|
21
|
+
headers: Headers,
|
|
22
|
+
method: string,
|
|
23
|
+
messages: readonly EventMessage[],
|
|
24
|
+
): void {
|
|
25
|
+
if (method.toUpperCase() === 'GET') return
|
|
26
|
+
if (messages.length === 0) return
|
|
27
|
+
headers.set(NOTIFICATIONS_HEADER, notificationsHeaderValue(messages))
|
|
28
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node object types.
|
|
3
|
+
*
|
|
4
|
+
* `node` is a universal polymorphic tree — documents, media, members, all content
|
|
5
|
+
* types, data types, templates and folders are rows in it — and this GUID is the
|
|
6
|
+
* discriminator. The values are Umbraco's, so SQL and documentation transfer
|
|
7
|
+
* even though the table names do not.
|
|
8
|
+
*
|
|
9
|
+
* Written lowercase deliberately: Postgres' `uuid` type normalises to lowercase
|
|
10
|
+
* on read while SQLite stores text verbatim, so everything that compares a uuid
|
|
11
|
+
* must agree on one case. `normaliseUuid` enforces it on the way in.
|
|
12
|
+
*/
|
|
13
|
+
export const ObjectTypes = {
|
|
14
|
+
SystemRoot: 'ea7d8624-4cfe-4578-a871-24aa946bf34d',
|
|
15
|
+
Document: 'c66ba18e-eaf3-4cff-8a22-41b16d66a972',
|
|
16
|
+
DocumentType: 'a2cb7800-f571-4787-9638-bc48539a0efb',
|
|
17
|
+
DocumentTypeContainer: '2f7a2769-6b0b-4468-90dd-af42d64f7f16',
|
|
18
|
+
DocumentBlueprint: '6ebef410-03aa-48cf-a792-e1c1cb087aca',
|
|
19
|
+
DocumentBlueprintContainer: 'a7eff71b-fa69-4552-93fc-038f7deee453',
|
|
20
|
+
ContentRecycleBin: '01bb7ff2-24dc-4c0c-95a2-c24ef72bbac8',
|
|
21
|
+
DataType: '30a2a501-1978-4ddb-a57b-f7efed43ba3c',
|
|
22
|
+
DataTypeContainer: '521231e3-8b37-469c-9f9d-51afc91feb7b',
|
|
23
|
+
Template: '6fbde604-4178-42ce-a10b-8a2600a2f07d',
|
|
24
|
+
Media: 'b796f64c-1f99-4ffb-b886-4bf4bc011a9c',
|
|
25
|
+
MediaType: '4ea4382b-2f5a-4c2b-9587-ae9b3cf3602e',
|
|
26
|
+
MediaTypeContainer: '42aef799-b288-4744-9b10-be144b73cdc4',
|
|
27
|
+
MediaRecycleBin: 'cf3d8e34-1c1c-41e9-ae56-878b57b32113',
|
|
28
|
+
ElementContainer: '2815b0cf-9706-499f-aa2a-8a4c7aef005d',
|
|
29
|
+
ElementRecycleBin: 'a1ee71eb-659c-4eee-bc97-6243e721cc0d',
|
|
30
|
+
Member: '39eb0f98-b348-42a1-8662-e7eb18487560',
|
|
31
|
+
MemberType: '9b5416fb-e72f-45a9-a07b-5a9a2709ce43',
|
|
32
|
+
MemberTypeContainer: '59ef5767-7223-4abc-b229-72821dc711b9',
|
|
33
|
+
MemberGroup: '366e63b9-880f-4e13-a61c-98069b029728',
|
|
34
|
+
Element: '3d7b623c-94b1-487d-8554-a46ec37568be',
|
|
35
|
+
Language: '6b05d05b-ec78-49be-a4e4-79e274f07a77',
|
|
36
|
+
RelationType: 'b1988fad-8675-4f47-915a-b3a602bc5d8d',
|
|
37
|
+
LockObject: '87a9f1ff-b1e4-4a25-babb-465a4a47ec41',
|
|
38
|
+
} as const
|
|
39
|
+
|
|
40
|
+
export type ObjectType = (typeof ObjectTypes)[keyof typeof ObjectTypes]
|
|
41
|
+
|
|
42
|
+
/** The canonical form for a uuid anywhere in the system. */
|
|
43
|
+
export function normaliseUuid(value: string): string {
|
|
44
|
+
return value.toLowerCase()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function uuidEquals(a: string | null | undefined, b: string | null | undefined): boolean {
|
|
48
|
+
if (!a || !b) return false
|
|
49
|
+
return a.toLowerCase() === b.toLowerCase()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `umb://document/2ef0…` or a bare uuid, as a dashed lowercase key.
|
|
54
|
+
*
|
|
55
|
+
* The two spellings a stored value uses for a reference. It lives here rather
|
|
56
|
+
* than beside the value converters that first needed it because exporting a
|
|
57
|
+
* bundle reads the same references out of the same values, and `@bunbraco/transfer`
|
|
58
|
+
* must not depend on the renderer to do it.
|
|
59
|
+
*/
|
|
60
|
+
export function keyFromReference(reference: unknown): string | undefined {
|
|
61
|
+
if (typeof reference !== 'string') return undefined
|
|
62
|
+
const hex = reference
|
|
63
|
+
.replace(/^umb:\/\/[a-z-]+\//i, '')
|
|
64
|
+
.replaceAll('-', '')
|
|
65
|
+
.toLowerCase()
|
|
66
|
+
if (!/^[0-9a-f]{32}$/.test(hex)) return undefined
|
|
67
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Fixed node ids, as Umbraco seeds them. */
|
|
71
|
+
export const SystemNodes = {
|
|
72
|
+
Root: -1,
|
|
73
|
+
ContentRecycleBin: -20,
|
|
74
|
+
MediaRecycleBin: -21,
|
|
75
|
+
ElementRecycleBin: -22,
|
|
76
|
+
} as const
|
|
77
|
+
|
|
78
|
+
/** `ContentVariation` is a flags byte on both content types and property types. */
|
|
79
|
+
export const ContentVariation = {
|
|
80
|
+
Nothing: 0,
|
|
81
|
+
Culture: 1,
|
|
82
|
+
Segment: 2,
|
|
83
|
+
CultureAndSegment: 3,
|
|
84
|
+
} as const
|
|
85
|
+
|
|
86
|
+
export function variationFlags(variesByCulture: boolean, variesBySegment: boolean): number {
|
|
87
|
+
return (variesByCulture ? 1 : 0) | (variesBySegment ? 2 : 0)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const variesByCulture = (flags: number): boolean => (flags & 1) !== 0
|
|
91
|
+
export const variesBySegment = (flags: number): boolean => (flags & 2) !== 0
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Effective variance is the intersection of the content type's and the property
|
|
95
|
+
* type's: a property varies by culture only if its type does too.
|
|
96
|
+
*/
|
|
97
|
+
export function effectiveVariation(contentTypeFlags: number, propertyTypeFlags: number): number {
|
|
98
|
+
return contentTypeFlags & propertyTypeFlags
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `PropertyGroupType`: a tab is rendered as a top-level tab, a group as a fieldset. */
|
|
102
|
+
export const PropertyGroupType = { Group: 0, Tab: 1 } as const
|
|
103
|
+
|
|
104
|
+
/** How values are stored in `property_data`, chosen by the data type. */
|
|
105
|
+
export const ValueStorageType = {
|
|
106
|
+
Ntext: 'Ntext',
|
|
107
|
+
Nvarchar: 'Nvarchar',
|
|
108
|
+
Integer: 'Integer',
|
|
109
|
+
Date: 'Date',
|
|
110
|
+
Decimal: 'Decimal',
|
|
111
|
+
} as const
|
|
112
|
+
|
|
113
|
+
export type ValueStorageTypeName = (typeof ValueStorageType)[keyof typeof ValueStorageType]
|
|
114
|
+
|
|
115
|
+
/** The `property_data` column a storage type writes to. */
|
|
116
|
+
export const STORAGE_COLUMN: Record<ValueStorageTypeName, string> = {
|
|
117
|
+
Ntext: 'text_value',
|
|
118
|
+
Nvarchar: 'varchar_value',
|
|
119
|
+
Integer: 'int_value',
|
|
120
|
+
Date: 'date_value',
|
|
121
|
+
Decimal: 'decimal_value',
|
|
122
|
+
}
|
package/src/paging.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Umbraco's universal paging envelope. `total` is int64 in the contract. */
|
|
2
|
+
export interface PagedViewModel<T> {
|
|
3
|
+
total: number
|
|
4
|
+
items: T[]
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_TAKE = 100
|
|
8
|
+
|
|
9
|
+
export interface SkipTake {
|
|
10
|
+
skip: number
|
|
11
|
+
take: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type SkipTakeResult = { ok: true; value: SkipTake } | { ok: false }
|
|
15
|
+
|
|
16
|
+
export function parseSkipTake(params: URLSearchParams, defaultTake = DEFAULT_TAKE): SkipTakeResult {
|
|
17
|
+
const skip = Number(params.get('skip') ?? 0)
|
|
18
|
+
const take = Number(params.get('take') ?? defaultTake)
|
|
19
|
+
if (!Number.isInteger(skip) || !Number.isInteger(take) || skip < 0 || take < 0)
|
|
20
|
+
return { ok: false }
|
|
21
|
+
if (take > 0 && skip % take !== 0) return { ok: false }
|
|
22
|
+
return { ok: true, value: { skip, take } }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function paged<T>(items: T[], total: number): PagedViewModel<T> {
|
|
26
|
+
return { total, items }
|
|
27
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission verbs.
|
|
3
|
+
*
|
|
4
|
+
* Umbraco 18 replaced the old single-letter action codes with verbs of the form
|
|
5
|
+
* `Umb.<Entity>.<Action>`, stored one per row. Granular rows additionally carry a
|
|
6
|
+
* node key and a context discriminator, so one table serves both per-document
|
|
7
|
+
* ACLs and per-property field-level security.
|
|
8
|
+
*/
|
|
9
|
+
export const DocumentPermissions = {
|
|
10
|
+
Read: 'Umb.Document.Read',
|
|
11
|
+
Create: 'Umb.Document.Create',
|
|
12
|
+
Update: 'Umb.Document.Update',
|
|
13
|
+
Delete: 'Umb.Document.Delete',
|
|
14
|
+
Publish: 'Umb.Document.Publish',
|
|
15
|
+
Unpublish: 'Umb.Document.Unpublish',
|
|
16
|
+
Move: 'Umb.Document.Move',
|
|
17
|
+
Duplicate: 'Umb.Document.Duplicate',
|
|
18
|
+
Sort: 'Umb.Document.Sort',
|
|
19
|
+
Rollback: 'Umb.Document.Rollback',
|
|
20
|
+
Notifications: 'Umb.Document.Notifications',
|
|
21
|
+
Permissions: 'Umb.Document.Permissions',
|
|
22
|
+
CultureAndHostnames: 'Umb.Document.CultureAndHostnames',
|
|
23
|
+
PublicAccess: 'Umb.Document.PublicAccess',
|
|
24
|
+
CreateBlueprint: 'Umb.Document.CreateBlueprint',
|
|
25
|
+
PropertyValueRead: 'Umb.Document.PropertyValue.Read',
|
|
26
|
+
PropertyValueWrite: 'Umb.Document.PropertyValue.Write',
|
|
27
|
+
RecycleBinRestore: 'Umb.DocumentRecycleBin.Restore',
|
|
28
|
+
} as const
|
|
29
|
+
|
|
30
|
+
export type DocumentVerb = (typeof DocumentPermissions)[keyof typeof DocumentPermissions]
|
|
31
|
+
|
|
32
|
+
/** The discriminator on a granular permission row. */
|
|
33
|
+
export type PermissionContext = 'Document' | 'Element' | 'DocumentTypeProperty'
|
|
34
|
+
|
|
35
|
+
export const ADMIN_GROUP_ALIAS = 'admin'
|
|
36
|
+
|
|
37
|
+
/** The resolved permission set for a user, assembled from all their groups. */
|
|
38
|
+
export interface UserPermissions {
|
|
39
|
+
isAdmin: boolean
|
|
40
|
+
/** Verbs granted regardless of node. */
|
|
41
|
+
global: ReadonlySet<string>
|
|
42
|
+
/** Verbs granted only on specific nodes, keyed by node key. */
|
|
43
|
+
granular: ReadonlyMap<string, ReadonlySet<string>>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Where a user may work in one tree: everywhere, or at and below the given
|
|
48
|
+
* nodes. Neither means no access at all, not even to the root.
|
|
49
|
+
*/
|
|
50
|
+
export interface StartNodes {
|
|
51
|
+
root: boolean
|
|
52
|
+
keys: readonly string[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const ROOT_ACCESS: StartNodes = { root: true, keys: [] }
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Umbraco's path access: with root access everything, the recycle bin
|
|
59
|
+
* included; otherwise a node at or below a start node, and never in the bin.
|
|
60
|
+
* `chain` is the node's ancestor keys then its own, root first.
|
|
61
|
+
*/
|
|
62
|
+
export function hasPathAccess(
|
|
63
|
+
start: StartNodes,
|
|
64
|
+
chain: readonly string[],
|
|
65
|
+
trashed: boolean,
|
|
66
|
+
): boolean {
|
|
67
|
+
if (start.root) return true
|
|
68
|
+
if (trashed) return false
|
|
69
|
+
return start.keys.some((key) => chain.includes(key))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A start node's position: its ancestor keys then its own, root first; empty for the root. */
|
|
73
|
+
export type StartNodePath = readonly string[]
|
|
74
|
+
|
|
75
|
+
const within = (path: StartNodePath, ancestor: StartNodePath) =>
|
|
76
|
+
ancestor.length <= path.length && ancestor.every((key, i) => path[i] === key)
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Umbraco's `CombineStartNodes`: the groups' start nodes keep the topmost, the
|
|
80
|
+
* user's own keep the deepest, and each of the user's replaces any group start
|
|
81
|
+
* node above or below it. Callers leave out nodes that no longer exist or are
|
|
82
|
+
* in the recycle bin.
|
|
83
|
+
*/
|
|
84
|
+
export function combineStartNodes(
|
|
85
|
+
group: readonly StartNodePath[],
|
|
86
|
+
user: readonly StartNodePath[],
|
|
87
|
+
): StartNodes {
|
|
88
|
+
let kept: StartNodePath[] = []
|
|
89
|
+
for (const path of group) {
|
|
90
|
+
if (kept.some((k) => within(path, k))) continue
|
|
91
|
+
kept = kept.filter((k) => !within(k, path))
|
|
92
|
+
kept.push(path)
|
|
93
|
+
}
|
|
94
|
+
let own: StartNodePath[] = []
|
|
95
|
+
for (const path of user) {
|
|
96
|
+
if (own.some((k) => within(k, path))) continue
|
|
97
|
+
own = own.filter((k) => !within(path, k))
|
|
98
|
+
own.push(path)
|
|
99
|
+
}
|
|
100
|
+
for (const path of own) {
|
|
101
|
+
kept = kept.filter((k) => !within(path, k) && !within(k, path))
|
|
102
|
+
kept.push(path)
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
root: kept.some((path) => path.length === 0),
|
|
106
|
+
keys: kept.filter((path) => path.length > 0).map((path) => path[path.length - 1] as string),
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** One group's verbs: its defaults, and the nodes it sets explicitly (an empty list denies all). */
|
|
111
|
+
export interface GroupGrant {
|
|
112
|
+
key: string
|
|
113
|
+
alias: string
|
|
114
|
+
defaults: readonly string[]
|
|
115
|
+
granular: ReadonlyMap<string, readonly string[]>
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Umbraco's `HasAccessToSensitiveData`: membership of the Sensitive data group.
|
|
120
|
+
* Without it, values of member properties marked sensitive are withheld.
|
|
121
|
+
*/
|
|
122
|
+
export function hasAccessToSensitiveData(groups: ReadonlyArray<{ alias: string }>): boolean {
|
|
123
|
+
return groups.some((group) => group.alias === 'sensitiveData')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The verbs a user holds on a node, as Umbraco calculates them: for each group,
|
|
128
|
+
* the nearest node on the path (the node itself first) that the group sets
|
|
129
|
+
* explicitly replaces its defaults; the result is the union over groups.
|
|
130
|
+
* `chain` is the node's ancestor keys then its own, root first; empty for the
|
|
131
|
+
* root itself.
|
|
132
|
+
*/
|
|
133
|
+
export function permissionsForPath(
|
|
134
|
+
groups: readonly GroupGrant[],
|
|
135
|
+
chain: readonly string[],
|
|
136
|
+
): Set<string> {
|
|
137
|
+
const verbs = new Set<string>()
|
|
138
|
+
for (const group of groups) {
|
|
139
|
+
let granted = group.defaults
|
|
140
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
141
|
+
const explicit = group.granular.get(chain[i] as string)
|
|
142
|
+
if (explicit) {
|
|
143
|
+
granted = explicit
|
|
144
|
+
break
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const verb of granted) verbs.add(verb)
|
|
148
|
+
}
|
|
149
|
+
return verbs
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* An administrator bypasses the permission tables entirely, matching Umbraco:
|
|
154
|
+
* membership of the admin group is the check, not an exhaustive grant list.
|
|
155
|
+
*/
|
|
156
|
+
export function hasPermission(
|
|
157
|
+
permissions: UserPermissions,
|
|
158
|
+
verb: string,
|
|
159
|
+
nodeKey?: string,
|
|
160
|
+
): boolean {
|
|
161
|
+
if (permissions.isAdmin) return true
|
|
162
|
+
if (permissions.global.has(verb)) return true
|
|
163
|
+
if (!nodeKey) return false
|
|
164
|
+
return permissions.granular.get(nodeKey)?.has(verb) === true
|
|
165
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 7807 Problem Details as Umbraco emits them: `type` is the literal string
|
|
3
|
+
* "Error", never a URI, plus Umbraco's own extension members.
|
|
4
|
+
*/
|
|
5
|
+
export interface ProblemDetails {
|
|
6
|
+
type: string
|
|
7
|
+
title: string
|
|
8
|
+
status: number
|
|
9
|
+
detail?: string
|
|
10
|
+
instance?: string
|
|
11
|
+
/** The domain operation status enum name, e.g. "NotFound". */
|
|
12
|
+
operationStatus?: string
|
|
13
|
+
/** Validation failures keyed by JSON path into the request model. */
|
|
14
|
+
errors?: Record<string, string[]>
|
|
15
|
+
invalidProperties?: string[]
|
|
16
|
+
failedBranchItems?: Array<{ id: string; operationStatus: string }>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const PROBLEM_TYPE = 'Error'
|
|
20
|
+
|
|
21
|
+
export function problemDetails(
|
|
22
|
+
init: Omit<ProblemDetails, 'type'> & { type?: string },
|
|
23
|
+
): ProblemDetails {
|
|
24
|
+
return { type: PROBLEM_TYPE, ...init }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function problemResponse(problem: ProblemDetails, headers?: HeadersInit): Response {
|
|
28
|
+
return new Response(JSON.stringify(problem), {
|
|
29
|
+
status: problem.status,
|
|
30
|
+
headers: { ...headers, 'content-type': 'application/problem+json; charset=utf-8' },
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const notFound = (title = 'Not Found', detail?: string) =>
|
|
35
|
+
problemDetails({ title, status: 404, detail, operationStatus: 'NotFound' })
|
|
36
|
+
|
|
37
|
+
export const unauthorized = (detail = 'Authentication is required.') =>
|
|
38
|
+
problemDetails({ title: 'Unauthorized', status: 401, detail, operationStatus: 'Unauthorized' })
|
|
39
|
+
|
|
40
|
+
export const notImplemented = (operationId: string) =>
|
|
41
|
+
problemDetails({
|
|
42
|
+
title: 'Not implemented',
|
|
43
|
+
status: 501,
|
|
44
|
+
detail: `Operation '${operationId}' is declared in the contract but not implemented yet.`,
|
|
45
|
+
operationStatus: 'NotImplemented',
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Umbraco rejects a skip that is not a multiple of take, and the backoffice
|
|
50
|
+
* relies on that contract when it pages.
|
|
51
|
+
*/
|
|
52
|
+
export const invalidSkipTake = () =>
|
|
53
|
+
problemDetails({
|
|
54
|
+
title: 'Invalid skip/take',
|
|
55
|
+
detail: 'Skip must be a multiple of take - i.e. skip = 10, take = 5',
|
|
56
|
+
status: 400,
|
|
57
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editor-specific value shapes. Structured editors store JSON text and hand the
|
|
3
|
+
* editor the parsed value, as Umbraco's value editors do; everything else is
|
|
4
|
+
* stored and returned as is.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const RICH_TEXT_EDITOR_ALIAS = 'Umbraco.RichText'
|
|
8
|
+
export const UPLOAD_FIELD_EDITOR_ALIAS = 'Umbraco.UploadField'
|
|
9
|
+
export const IMAGE_CROPPER_EDITOR_ALIAS = 'Umbraco.ImageCropper'
|
|
10
|
+
|
|
11
|
+
/** Editors whose value names an uploaded file, and so may arrive with a temporary file. */
|
|
12
|
+
export const FILE_VALUE_EDITORS: ReadonlySet<string> = new Set([
|
|
13
|
+
UPLOAD_FIELD_EDITOR_ALIAS,
|
|
14
|
+
IMAGE_CROPPER_EDITOR_ALIAS,
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
/** Editors whose values are objects or arrays, stored as JSON. */
|
|
18
|
+
export const JSON_VALUE_EDITORS: ReadonlySet<string> = new Set([
|
|
19
|
+
RICH_TEXT_EDITOR_ALIAS,
|
|
20
|
+
'Umbraco.BlockList',
|
|
21
|
+
'Umbraco.BlockGrid',
|
|
22
|
+
'Umbraco.CheckBoxList',
|
|
23
|
+
'Umbraco.ColorPicker',
|
|
24
|
+
'Umbraco.DateTimeWithTimeZone',
|
|
25
|
+
'Umbraco.DropDown.Flexible',
|
|
26
|
+
// A `Guid[]` of the elements picked, not the `umb://…` references a content
|
|
27
|
+
// picker stores.
|
|
28
|
+
'Umbraco.ElementPicker',
|
|
29
|
+
'Umbraco.ImageCropper',
|
|
30
|
+
'Umbraco.MediaPicker3',
|
|
31
|
+
'Umbraco.MultipleTextstring',
|
|
32
|
+
'Umbraco.MultiUrlPicker',
|
|
33
|
+
'Umbraco.Slider',
|
|
34
|
+
'Umbraco.Tags',
|
|
35
|
+
])
|
|
36
|
+
|
|
37
|
+
export interface RichTextValue {
|
|
38
|
+
markup: string
|
|
39
|
+
blocks: unknown
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function tryParse(text: string): unknown {
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(text)
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A string spread into an object (`{...'abc'}` is `{0:'a',1:'b',2:'c'}`), which
|
|
52
|
+
* earlier builds stored when they handed the editor JSON text instead of JSON.
|
|
53
|
+
*/
|
|
54
|
+
function unspreadString(value: unknown): string | undefined {
|
|
55
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
56
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
57
|
+
const indexed = entries.filter(([key]) => /^\d+$/.test(key))
|
|
58
|
+
if (indexed.length === 0) return undefined
|
|
59
|
+
const chars: string[] = []
|
|
60
|
+
for (const [key, char] of indexed) {
|
|
61
|
+
if (typeof char !== 'string' || char.length !== 1) return undefined
|
|
62
|
+
chars[Number(key)] = char
|
|
63
|
+
}
|
|
64
|
+
if (chars.length !== indexed.length) return undefined
|
|
65
|
+
return chars.join('')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function toRichText(value: unknown): RichTextValue {
|
|
69
|
+
if (typeof value === 'string') {
|
|
70
|
+
const parsed = tryParse(value)
|
|
71
|
+
if (parsed && typeof parsed === 'object') return toRichText(parsed)
|
|
72
|
+
return { markup: value, blocks: null }
|
|
73
|
+
}
|
|
74
|
+
const spread = unspreadString(value)
|
|
75
|
+
if (spread !== undefined) return toRichText(spread)
|
|
76
|
+
const object = (value ?? {}) as Partial<RichTextValue>
|
|
77
|
+
return {
|
|
78
|
+
markup: typeof object.markup === 'string' ? object.markup : '',
|
|
79
|
+
blocks: object.blocks ?? null,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* An upload is stored as its path (`/media/ab12cd34/file.pdf`), as Umbraco
|
|
85
|
+
* stores it, and edited as `{ src }`.
|
|
86
|
+
*/
|
|
87
|
+
function toUpload(stored: unknown): { src: string } | null {
|
|
88
|
+
if (typeof stored === 'string') {
|
|
89
|
+
const parsed = tryParse(stored)
|
|
90
|
+
if (parsed && typeof parsed === 'object') return toUpload(parsed)
|
|
91
|
+
return stored ? { src: stored } : null
|
|
92
|
+
}
|
|
93
|
+
const src = (stored as { src?: unknown } | null)?.src
|
|
94
|
+
return typeof src === 'string' && src ? { src } : null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A stored instant as the date pickers show it: `2026-01-02 03:04:05`, in the zone it was entered in. */
|
|
98
|
+
function toWallClock(stored: unknown): unknown {
|
|
99
|
+
const date = new Date(String(stored))
|
|
100
|
+
if (Number.isNaN(date.getTime())) return stored
|
|
101
|
+
return date.toISOString().slice(0, 19).replace('T', ' ')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The value an editor receives for what is stored. */
|
|
105
|
+
export function toEditorValue(editorAlias: string | undefined, stored: unknown): unknown {
|
|
106
|
+
if (stored === null || stored === undefined || !editorAlias) return stored
|
|
107
|
+
if (editorAlias === RICH_TEXT_EDITOR_ALIAS) return toRichText(stored)
|
|
108
|
+
if (editorAlias === UPLOAD_FIELD_EDITOR_ALIAS) return toUpload(stored)
|
|
109
|
+
if (editorAlias === 'Umbraco.TrueFalse')
|
|
110
|
+
return stored === true || stored === 1 || stored === '1' || stored === 'true'
|
|
111
|
+
if (editorAlias === 'Umbraco.DateTime') return toWallClock(stored)
|
|
112
|
+
if (!JSON_VALUE_EDITORS.has(editorAlias) || typeof stored !== 'string') return stored
|
|
113
|
+
const parsed = tryParse(stored)
|
|
114
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : stored
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The value a template receives: rich text is its markup, an upload its URL. */
|
|
118
|
+
export function toPublishedValue(editorAlias: string | undefined, stored: unknown): unknown {
|
|
119
|
+
const value = toEditorValue(editorAlias, stored)
|
|
120
|
+
if (editorAlias === RICH_TEXT_EDITOR_ALIAS && value) return (value as RichTextValue).markup
|
|
121
|
+
if (editorAlias === UPLOAD_FIELD_EDITOR_ALIAS)
|
|
122
|
+
return (value as { src: string } | null)?.src ?? null
|
|
123
|
+
return value
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** What is stored for an upload the editor sent: its path, once any temporary file is placed. */
|
|
127
|
+
export function toStoredUpload(value: unknown): string | null {
|
|
128
|
+
return toUpload(value)?.src ?? null
|
|
129
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Validation for `returnUrl` values, which are attacker-supplied by nature. */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The origin a candidate is resolved against. Any host but this one means the
|
|
5
|
+
* value escaped the site, whatever it looked like as text.
|
|
6
|
+
*/
|
|
7
|
+
const PROBE = 'https://return-url.invalid'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A `returnUrl` reduced to a path on this site, or nothing.
|
|
11
|
+
*
|
|
12
|
+
* Both callers take the value from a query string and hand it to a browser to
|
|
13
|
+
* navigate to, so this is the difference between resuming a sign-in and bouncing
|
|
14
|
+
* the person somewhere else with a session in hand. Text comparisons are not
|
|
15
|
+
* enough: browsers read `\` as `/` and strip tabs and newlines before resolving,
|
|
16
|
+
* so `/\evil.com` and `/<tab>/evil.com` both leave the site while starting with a
|
|
17
|
+
* single slash. Resolving against a probe origin and insisting the result stayed
|
|
18
|
+
* there is the check that holds, and it rejects `javascript:` on the way through
|
|
19
|
+
* because that parses to no origin at all.
|
|
20
|
+
*
|
|
21
|
+
* The value returned is the re-serialised path, so what the caller stores cannot
|
|
22
|
+
* be read differently by whatever parses it next.
|
|
23
|
+
*/
|
|
24
|
+
export function localReturnUrl(value: string | null | undefined): string | undefined {
|
|
25
|
+
if (!value) return undefined
|
|
26
|
+
// By code point rather than a regex: a control character written into one is
|
|
27
|
+
// exactly what a linter is right to stop, and this says the same thing plainly.
|
|
28
|
+
const cleaned = [...value]
|
|
29
|
+
.filter((character) => {
|
|
30
|
+
const code = character.codePointAt(0) ?? 0
|
|
31
|
+
return code > 0x1f && code !== 0x7f
|
|
32
|
+
})
|
|
33
|
+
.join('')
|
|
34
|
+
if (!cleaned.startsWith('/')) return undefined
|
|
35
|
+
try {
|
|
36
|
+
const resolved = new URL(cleaned, PROBE)
|
|
37
|
+
if (resolved.origin !== PROBE) return undefined
|
|
38
|
+
return `${resolved.pathname}${resolved.search}${resolved.hash}`
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/sections.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sections, and the two different alias vocabularies for them.
|
|
3
|
+
*
|
|
4
|
+
* The database stores Umbraco's application aliases (`content`, `users`), while
|
|
5
|
+
* the backoffice matches section *manifest* aliases (`Umb.Section.Content`)
|
|
6
|
+
* against `allowedSections`. The Management API translates between them, so a
|
|
7
|
+
* value from one vocabulary must never leak into the other.
|
|
8
|
+
*/
|
|
9
|
+
export const SECTION_ALIASES = {
|
|
10
|
+
content: 'Umb.Section.Content',
|
|
11
|
+
media: 'Umb.Section.Media',
|
|
12
|
+
settings: 'Umb.Section.Settings',
|
|
13
|
+
users: 'Umb.Section.Users',
|
|
14
|
+
members: 'Umb.Section.Members',
|
|
15
|
+
translation: 'Umb.Section.Translation',
|
|
16
|
+
library: 'Umb.Section.Library',
|
|
17
|
+
packages: 'Umb.Section.Packages',
|
|
18
|
+
} as const
|
|
19
|
+
|
|
20
|
+
export type AppAlias = keyof typeof SECTION_ALIASES
|
|
21
|
+
export type SectionAlias = (typeof SECTION_ALIASES)[AppAlias]
|
|
22
|
+
|
|
23
|
+
/** Every section the shipped backoffice knows how to render. */
|
|
24
|
+
export const CORE_SECTION_ALIASES: readonly SectionAlias[] = Object.values(SECTION_ALIASES)
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Translates stored application aliases into section manifest aliases, dropping
|
|
28
|
+
* any with no core section — `forms` is seeded by Umbraco but is a commercial
|
|
29
|
+
* add-on, so it has no section in the open-source backoffice.
|
|
30
|
+
*/
|
|
31
|
+
export function toSectionAliases(appAliases: readonly string[]): SectionAlias[] {
|
|
32
|
+
const seen = new Set<SectionAlias>()
|
|
33
|
+
for (const appAlias of appAliases) {
|
|
34
|
+
const sectionAlias = SECTION_ALIASES[appAlias as AppAlias]
|
|
35
|
+
if (sectionAlias) seen.add(sectionAlias)
|
|
36
|
+
}
|
|
37
|
+
return [...seen]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const STORED_ALIASES: Record<string, string> = { ...SECTION_ALIASES, forms: 'Umb.Section.Forms' }
|
|
41
|
+
|
|
42
|
+
/** Umbraco's `SectionMapper.GetName`: a stored alias as its section alias, or unchanged when unknown. */
|
|
43
|
+
export function sectionName(appAlias: string): string {
|
|
44
|
+
return STORED_ALIASES[appAlias] ?? appAlias
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Umbraco's `SectionMapper.GetAlias`: a section alias as the alias stored for it, or unchanged. */
|
|
48
|
+
export function sectionAppAlias(name: string): string {
|
|
49
|
+
return Object.entries(STORED_ALIASES).find(([, value]) => value === name)?.[0] ?? name
|
|
50
|
+
}
|
package/src/uploads.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What may be uploaded, as Umbraco's defaults have it (`ContentSettings`,
|
|
3
|
+
* `ContentImagingSettings`). The temporary-file configuration endpoint reports
|
|
4
|
+
* these, and the upload endpoints enforce the same lists.
|
|
5
|
+
*/
|
|
6
|
+
export interface UploadSettings {
|
|
7
|
+
/** Extensions treated as images: resizable, croppable, shown as thumbnails. */
|
|
8
|
+
imageFileTypes: string[]
|
|
9
|
+
/** Never accepted, whatever else is configured: files a server could execute. */
|
|
10
|
+
disallowedExtensions: string[]
|
|
11
|
+
/** When non-empty, only these are accepted. Empty means anything not disallowed. */
|
|
12
|
+
allowedExtensions: string[]
|
|
13
|
+
/** In bytes; null means no limit is configured. */
|
|
14
|
+
maxFileSize: number | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_UPLOAD_SETTINGS: Readonly<UploadSettings> = {
|
|
18
|
+
imageFileTypes: ['jpeg', 'jpg', 'gif', 'bmp', 'png', 'tiff', 'tif', 'webp'],
|
|
19
|
+
disallowedExtensions: [
|
|
20
|
+
'ashx',
|
|
21
|
+
'aspx',
|
|
22
|
+
'ascx',
|
|
23
|
+
'config',
|
|
24
|
+
'cshtml',
|
|
25
|
+
'vbhtml',
|
|
26
|
+
'asmx',
|
|
27
|
+
'air',
|
|
28
|
+
'axd',
|
|
29
|
+
'xamlx',
|
|
30
|
+
],
|
|
31
|
+
allowedExtensions: [],
|
|
32
|
+
maxFileSize: null,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether a file name's extension may be uploaded under these settings. */
|
|
36
|
+
export function isUploadAllowed(fileName: string, settings: UploadSettings): boolean {
|
|
37
|
+
const dot = fileName.lastIndexOf('.')
|
|
38
|
+
const extension = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : ''
|
|
39
|
+
if (settings.disallowedExtensions.includes(extension)) return false
|
|
40
|
+
return settings.allowedExtensions.length === 0 || settings.allowedExtensions.includes(extension)
|
|
41
|
+
}
|
package/src/users.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Rules for backoffice users' credentials, as Umbraco's `UserPasswordConfigurationSettings` defaults them. */
|
|
2
|
+
|
|
3
|
+
export interface PasswordConfiguration {
|
|
4
|
+
minimumPasswordLength: number
|
|
5
|
+
requireNonLetterOrDigit: boolean
|
|
6
|
+
requireDigit: boolean
|
|
7
|
+
requireLowercase: boolean
|
|
8
|
+
requireUppercase: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_PASSWORD_CONFIGURATION: PasswordConfiguration = {
|
|
12
|
+
minimumPasswordLength: 10,
|
|
13
|
+
requireNonLetterOrDigit: false,
|
|
14
|
+
requireDigit: false,
|
|
15
|
+
requireLowercase: false,
|
|
16
|
+
requireUppercase: false,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isValidPassword(
|
|
20
|
+
password: string,
|
|
21
|
+
config: PasswordConfiguration = DEFAULT_PASSWORD_CONFIGURATION,
|
|
22
|
+
): boolean {
|
|
23
|
+
if (password.length < config.minimumPasswordLength) return false
|
|
24
|
+
if (config.requireDigit && !/\d/.test(password)) return false
|
|
25
|
+
if (config.requireLowercase && !/[a-z]/.test(password)) return false
|
|
26
|
+
if (config.requireUppercase && !/[A-Z]/.test(password)) return false
|
|
27
|
+
if (config.requireNonLetterOrDigit && !/[^A-Za-z0-9]/.test(password)) return false
|
|
28
|
+
return true
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A password that satisfies `config`, for a reset the backoffice shows once. */
|
|
32
|
+
export function generateUserPassword(
|
|
33
|
+
config: PasswordConfiguration = DEFAULT_PASSWORD_CONFIGURATION,
|
|
34
|
+
): string {
|
|
35
|
+
const letters = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ'
|
|
36
|
+
const all = `${letters}23456789`
|
|
37
|
+
const length = Math.max(config.minimumPasswordLength, 12)
|
|
38
|
+
const random = crypto.getRandomValues(new Uint32Array(length))
|
|
39
|
+
const chars = Array.from(random, (n) => all[n % all.length] as string)
|
|
40
|
+
// One of each class keeps any combination of requirements satisfied
|
|
41
|
+
chars[0] = 'abcdefghijkmnopqrstuvwxyz'[(random[0] ?? 0) % 25] as string
|
|
42
|
+
chars[1] = 'ABCDEFGHJKLMNPQRSTUVWXYZ'[(random[1] ?? 0) % 24] as string
|
|
43
|
+
chars[2] = '23456789'[(random[2] ?? 0) % 8] as string
|
|
44
|
+
if (config.requireNonLetterOrDigit) chars[3] = '!@#$%*-_+='[(random[3] ?? 0) % 10] as string
|
|
45
|
+
return chars.join('')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A plausible e-mail address; Umbraco's check is no stricter. */
|
|
49
|
+
export function isValidEmail(email: string): boolean {
|
|
50
|
+
return /^[^\s@]+@[^\s@]+$/.test(email)
|
|
51
|
+
}
|
package/README.md
DELETED