@crombolo/api-management 0.0.0-stage → 0.10.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 +32 -4
- package/src/authorization.ts +453 -0
- package/src/handlers/content-type.ts +784 -0
- package/src/handlers/data-type.ts +267 -0
- package/src/handlers/dictionary.ts +245 -0
- package/src/handlers/document-blueprint.ts +193 -0
- package/src/handlers/document.ts +961 -0
- package/src/handlers/editor-services.ts +59 -0
- package/src/handlers/element.ts +492 -0
- package/src/handlers/file-systems.ts +207 -0
- package/src/handlers/import.ts +46 -0
- package/src/handlers/localization.ts +116 -0
- package/src/handlers/log-viewer.ts +125 -0
- package/src/handlers/manifest.ts +15 -0
- package/src/handlers/media.ts +453 -0
- package/src/handlers/member-group.ts +87 -0
- package/src/handlers/member.ts +189 -0
- package/src/handlers/models-builder.ts +29 -0
- package/src/handlers/package.ts +193 -0
- package/src/handlers/public-access.ts +107 -0
- package/src/handlers/published-cache.ts +27 -0
- package/src/handlers/redirects.ts +78 -0
- package/src/handlers/references.ts +44 -0
- package/src/handlers/server.ts +25 -0
- package/src/handlers/settings-stubs.ts +76 -0
- package/src/handlers/start-node-trees.ts +84 -0
- package/src/handlers/template-query.ts +191 -0
- package/src/handlers/template.ts +150 -0
- package/src/handlers/temporary-file.ts +62 -0
- package/src/handlers/user.ts +604 -0
- package/src/index.ts +17 -0
- package/src/ports-content.ts +564 -0
- package/src/ports-files.ts +51 -0
- package/src/ports-logs.ts +38 -0
- package/src/ports-members.ts +111 -0
- package/src/ports-models-builder.ts +26 -0
- package/src/ports-packages.ts +59 -0
- package/src/ports-users.ts +230 -0
- package/src/ports.ts +225 -0
- package/src/router.ts +216 -0
- package/src/routes.ts +99 -0
- package/src/server-events.ts +71 -0
- package/README.md +0 -3
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Partial views, stylesheets and scripts: one file-system area each, served by
|
|
3
|
+
* the same handlers — the tree, items, the file and its folders — keyed by path.
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
invalidSkipTake,
|
|
7
|
+
notFound,
|
|
8
|
+
paged,
|
|
9
|
+
parseSkipTake,
|
|
10
|
+
problemDetails,
|
|
11
|
+
problemResponse,
|
|
12
|
+
} from '@crombolo/core'
|
|
13
|
+
import type { FileEntry, FileResult, FileStatus, FileSystemPort, Snippet } from '../ports-files.ts'
|
|
14
|
+
import type { ManagementApiRouter, RequestContext } from '../router.ts'
|
|
15
|
+
|
|
16
|
+
export type FileArea = 'PartialView' | 'Stylesheet' | 'Script'
|
|
17
|
+
|
|
18
|
+
const LABEL: Record<FileArea, string> = {
|
|
19
|
+
PartialView: 'Partial view',
|
|
20
|
+
Stylesheet: 'Stylesheet',
|
|
21
|
+
Script: 'Script',
|
|
22
|
+
}
|
|
23
|
+
const ROUTE: Record<FileArea, string> = {
|
|
24
|
+
PartialView: 'partial-view',
|
|
25
|
+
Stylesheet: 'stylesheet',
|
|
26
|
+
Script: 'script',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const parentRef = (parentPath: string | null) => (parentPath ? { path: parentPath } : null)
|
|
30
|
+
|
|
31
|
+
const treeItem = (entry: FileEntry) => ({
|
|
32
|
+
name: entry.name,
|
|
33
|
+
path: entry.path,
|
|
34
|
+
parent: parentRef(entry.parentPath),
|
|
35
|
+
isFolder: entry.isFolder,
|
|
36
|
+
hasChildren: entry.hasChildren,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
function failure(area: FileArea, status: FileStatus, folder = false): Response {
|
|
40
|
+
const thing = folder ? 'Folder' : LABEL[area]
|
|
41
|
+
const [code, title] = (
|
|
42
|
+
{
|
|
43
|
+
AlreadyExists: [400, `${thing} already exists`],
|
|
44
|
+
NotFound: [404, `${thing} not found`],
|
|
45
|
+
ParentNotFound: [404, 'Parent not found'],
|
|
46
|
+
InvalidName: [400, 'Invalid name'],
|
|
47
|
+
InvalidFileExtension: [400, 'Invalid file extension'],
|
|
48
|
+
NotEmpty: [400, 'Folder is not empty'],
|
|
49
|
+
} as const
|
|
50
|
+
)[status]
|
|
51
|
+
return problemResponse(problemDetails({ title, status: code, operationStatus: status }))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function body(ctx: RequestContext): Promise<Record<string, unknown>> {
|
|
55
|
+
return ((await ctx.request.json().catch(() => ({}))) ?? {}) as Record<string, unknown>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const parentPathOf = (value: unknown): string | null => {
|
|
59
|
+
const path = (value as { path?: unknown } | null | undefined)?.path
|
|
60
|
+
return typeof path === 'string' && path !== '' && path !== '/' ? path : null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function registerFileSystemHandlers(
|
|
64
|
+
router: ManagementApiRouter,
|
|
65
|
+
area: FileArea,
|
|
66
|
+
port: FileSystemPort,
|
|
67
|
+
snippets: readonly Snippet[] = [],
|
|
68
|
+
): void {
|
|
69
|
+
const base = `/umbraco/management/api/v1/${ROUTE[area]}`
|
|
70
|
+
const created = (ctx: RequestContext, path: string, folder = false) =>
|
|
71
|
+
new Response(null, {
|
|
72
|
+
status: 201,
|
|
73
|
+
headers: {
|
|
74
|
+
location: `${ctx.url.origin}${base}${folder ? '/folder' : ''}/${encodeURIComponent(path)}`,
|
|
75
|
+
'umb-generated-resource': encodeURIComponent(path),
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
const result = (ctx: RequestContext, outcome: FileResult, create: boolean, folder = false) => {
|
|
79
|
+
if (!outcome.ok) return failure(area, outcome.status, folder)
|
|
80
|
+
return create ? created(ctx, outcome.path, folder) : new Response(null, { status: 200 })
|
|
81
|
+
}
|
|
82
|
+
const page = (ctx: RequestContext, entries: readonly FileEntry[] | undefined) => {
|
|
83
|
+
const parsed = parseSkipTake(ctx.url.searchParams)
|
|
84
|
+
if (!parsed.ok) return problemResponse(invalidSkipTake())
|
|
85
|
+
const all = (entries ?? []).map(treeItem)
|
|
86
|
+
const { skip, take } = parsed.value
|
|
87
|
+
return Response.json(paged(all.slice(skip, skip + take), all.length))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
router.handle(`GetTree${area}Root`, async (ctx) => page(ctx, await port.children(null)))
|
|
91
|
+
router.handle(`GetTree${area}Children`, async (ctx) =>
|
|
92
|
+
page(ctx, await port.children(ctx.url.searchParams.get('parentPath') || null)),
|
|
93
|
+
)
|
|
94
|
+
router.handle(`GetTree${area}Ancestors`, async (ctx) => {
|
|
95
|
+
const path = ctx.url.searchParams.get('descendantPath')
|
|
96
|
+
const chain: FileEntry[] = []
|
|
97
|
+
let current = path ? await port.entry(path) : undefined
|
|
98
|
+
while (current) {
|
|
99
|
+
chain.unshift(current)
|
|
100
|
+
current = current.parentPath ? await port.entry(current.parentPath) : undefined
|
|
101
|
+
}
|
|
102
|
+
return Response.json(chain.map(treeItem))
|
|
103
|
+
})
|
|
104
|
+
router.handle(`GetTree${area}Siblings`, async (ctx) => {
|
|
105
|
+
const path = ctx.url.searchParams.get('path') ?? ''
|
|
106
|
+
const target = await port.entry(path)
|
|
107
|
+
// As the tree answers for a path it cannot find: no siblings
|
|
108
|
+
if (!target) return Response.json({ totalBefore: 0, totalAfter: 0, items: [] })
|
|
109
|
+
const siblings = (await port.children(target.parentPath)) ?? []
|
|
110
|
+
const index = siblings.findIndex((s) => s.path === target.path)
|
|
111
|
+
const before = Math.max(0, Number(ctx.url.searchParams.get('before') ?? 0))
|
|
112
|
+
const after = Math.max(0, Number(ctx.url.searchParams.get('after') ?? 0))
|
|
113
|
+
const from = Math.max(0, index - before)
|
|
114
|
+
return Response.json({
|
|
115
|
+
totalBefore: from,
|
|
116
|
+
totalAfter: Math.max(0, siblings.length - (index + after + 1)),
|
|
117
|
+
items: siblings.slice(from, index + after + 1).map(treeItem),
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
router.handle(`GetItem${area}`, async (ctx) => {
|
|
122
|
+
const items = []
|
|
123
|
+
for (const path of ctx.url.searchParams.getAll('path')) {
|
|
124
|
+
const entry = await port.entry(path)
|
|
125
|
+
if (entry)
|
|
126
|
+
items.push({
|
|
127
|
+
isFolder: entry.isFolder,
|
|
128
|
+
name: entry.name,
|
|
129
|
+
parent: parentRef(entry.parentPath),
|
|
130
|
+
path: entry.path,
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
return Response.json(items)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
router.handle(`Get${area}ByPath`, async (ctx) => {
|
|
137
|
+
const file = await port.read(ctx.params.path as string)
|
|
138
|
+
if (!file) return failure(area, 'NotFound')
|
|
139
|
+
return Response.json({
|
|
140
|
+
name: file.name,
|
|
141
|
+
path: file.path,
|
|
142
|
+
parent: parentRef(file.parentPath),
|
|
143
|
+
content: file.content,
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
router.handle(`Post${area}`, async (ctx) => {
|
|
147
|
+
const b = await body(ctx)
|
|
148
|
+
const outcome = await port.create(
|
|
149
|
+
parentPathOf(b.parent),
|
|
150
|
+
String(b.name ?? ''),
|
|
151
|
+
typeof b.content === 'string' ? b.content : '',
|
|
152
|
+
)
|
|
153
|
+
return result(ctx, outcome, true)
|
|
154
|
+
})
|
|
155
|
+
router.handle(`Put${area}ByPath`, async (ctx) => {
|
|
156
|
+
const b = await body(ctx)
|
|
157
|
+
const content = typeof b.content === 'string' ? b.content : ''
|
|
158
|
+
return result(ctx, await port.update(ctx.params.path as string, content), false)
|
|
159
|
+
})
|
|
160
|
+
router.handle(`Put${area}ByPathRename`, async (ctx) => {
|
|
161
|
+
const b = await body(ctx)
|
|
162
|
+
return result(ctx, await port.rename(ctx.params.path as string, String(b.name ?? '')), true)
|
|
163
|
+
})
|
|
164
|
+
router.handle(`Delete${area}ByPath`, async (ctx) =>
|
|
165
|
+
result(ctx, await port.delete(ctx.params.path as string), false),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
router.handle(`Post${area}Folder`, async (ctx) => {
|
|
169
|
+
const b = await body(ctx)
|
|
170
|
+
return result(
|
|
171
|
+
ctx,
|
|
172
|
+
await port.createFolder(parentPathOf(b.parent), String(b.name ?? '')),
|
|
173
|
+
true,
|
|
174
|
+
true,
|
|
175
|
+
)
|
|
176
|
+
})
|
|
177
|
+
router.handle(`Get${area}FolderByPath`, async (ctx) => {
|
|
178
|
+
const entry = await port.entry(ctx.params.path as string)
|
|
179
|
+
if (!entry?.isFolder) return failure(area, 'NotFound', true)
|
|
180
|
+
return Response.json({
|
|
181
|
+
name: entry.name,
|
|
182
|
+
path: entry.path,
|
|
183
|
+
parent: parentRef(entry.parentPath),
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
router.handle(`Delete${area}FolderByPath`, async (ctx) =>
|
|
187
|
+
result(ctx, await port.deleteFolder(ctx.params.path as string), false, true),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
if (area !== 'PartialView') return
|
|
191
|
+
router.handle('GetPartialViewSnippet', (ctx) => {
|
|
192
|
+
const parsed = parseSkipTake(ctx.url.searchParams)
|
|
193
|
+
if (!parsed.ok) return problemResponse(invalidSkipTake())
|
|
194
|
+
const { skip, take } = parsed.value
|
|
195
|
+
return Response.json(
|
|
196
|
+
paged(
|
|
197
|
+
snippets.slice(skip, skip + take).map((s) => ({ id: s.id, name: s.name })),
|
|
198
|
+
snippets.length,
|
|
199
|
+
),
|
|
200
|
+
)
|
|
201
|
+
})
|
|
202
|
+
router.handle('GetPartialViewSnippetById', (ctx) => {
|
|
203
|
+
const snippet = snippets.find((s) => s.id === ctx.params.id)
|
|
204
|
+
if (!snippet) return problemResponse(notFound('Snippet not found'))
|
|
205
|
+
return Response.json(snippet)
|
|
206
|
+
})
|
|
207
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an uploaded file turns out to be, which the client asks before it offers
|
|
3
|
+
* to import: the entity type, and the alias and key inside the file so it can
|
|
4
|
+
* tell a new type from an update to an existing one.
|
|
5
|
+
*/
|
|
6
|
+
import { notFound, problemDetails, problemResponse } from '@crombolo/core'
|
|
7
|
+
import type { TemporaryFilePort } from '../ports.ts'
|
|
8
|
+
import type { ManagementApiRouter } from '../router.ts'
|
|
9
|
+
|
|
10
|
+
/** The root element names Umbraco's `.udt` exports use, to their entity types. */
|
|
11
|
+
const ENTITY_OF: Record<string, string> = {
|
|
12
|
+
documenttype: 'document-type',
|
|
13
|
+
mediatype: 'media-type',
|
|
14
|
+
membertype: 'member-type',
|
|
15
|
+
datatype: 'data-type',
|
|
16
|
+
dictionaryitem: 'dictionary-item',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function registerImportHandlers(
|
|
20
|
+
router: ManagementApiRouter,
|
|
21
|
+
temporaryFiles: TemporaryFilePort,
|
|
22
|
+
): void {
|
|
23
|
+
router.handle('GetImportAnalyze', async (ctx) => {
|
|
24
|
+
const id = ctx.url.searchParams.get('temporaryFileId')
|
|
25
|
+
if (!id)
|
|
26
|
+
return problemResponse(
|
|
27
|
+
problemDetails({ title: 'A temporary file id is required', status: 400 }),
|
|
28
|
+
)
|
|
29
|
+
const file = await temporaryFiles.read(id)
|
|
30
|
+
if (!file) return problemResponse(notFound('The file could not be found'))
|
|
31
|
+
const text = new TextDecoder().decode(file.bytes)
|
|
32
|
+
const root = /<\s*([A-Za-z][\w.-]*)/.exec(text.replace(/<\?[^?]*\?>/g, ''))?.[1]
|
|
33
|
+
const entityType = root ? ENTITY_OF[root.toLowerCase()] : undefined
|
|
34
|
+
if (!entityType)
|
|
35
|
+
return problemResponse(
|
|
36
|
+
problemDetails({
|
|
37
|
+
title: 'The file could not be read',
|
|
38
|
+
detail: 'It is not an Umbraco export.',
|
|
39
|
+
status: 400,
|
|
40
|
+
}),
|
|
41
|
+
)
|
|
42
|
+
const inside = (name: string) =>
|
|
43
|
+
new RegExp(`<${name}>([^<]*)</${name}>`, 'i').exec(text)?.[1]?.trim() || null
|
|
44
|
+
return Response.json({ entityType, alias: inside('Alias'), key: inside('Key') })
|
|
45
|
+
})
|
|
46
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cultures and languages. UI translation strings are client-side manifests, not
|
|
3
|
+
* server endpoints — these describe which cultures exist, not what words mean.
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
invalidSkipTake,
|
|
7
|
+
notFound,
|
|
8
|
+
paged,
|
|
9
|
+
parseSkipTake,
|
|
10
|
+
problemDetails,
|
|
11
|
+
problemResponse,
|
|
12
|
+
} from '@crombolo/core'
|
|
13
|
+
import type { LanguageInput, LocalizationPort } from '../ports.ts'
|
|
14
|
+
import type { ManagementApiRouter, RequestContext } from '../router.ts'
|
|
15
|
+
|
|
16
|
+
/** Applies Umbraco's paging contract, including the skip-multiple-of-take rule. */
|
|
17
|
+
function pageOf<T>(ctx: RequestContext, items: readonly T[]): Response {
|
|
18
|
+
const skipTake = parseSkipTake(ctx.url.searchParams)
|
|
19
|
+
if (!skipTake.ok) return problemResponse(invalidSkipTake())
|
|
20
|
+
const { skip, take } = skipTake.value
|
|
21
|
+
const window = take === 0 ? [] : items.slice(skip, skip + take)
|
|
22
|
+
return Response.json(paged([...window], items.length))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function registerLocalizationHandlers(
|
|
26
|
+
router: ManagementApiRouter,
|
|
27
|
+
localization: LocalizationPort,
|
|
28
|
+
): void {
|
|
29
|
+
router.handle('GetCulture', (ctx) => pageOf(ctx, localization.cultures()))
|
|
30
|
+
// Segments come from a segment provider; like Umbraco without one, there are none
|
|
31
|
+
router.handle('GetSegment', (ctx) => pageOf(ctx, []))
|
|
32
|
+
router.handle('GetDocumentByIdAvailableSegmentOptions', (ctx) => pageOf(ctx, []))
|
|
33
|
+
router.handle('GetLanguage', async (ctx) => pageOf(ctx, await localization.allLanguages()))
|
|
34
|
+
router.handle('GetItemLanguageDefault', async () => {
|
|
35
|
+
const languages = await localization.allLanguages()
|
|
36
|
+
const fallback = languages.find((l) => l.isDefault) ?? languages[0]
|
|
37
|
+
return Response.json(
|
|
38
|
+
fallback ?? {
|
|
39
|
+
isoCode: 'en-US',
|
|
40
|
+
name: 'English (United States)',
|
|
41
|
+
isDefault: true,
|
|
42
|
+
isMandatory: true,
|
|
43
|
+
fallbackIsoCode: null,
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
})
|
|
47
|
+
router.handle('GetItemLanguage', async (ctx) => {
|
|
48
|
+
const wanted = new Set(ctx.url.searchParams.getAll('isoCode').map((c) => c.toLowerCase()))
|
|
49
|
+
const languages = await localization.allLanguages()
|
|
50
|
+
return Response.json(
|
|
51
|
+
languages
|
|
52
|
+
.filter((l) => wanted.size === 0 || wanted.has(l.isoCode.toLowerCase()))
|
|
53
|
+
.map((l) => ({ name: l.name, isoCode: l.isoCode })),
|
|
54
|
+
)
|
|
55
|
+
})
|
|
56
|
+
router.handle('GetLanguageByIsoCode', async ({ params }) => {
|
|
57
|
+
const language = await localization.language(params.isoCode as string)
|
|
58
|
+
if (!language) return problemResponse(notFound('The language could not be found'))
|
|
59
|
+
return Response.json(language)
|
|
60
|
+
})
|
|
61
|
+
router.handle('PostLanguage', async (ctx) => {
|
|
62
|
+
const body = (await ctx.request.json()) as Record<string, unknown>
|
|
63
|
+
const isoCode = String(body.isoCode ?? '')
|
|
64
|
+
if (!isoCode)
|
|
65
|
+
return problemResponse(problemDetails({ title: 'isoCode is required', status: 400 }))
|
|
66
|
+
if (await localization.language(isoCode))
|
|
67
|
+
return problemResponse(
|
|
68
|
+
problemDetails({
|
|
69
|
+
title: 'The language already exists',
|
|
70
|
+
status: 400,
|
|
71
|
+
operationStatus: 'DuplicateIsoCode',
|
|
72
|
+
}),
|
|
73
|
+
)
|
|
74
|
+
await localization.saveLanguage(readLanguage(isoCode, body))
|
|
75
|
+
ctx.notifications.push({ message: 'Language created', category: 'Language', type: 'Success' })
|
|
76
|
+
return new Response(null, {
|
|
77
|
+
status: 201,
|
|
78
|
+
headers: { location: `${ctx.url.origin}/umbraco/management/api/v1/language/${isoCode}` },
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
router.handle('PutLanguageByIsoCode', async (ctx) => {
|
|
82
|
+
const isoCode = ctx.params.isoCode as string
|
|
83
|
+
const existing = await localization.language(isoCode)
|
|
84
|
+
if (!existing) return problemResponse(notFound('The language could not be found'))
|
|
85
|
+
const body = (await ctx.request.json()) as Record<string, unknown>
|
|
86
|
+
await localization.saveLanguage(readLanguage(existing.isoCode, body))
|
|
87
|
+
ctx.notifications.push({ message: 'Language saved', category: 'Language', type: 'Success' })
|
|
88
|
+
return new Response(null, { status: 200 })
|
|
89
|
+
})
|
|
90
|
+
router.handle('DeleteLanguageByIsoCode', async (ctx) => {
|
|
91
|
+
const result = await localization.deleteLanguage(ctx.params.isoCode as string)
|
|
92
|
+
if (result === 'not-found') return problemResponse(notFound('The language could not be found'))
|
|
93
|
+
if (result !== 'deleted')
|
|
94
|
+
return problemResponse(
|
|
95
|
+
problemDetails({
|
|
96
|
+
title:
|
|
97
|
+
result === 'default'
|
|
98
|
+
? 'The default language cannot be deleted'
|
|
99
|
+
: 'Another language falls back to this one',
|
|
100
|
+
status: 400,
|
|
101
|
+
}),
|
|
102
|
+
)
|
|
103
|
+
ctx.notifications.push({ message: 'Language deleted', category: 'Language', type: 'Success' })
|
|
104
|
+
return new Response(null, { status: 200 })
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function readLanguage(isoCode: string, body: Record<string, unknown>): LanguageInput {
|
|
109
|
+
return {
|
|
110
|
+
isoCode,
|
|
111
|
+
name: String(body.name ?? isoCode),
|
|
112
|
+
isDefault: body.isDefault === true,
|
|
113
|
+
isMandatory: body.isMandatory === true,
|
|
114
|
+
fallbackIsoCode: typeof body.fallbackIsoCode === 'string' ? body.fallbackIsoCode : null,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The log viewer: levels, level counts, message templates, the log itself and
|
|
3
|
+
* saved searches, with Umbraco's defaults — the last day when either date is
|
|
4
|
+
* missing, 100 per page, newest first — and its size check before any read.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
invalidSkipTake,
|
|
8
|
+
notFound,
|
|
9
|
+
paged,
|
|
10
|
+
parseSkipTake,
|
|
11
|
+
problemDetails,
|
|
12
|
+
problemResponse,
|
|
13
|
+
} from '@crombolo/core'
|
|
14
|
+
import type { LogLevelName, LogRange, LogViewerPort } from '../ports-logs.ts'
|
|
15
|
+
import type { ManagementApiRouter, RequestContext } from '../router.ts'
|
|
16
|
+
|
|
17
|
+
const LEVELS: readonly LogLevelName[] = [
|
|
18
|
+
'Verbose',
|
|
19
|
+
'Debug',
|
|
20
|
+
'Information',
|
|
21
|
+
'Warning',
|
|
22
|
+
'Error',
|
|
23
|
+
'Fatal',
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
const tooLarge = () =>
|
|
27
|
+
problemResponse(
|
|
28
|
+
problemDetails({
|
|
29
|
+
title: 'Cancelled due to log file size',
|
|
30
|
+
detail: 'The log file size for the requested date range prevented the operation.',
|
|
31
|
+
status: 400,
|
|
32
|
+
operationStatus: 'CancelledByLogsSizeValidation',
|
|
33
|
+
}),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
function dateParam(ctx: RequestContext, name: string): Date | null {
|
|
37
|
+
const value = ctx.url.searchParams.get(name)
|
|
38
|
+
if (!value) return null
|
|
39
|
+
const date = new Date(value)
|
|
40
|
+
return Number.isNaN(date.getTime()) ? null : date
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const rangeOf = (ctx: RequestContext): LogRange => ({
|
|
44
|
+
start: dateParam(ctx, 'startDate'),
|
|
45
|
+
end: dateParam(ctx, 'endDate'),
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
function page<T>(ctx: RequestContext, items: readonly T[]): Response {
|
|
49
|
+
const parsed = parseSkipTake(ctx.url.searchParams)
|
|
50
|
+
if (!parsed.ok) return problemResponse(invalidSkipTake())
|
|
51
|
+
const { skip, take } = parsed.value
|
|
52
|
+
return Response.json(paged(items.slice(skip, skip + take), items.length))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function registerLogViewerHandlers(router: ManagementApiRouter, logs: LogViewerPort): void {
|
|
56
|
+
router.handle('GetLogViewerLevel', (ctx) => page(ctx, logs.loggers()))
|
|
57
|
+
|
|
58
|
+
router.handle('GetLogViewerValidateLogsSize', async (ctx) =>
|
|
59
|
+
(await logs.canView(rangeOf(ctx))) ? new Response(null, { status: 200 }) : tooLarge(),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
router.handle('GetLogViewerLevelCount', async (ctx) => {
|
|
63
|
+
const range = rangeOf(ctx)
|
|
64
|
+
if (!(await logs.canView(range))) return tooLarge()
|
|
65
|
+
return Response.json(await logs.levelCounts(range))
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
router.handle('GetLogViewerMessageTemplate', async (ctx) => {
|
|
69
|
+
const range = rangeOf(ctx)
|
|
70
|
+
if (!(await logs.canView(range))) return tooLarge()
|
|
71
|
+
return page(ctx, await logs.templates(range))
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
router.handle('GetLogViewerLog', async (ctx) => {
|
|
75
|
+
const range = rangeOf(ctx)
|
|
76
|
+
if (!(await logs.canView(range))) return tooLarge()
|
|
77
|
+
const q = ctx.url.searchParams
|
|
78
|
+
const levels = q
|
|
79
|
+
.getAll('logLevel')
|
|
80
|
+
.filter((l): l is LogLevelName => LEVELS.includes(l as LogLevelName))
|
|
81
|
+
const entries = await logs.logs(range, {
|
|
82
|
+
filter: q.get('filterExpression'),
|
|
83
|
+
levels,
|
|
84
|
+
direction: q.get('orderDirection') === 'Ascending' ? 'Ascending' : 'Descending',
|
|
85
|
+
})
|
|
86
|
+
return page(ctx, entries)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
router.handle('GetLogViewerSavedSearch', async (ctx) => page(ctx, await logs.savedSearches()))
|
|
90
|
+
|
|
91
|
+
router.handle('GetLogViewerSavedSearchByName', async (ctx) => {
|
|
92
|
+
const found = await logs.savedSearch(ctx.params.name as string)
|
|
93
|
+
if (!found) return problemResponse(notFound('The saved search could not be found'))
|
|
94
|
+
return Response.json(found)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
router.handle('PostLogViewerSavedSearch', async (ctx) => {
|
|
98
|
+
const body = ((await ctx.request.json().catch(() => ({}))) ?? {}) as Record<string, unknown>
|
|
99
|
+
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
|
100
|
+
const query = typeof body.query === 'string' ? body.query : ''
|
|
101
|
+
if (!name) return problemResponse(problemDetails({ title: 'A name is required', status: 400 }))
|
|
102
|
+
if ((await logs.saveSearch(name, query)) === 'duplicate')
|
|
103
|
+
return problemResponse(
|
|
104
|
+
problemDetails({
|
|
105
|
+
title: 'Duplicate log search name',
|
|
106
|
+
detail: 'Another log search already exists with the same name.',
|
|
107
|
+
status: 400,
|
|
108
|
+
operationStatus: 'DuplicateLogSearch',
|
|
109
|
+
}),
|
|
110
|
+
)
|
|
111
|
+
return new Response(null, {
|
|
112
|
+
status: 201,
|
|
113
|
+
headers: {
|
|
114
|
+
location: `${ctx.url.origin}/umbraco/management/api/v1/log-viewer/saved-search/${encodeURIComponent(name)}`,
|
|
115
|
+
'umb-generated-resource': name,
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
router.handle('DeleteLogViewerSavedSearchByName', async (ctx) =>
|
|
121
|
+
(await logs.deleteSearch(ctx.params.name as string))
|
|
122
|
+
? new Response(null, { status: 200 })
|
|
123
|
+
: problemResponse(notFound('The saved search could not be found')),
|
|
124
|
+
)
|
|
125
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension discovery. `/public` is anonymous and feeds the login screen;
|
|
3
|
+
* `/private` requires a signed-in user and carries the rest.
|
|
4
|
+
*/
|
|
5
|
+
import type { ManifestPort } from '../ports.ts'
|
|
6
|
+
import type { ManagementApiRouter } from '../router.ts'
|
|
7
|
+
|
|
8
|
+
export function registerManifestHandlers(
|
|
9
|
+
router: ManagementApiRouter,
|
|
10
|
+
manifests: ManifestPort,
|
|
11
|
+
): void {
|
|
12
|
+
router.handle('GetManifestManifest', () => Response.json(manifests.list('all')))
|
|
13
|
+
router.handle('GetManifestManifestPublic', () => Response.json(manifests.list('public')))
|
|
14
|
+
router.handle('GetManifestManifestPrivate', () => Response.json(manifests.list('private')))
|
|
15
|
+
}
|