@ossy/resources 3.4.0 → 3.5.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/package.json +4 -4
- package/src/PlatformFileField.jsx +120 -85
- package/src/PlatformReferenceField.jsx +216 -0
- package/src/ResourceContentPage.jsx +2 -2
- package/src/ResourceGrid.jsx +44 -4
- package/src/ResourceList.jsx +38 -4
- package/src/ResourcePanel.jsx +19 -2
- package/src/ResourceSelectionCheckbox.jsx +68 -0
- package/src/SchemaDetailView.jsx +18 -18
- package/src/SchemaPresenter.jsx +6 -4
- package/src/Upload.jsx +28 -13
- package/src/create.task.js +4 -0
- package/src/en.translations.json +4 -1
- package/src/get-resource.api.js +15 -0
- package/src/index.js +2 -0
- package/src/markdown.component.jsx +20 -0
- package/src/markdown.schema.js +13 -0
- package/src/markdown.spec.js +43 -0
- package/src/platform-reference-field.component.jsx +5 -0
- package/src/reference-field.helpers.js +57 -0
- package/src/reference-field.helpers.spec.js +100 -0
- package/src/resource-download.js +194 -0
- package/src/resource-download.spec.js +93 -0
- package/src/resource-read.helpers.js +5 -1
- package/src/resource-selection.js +91 -0
- package/src/resource-selection.spec.js +77 -0
- package/src/resource-upload-access.js +63 -0
- package/src/resource-upload-access.spec.js +100 -0
- package/src/resource.helpers.js +30 -5
- package/src/resources.attach-reference-links.js +100 -0
- package/src/resources.attach-reference-links.spec.js +118 -0
- package/src/resources.validate-references.js +104 -0
- package/src/resources.validate-references.spec.js +182 -0
- package/src/schema-field-display.js +12 -0
- package/src/server.js +13 -0
- package/src/sv.translations.json +4 -1
- package/src/update-content.task.js +4 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
|
|
2
|
+
|
|
3
|
+
const viewResource = jest.fn()
|
|
4
|
+
const workspaceView = jest.fn()
|
|
5
|
+
|
|
6
|
+
jest.unstable_mockModule('./resource-stream.helpers.js', () => ({
|
|
7
|
+
viewResource,
|
|
8
|
+
}))
|
|
9
|
+
|
|
10
|
+
jest.unstable_mockModule('@ossy/event-store', () => ({
|
|
11
|
+
Aggregate: {
|
|
12
|
+
Of: jest.fn(async () => ({})),
|
|
13
|
+
View: () => workspaceView,
|
|
14
|
+
},
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
jest.unstable_mockModule('@ossy/workspaces/server', () => ({
|
|
18
|
+
Workspace: {},
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
const {
|
|
22
|
+
resourceIdFromStorageKey,
|
|
23
|
+
assertResourceUploadAccess,
|
|
24
|
+
assertUploadAccessForStorageKey,
|
|
25
|
+
} = await import('./resource-upload-access.js')
|
|
26
|
+
|
|
27
|
+
describe('resourceIdFromStorageKey', () => {
|
|
28
|
+
it('returns the resource id for original keys', () => {
|
|
29
|
+
expect(resourceIdFromStorageKey('abc123')).toBe('abc123')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('strips derivative variants', () => {
|
|
33
|
+
expect(resourceIdFromStorageKey('abc123:thumb')).toBe('abc123')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('rejects empty keys', () => {
|
|
37
|
+
expect(() => resourceIdFromStorageKey('')).toThrow(/Invalid storage key/)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('assertResourceUploadAccess', () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
workspaceView.mockReset()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('rejects anonymous callers', async () => {
|
|
47
|
+
await expect(
|
|
48
|
+
assertResourceUploadAccess({ id: 'r1', belongsTo: 'ws1' }, { userId: 'anonymous', workspaceId: 'ws1' }),
|
|
49
|
+
).rejects.toMatchObject({ status: 401 })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('rejects missing workspace context', async () => {
|
|
53
|
+
await expect(
|
|
54
|
+
assertResourceUploadAccess({ id: 'r1', belongsTo: 'ws1' }, { userId: 'u1' }),
|
|
55
|
+
).rejects.toMatchObject({ status: 403 })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('rejects cross-workspace resources', async () => {
|
|
59
|
+
await expect(
|
|
60
|
+
assertResourceUploadAccess({ id: 'r1', belongsTo: 'ws-other' }, { userId: 'u1', workspaceId: 'ws1' }),
|
|
61
|
+
).rejects.toMatchObject({ status: 403 })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('rejects non-members', async () => {
|
|
65
|
+
workspaceView.mockReturnValue({ users: ['someone-else'] })
|
|
66
|
+
await expect(
|
|
67
|
+
assertResourceUploadAccess({ id: 'r1', belongsTo: 'ws1' }, { userId: 'u1', workspaceId: 'ws1' }),
|
|
68
|
+
).rejects.toMatchObject({ status: 403 })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('allows workspace members', async () => {
|
|
72
|
+
workspaceView.mockReturnValue({ users: ['u1', 'u2'] })
|
|
73
|
+
await expect(
|
|
74
|
+
assertResourceUploadAccess({ id: 'r1', belongsTo: 'ws1' }, { userId: 'u1', workspaceId: 'ws1' }),
|
|
75
|
+
).resolves.toBeUndefined()
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('assertUploadAccessForStorageKey', () => {
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
viewResource.mockReset()
|
|
82
|
+
workspaceView.mockReset()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('returns 404 when the resource is missing', async () => {
|
|
86
|
+
viewResource.mockResolvedValue(null)
|
|
87
|
+
await expect(
|
|
88
|
+
assertUploadAccessForStorageKey('missing', { userId: 'u1', workspaceId: 'ws1' }),
|
|
89
|
+
).rejects.toMatchObject({ status: 404 })
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('loads the resource and asserts membership', async () => {
|
|
93
|
+
viewResource.mockResolvedValue({ id: 'abc', belongsTo: 'ws1' })
|
|
94
|
+
workspaceView.mockReturnValue({ users: ['u1'] })
|
|
95
|
+
await expect(
|
|
96
|
+
assertUploadAccessForStorageKey('abc:thumb', { userId: 'u1', workspaceId: 'ws1' }),
|
|
97
|
+
).resolves.toEqual({ id: 'abc', belongsTo: 'ws1' })
|
|
98
|
+
expect(viewResource).toHaveBeenCalledWith('abc')
|
|
99
|
+
})
|
|
100
|
+
})
|
package/src/resource.helpers.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isLocalStorageUploadUrl } from '@ossy/sdk'
|
|
1
2
|
import { cacheKey, normalizeLocation } from '@ossy/sdk-react'
|
|
2
3
|
import { metadata as CreateResource } from './create.action.js'
|
|
3
4
|
import { metadata as DeleteResource } from './delete.action.js'
|
|
@@ -7,6 +8,25 @@ import { metadata as UpdateResourceContent } from './update-content.action.js'
|
|
|
7
8
|
import { metadata as UpdateResourceLocation } from './update-location.action.js'
|
|
8
9
|
import { metadata as UpdateResourceName } from './update-name.action.js'
|
|
9
10
|
|
|
11
|
+
export {
|
|
12
|
+
downloadFilenameForResource,
|
|
13
|
+
filterDownloadableResources,
|
|
14
|
+
isDirectoryResource,
|
|
15
|
+
isDownloadableResource,
|
|
16
|
+
resourceDownloadHref,
|
|
17
|
+
triggerResourceDownload,
|
|
18
|
+
triggerResourceBlobDownload,
|
|
19
|
+
triggerResourceDownloads,
|
|
20
|
+
} from './resource-download.js'
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
applyResourceSelectionGesture,
|
|
24
|
+
idsInRange,
|
|
25
|
+
isMultiSelectGesture,
|
|
26
|
+
isRangeSelectModifier,
|
|
27
|
+
isToggleSelectModifier,
|
|
28
|
+
} from './resource-selection.js'
|
|
29
|
+
|
|
10
30
|
function invalidateLocation(sdk, location) {
|
|
11
31
|
sdk.invalidate(cacheKey(ListResources, { location: normalizeLocation(location) }))
|
|
12
32
|
}
|
|
@@ -41,11 +61,16 @@ export async function uploadFile(sdk, uploadLocation, file) {
|
|
|
41
61
|
size: file.size,
|
|
42
62
|
})
|
|
43
63
|
if (resource.content?.uploadUrl) {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
64
|
+
const uploadUrl = resource.content.uploadUrl
|
|
65
|
+
const headers = {}
|
|
66
|
+
if (file.type) headers['Content-Type'] = file.type
|
|
67
|
+
const init = { method: 'PUT', headers, body: file }
|
|
68
|
+
if (isLocalStorageUploadUrl(uploadUrl)) {
|
|
69
|
+
init.credentials = 'include'
|
|
70
|
+
if (sdk.workspaceId) headers.workspaceId = sdk.workspaceId
|
|
71
|
+
if (sdk.authorization) headers.Authorization = sdk.authorization
|
|
72
|
+
}
|
|
73
|
+
const response = await fetch(uploadUrl, init)
|
|
49
74
|
if (!response.ok) {
|
|
50
75
|
throw Object.assign(
|
|
51
76
|
new Error(`Upload failed: HTTP ${response.status}`),
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Aggregate } from '@ossy/event-store'
|
|
2
|
+
import { getPlatformSchema, schemaForWorkspace } from '@ossy/platform'
|
|
3
|
+
import { Workspace } from '@ossy/workspaces/server'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Public read path for a linked document resource.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} resourceId
|
|
9
|
+
* @returns {string}
|
|
10
|
+
*/
|
|
11
|
+
export function documentReferenceHref(resourceId) {
|
|
12
|
+
return `/r/${resourceId}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isResourceRef(value) {
|
|
16
|
+
return (
|
|
17
|
+
value !== null &&
|
|
18
|
+
typeof value === 'object' &&
|
|
19
|
+
!Array.isArray(value) &&
|
|
20
|
+
typeof value.resourceId === 'string' &&
|
|
21
|
+
value.resourceId.trim().length > 0
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function rewriteReferenceValue(value, max = 1) {
|
|
26
|
+
if (max === 1) {
|
|
27
|
+
if (!isResourceRef(value)) return value
|
|
28
|
+
return documentReferenceHref(value.resourceId.trim())
|
|
29
|
+
}
|
|
30
|
+
if (!Array.isArray(value)) return value
|
|
31
|
+
return value.map(item =>
|
|
32
|
+
isResourceRef(item) ? documentReferenceHref(item.resourceId.trim()) : item,
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve a schema engine for rewriting reference fields.
|
|
38
|
+
* Falls back to platform schemas when workspace is unavailable.
|
|
39
|
+
*
|
|
40
|
+
* @param {{ workspaceId?: string }} context
|
|
41
|
+
*/
|
|
42
|
+
async function resolveSchemaEngine({ workspaceId } = {}) {
|
|
43
|
+
try {
|
|
44
|
+
if (workspaceId) {
|
|
45
|
+
const workspace = await Aggregate.Of(Workspace, workspaceId).then(Aggregate.View())
|
|
46
|
+
return schemaForWorkspace(workspace?.schemas ?? [])
|
|
47
|
+
}
|
|
48
|
+
return getPlatformSchema()
|
|
49
|
+
} catch {
|
|
50
|
+
try {
|
|
51
|
+
return getPlatformSchema()
|
|
52
|
+
} catch {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Replace stored `{ resourceId }` reference field values with public document
|
|
60
|
+
* links (`/r/{id}`) for HTTP read responses. Action `get` keeps the stored shape
|
|
61
|
+
* for editing.
|
|
62
|
+
*
|
|
63
|
+
* @param {object} resource
|
|
64
|
+
* @param {{ workspaceId?: string }} [context]
|
|
65
|
+
* @returns {Promise<object>}
|
|
66
|
+
*/
|
|
67
|
+
export async function attachResourceReferenceLinks(resource, { workspaceId } = {}) {
|
|
68
|
+
if (!resource?.content || typeof resource.content !== 'object') return resource
|
|
69
|
+
if (!resource.type || typeof resource.type !== 'string') return resource
|
|
70
|
+
|
|
71
|
+
const engine = await resolveSchemaEngine({ workspaceId })
|
|
72
|
+
if (!engine?.has?.(resource.type)) return resource
|
|
73
|
+
|
|
74
|
+
let definition
|
|
75
|
+
try {
|
|
76
|
+
definition = engine.resolve(resource.type)
|
|
77
|
+
} catch {
|
|
78
|
+
return resource
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const fields = definition?.fields
|
|
82
|
+
if (!Array.isArray(fields) || !fields.length) return resource
|
|
83
|
+
|
|
84
|
+
let changed = false
|
|
85
|
+
const nextContent = { ...resource.content }
|
|
86
|
+
|
|
87
|
+
for (const field of fields) {
|
|
88
|
+
if (field?.type?.trim?.() !== 'reference') continue
|
|
89
|
+
const name = field.name
|
|
90
|
+
if (!name || !Object.prototype.hasOwnProperty.call(nextContent, name)) continue
|
|
91
|
+
|
|
92
|
+
const rewritten = rewriteReferenceValue(nextContent[name], field.max ?? 1)
|
|
93
|
+
if (rewritten !== nextContent[name]) {
|
|
94
|
+
nextContent[name] = rewritten
|
|
95
|
+
changed = true
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return changed ? { ...resource, content: nextContent } : resource
|
|
100
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, it, jest, beforeEach, afterEach } from '@jest/globals'
|
|
2
|
+
import { Schema } from '@ossy/schema'
|
|
3
|
+
|
|
4
|
+
const authorSchema = {
|
|
5
|
+
id: '@ossy/blog/schema/author',
|
|
6
|
+
name: 'Author',
|
|
7
|
+
fields: [{ name: 'name', type: 'text', required: true }],
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const postSchema = {
|
|
11
|
+
id: '@ossy/blog/schema/post',
|
|
12
|
+
name: 'Post',
|
|
13
|
+
fields: [
|
|
14
|
+
{ name: 'title', type: 'text', required: true },
|
|
15
|
+
{ name: 'author', type: 'reference', of: '@ossy/blog/schema/author', required: true },
|
|
16
|
+
{ name: 'cover', type: 'file' },
|
|
17
|
+
],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('attachResourceReferenceLinks', () => {
|
|
21
|
+
let attachResourceReferenceLinks
|
|
22
|
+
let documentReferenceHref
|
|
23
|
+
let getPlatformSchema
|
|
24
|
+
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
jest.resetModules()
|
|
27
|
+
getPlatformSchema = jest.fn(() => Schema.of({ schemas: [authorSchema, postSchema] }))
|
|
28
|
+
jest.unstable_mockModule('@ossy/platform', () => ({
|
|
29
|
+
getPlatformSchema,
|
|
30
|
+
schemaForWorkspace: schemas => Schema.of({ schemas: [...schemas, authorSchema, postSchema] }),
|
|
31
|
+
}))
|
|
32
|
+
jest.unstable_mockModule('@ossy/event-store', () => ({
|
|
33
|
+
Aggregate: {
|
|
34
|
+
Of: jest.fn(),
|
|
35
|
+
View: jest.fn(),
|
|
36
|
+
},
|
|
37
|
+
}))
|
|
38
|
+
jest.unstable_mockModule('@ossy/workspaces/server', () => ({
|
|
39
|
+
Workspace: {},
|
|
40
|
+
}))
|
|
41
|
+
|
|
42
|
+
;({
|
|
43
|
+
attachResourceReferenceLinks,
|
|
44
|
+
documentReferenceHref,
|
|
45
|
+
} = await import('./resources.attach-reference-links.js'))
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
jest.restoreAllMocks()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('builds document hrefs', () => {
|
|
53
|
+
expect(documentReferenceHref('abc')).toBe('/r/abc')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('rewrites reference fields to /r/{id} links and leaves file fields alone', async () => {
|
|
57
|
+
const resource = {
|
|
58
|
+
id: 'post-1',
|
|
59
|
+
type: '@ossy/blog/schema/post',
|
|
60
|
+
content: {
|
|
61
|
+
title: 'Hello',
|
|
62
|
+
author: { resourceId: 'author-9' },
|
|
63
|
+
cover: { resourceId: 'file-3' },
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const next = await attachResourceReferenceLinks(resource, {})
|
|
68
|
+
expect(next.content.author).toBe('/r/author-9')
|
|
69
|
+
expect(next.content.cover).toEqual({ resourceId: 'file-3' })
|
|
70
|
+
expect(next.content.title).toBe('Hello')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('rewrites multi-reference arrays to /r/{id} links', async () => {
|
|
74
|
+
getPlatformSchema.mockReturnValue(
|
|
75
|
+
Schema.of({
|
|
76
|
+
schemas: [
|
|
77
|
+
authorSchema,
|
|
78
|
+
{
|
|
79
|
+
id: '@ossy/blog/schema/post',
|
|
80
|
+
name: 'Post',
|
|
81
|
+
fields: [
|
|
82
|
+
{ name: 'title', type: 'text', required: true },
|
|
83
|
+
{
|
|
84
|
+
name: 'authors',
|
|
85
|
+
type: 'reference',
|
|
86
|
+
of: '@ossy/blog/schema/author',
|
|
87
|
+
max: 3,
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
}),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
const resource = {
|
|
96
|
+
id: 'post-2',
|
|
97
|
+
type: '@ossy/blog/schema/post',
|
|
98
|
+
content: {
|
|
99
|
+
title: 'Team',
|
|
100
|
+
authors: [{ resourceId: 'a1' }, { resourceId: 'a2' }],
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const next = await attachResourceReferenceLinks(resource, {})
|
|
105
|
+
expect(next.content.authors).toEqual(['/r/a1', '/r/a2'])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('is a no-op when schema is unknown', async () => {
|
|
109
|
+
getPlatformSchema.mockReturnValue(Schema.of({ schemas: [] }))
|
|
110
|
+
const resource = {
|
|
111
|
+
id: 'post-1',
|
|
112
|
+
type: '@ossy/blog/schema/post',
|
|
113
|
+
content: { author: { resourceId: 'author-9' } },
|
|
114
|
+
}
|
|
115
|
+
const next = await attachResourceReferenceLinks(resource, {})
|
|
116
|
+
expect(next).toBe(resource)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resourceIdFromValue,
|
|
3
|
+
resourceIdsFromValue,
|
|
4
|
+
} from './reference-field.helpers.js'
|
|
5
|
+
import { viewResource as defaultViewResource } from './resource-stream.helpers.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Collect reference field lookups from validated document content.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} schema
|
|
11
|
+
* @param {object} content
|
|
12
|
+
* @returns {Array<{ fieldName: string, of: string, resourceId: string, path: string }>}
|
|
13
|
+
*/
|
|
14
|
+
export function collectReferenceChecks(schema, content) {
|
|
15
|
+
const checks = []
|
|
16
|
+
const fields = schema?.fields
|
|
17
|
+
if (!Array.isArray(fields) || !content || typeof content !== 'object') return checks
|
|
18
|
+
|
|
19
|
+
const schemaLabel = typeof schema.id === 'string' && schema.id ? schema.id : 'schema'
|
|
20
|
+
|
|
21
|
+
for (const field of fields) {
|
|
22
|
+
if (field?.type?.trim?.() !== 'reference') continue
|
|
23
|
+
const name = field.name
|
|
24
|
+
const of = typeof field.of === 'string' ? field.of.trim() : ''
|
|
25
|
+
if (!name || !of) continue
|
|
26
|
+
if (!Object.prototype.hasOwnProperty.call(content, name)) continue
|
|
27
|
+
|
|
28
|
+
const value = content[name]
|
|
29
|
+
if (value === undefined || value === null) continue
|
|
30
|
+
|
|
31
|
+
const max = field.max ?? 1
|
|
32
|
+
const ids =
|
|
33
|
+
max === 1
|
|
34
|
+
? (() => {
|
|
35
|
+
const id = resourceIdFromValue(value)
|
|
36
|
+
return id ? [id] : []
|
|
37
|
+
})()
|
|
38
|
+
: resourceIdsFromValue(value)
|
|
39
|
+
|
|
40
|
+
for (const resourceId of ids) {
|
|
41
|
+
checks.push({
|
|
42
|
+
fieldName: name,
|
|
43
|
+
of,
|
|
44
|
+
resourceId,
|
|
45
|
+
path: `${schemaLabel}.${name}`,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return checks
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isRemoved(resource) {
|
|
54
|
+
return Array.isArray(resource?.status) && resource.status.includes('removed')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Ensure every `reference` field points at an existing workspace document of
|
|
59
|
+
* the declared `of` schema id. Shape checks stay in `@ossy/schema`; this runs
|
|
60
|
+
* on the write path after sync validation.
|
|
61
|
+
*
|
|
62
|
+
* @param {object} schema
|
|
63
|
+
* @param {object} content
|
|
64
|
+
* @param {{ workspaceId?: string, viewResource?: (id: string) => Promise<object|null> }} [options]
|
|
65
|
+
*/
|
|
66
|
+
export async function assertResourceReferencesExist(schema, content, options = {}) {
|
|
67
|
+
const viewResource = options.viewResource ?? defaultViewResource
|
|
68
|
+
const workspaceId = options.workspaceId
|
|
69
|
+
const checks = collectReferenceChecks(schema, content)
|
|
70
|
+
if (!checks.length) return
|
|
71
|
+
|
|
72
|
+
const results = await Promise.all(
|
|
73
|
+
checks.map(async check => {
|
|
74
|
+
let resource = null
|
|
75
|
+
try {
|
|
76
|
+
resource = await viewResource(check.resourceId)
|
|
77
|
+
} catch {
|
|
78
|
+
resource = null
|
|
79
|
+
}
|
|
80
|
+
return { check, resource }
|
|
81
|
+
}),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
for (const { check, resource } of results) {
|
|
85
|
+
const missing =
|
|
86
|
+
!resource?.id ||
|
|
87
|
+
isRemoved(resource) ||
|
|
88
|
+
(workspaceId && resource.belongsTo && resource.belongsTo !== workspaceId)
|
|
89
|
+
|
|
90
|
+
if (missing) {
|
|
91
|
+
throw Object.assign(
|
|
92
|
+
new Error(`${check.path} references missing resource "${check.resourceId}"`),
|
|
93
|
+
{ status: 400, type: 'REFERENCE_NOT_FOUND' },
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (resource.type !== check.of) {
|
|
98
|
+
throw Object.assign(
|
|
99
|
+
new Error(`${check.path} must reference a document of type "${check.of}"`),
|
|
100
|
+
{ status: 400, type: 'REFERENCE_TYPE_MISMATCH' },
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { describe, expect, it, jest } from '@jest/globals'
|
|
2
|
+
import {
|
|
3
|
+
assertResourceReferencesExist,
|
|
4
|
+
collectReferenceChecks,
|
|
5
|
+
} from './resources.validate-references.js'
|
|
6
|
+
|
|
7
|
+
const authorSchema = {
|
|
8
|
+
id: '@ossy/blog/schema/author',
|
|
9
|
+
name: 'Author',
|
|
10
|
+
fields: [{ name: 'name', type: 'text', required: true }],
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const postSchema = {
|
|
14
|
+
id: '@ossy/blog/schema/post',
|
|
15
|
+
name: 'Post',
|
|
16
|
+
fields: [
|
|
17
|
+
{ name: 'title', type: 'text', required: true },
|
|
18
|
+
{ name: 'author', type: 'reference', of: '@ossy/blog/schema/author', required: true },
|
|
19
|
+
{ name: 'related', type: 'reference', of: '@ossy/blog/schema/post', max: 3 },
|
|
20
|
+
{ name: 'cover', type: 'file' },
|
|
21
|
+
],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('collectReferenceChecks', () => {
|
|
25
|
+
it('collects single and multi reference ids and skips file fields', () => {
|
|
26
|
+
expect(
|
|
27
|
+
collectReferenceChecks(postSchema, {
|
|
28
|
+
title: 'Hello',
|
|
29
|
+
author: { resourceId: 'author-1' },
|
|
30
|
+
related: [{ resourceId: 'post-2' }, { resourceId: 'post-3' }],
|
|
31
|
+
cover: { resourceId: 'file-1' },
|
|
32
|
+
}),
|
|
33
|
+
).toEqual([
|
|
34
|
+
{
|
|
35
|
+
fieldName: 'author',
|
|
36
|
+
of: '@ossy/blog/schema/author',
|
|
37
|
+
resourceId: 'author-1',
|
|
38
|
+
path: '@ossy/blog/schema/post.author',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
fieldName: 'related',
|
|
42
|
+
of: '@ossy/blog/schema/post',
|
|
43
|
+
resourceId: 'post-2',
|
|
44
|
+
path: '@ossy/blog/schema/post.related',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
fieldName: 'related',
|
|
48
|
+
of: '@ossy/blog/schema/post',
|
|
49
|
+
resourceId: 'post-3',
|
|
50
|
+
path: '@ossy/blog/schema/post.related',
|
|
51
|
+
},
|
|
52
|
+
])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('returns no checks when content has no reference values', () => {
|
|
56
|
+
expect(collectReferenceChecks(postSchema, { title: 'Hello' })).toEqual([])
|
|
57
|
+
expect(collectReferenceChecks(authorSchema, { name: 'Ada' })).toEqual([])
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('assertResourceReferencesExist', () => {
|
|
62
|
+
it('resolves when referenced documents exist with matching type and workspace', async () => {
|
|
63
|
+
const viewResource = jest.fn(async id => {
|
|
64
|
+
if (id === 'author-1') {
|
|
65
|
+
return {
|
|
66
|
+
id: 'author-1',
|
|
67
|
+
type: '@ossy/blog/schema/author',
|
|
68
|
+
belongsTo: 'ws-1',
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return null
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
await expect(
|
|
75
|
+
assertResourceReferencesExist(
|
|
76
|
+
postSchema,
|
|
77
|
+
{ title: 'Hello', author: { resourceId: 'author-1' } },
|
|
78
|
+
{ workspaceId: 'ws-1', viewResource },
|
|
79
|
+
),
|
|
80
|
+
).resolves.toBeUndefined()
|
|
81
|
+
|
|
82
|
+
expect(viewResource).toHaveBeenCalledWith('author-1')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('rejects missing referenced resources', async () => {
|
|
86
|
+
const viewResource = jest.fn(async () => null)
|
|
87
|
+
|
|
88
|
+
await expect(
|
|
89
|
+
assertResourceReferencesExist(
|
|
90
|
+
postSchema,
|
|
91
|
+
{ title: 'Hello', author: { resourceId: 'missing' } },
|
|
92
|
+
{ workspaceId: 'ws-1', viewResource },
|
|
93
|
+
),
|
|
94
|
+
).rejects.toMatchObject({
|
|
95
|
+
status: 400,
|
|
96
|
+
type: 'REFERENCE_NOT_FOUND',
|
|
97
|
+
message: '@ossy/blog/schema/post.author references missing resource "missing"',
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('rejects deleted referenced resources', async () => {
|
|
102
|
+
const viewResource = jest.fn(async () => ({
|
|
103
|
+
status: ['removed'],
|
|
104
|
+
}))
|
|
105
|
+
|
|
106
|
+
await expect(
|
|
107
|
+
assertResourceReferencesExist(
|
|
108
|
+
postSchema,
|
|
109
|
+
{ title: 'Hello', author: { resourceId: 'gone' } },
|
|
110
|
+
{ viewResource },
|
|
111
|
+
),
|
|
112
|
+
).rejects.toMatchObject({ type: 'REFERENCE_NOT_FOUND' })
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('rejects references outside the workspace without leaking existence', async () => {
|
|
116
|
+
const viewResource = jest.fn(async () => ({
|
|
117
|
+
id: 'author-1',
|
|
118
|
+
type: '@ossy/blog/schema/author',
|
|
119
|
+
belongsTo: 'other-ws',
|
|
120
|
+
}))
|
|
121
|
+
|
|
122
|
+
await expect(
|
|
123
|
+
assertResourceReferencesExist(
|
|
124
|
+
postSchema,
|
|
125
|
+
{ title: 'Hello', author: { resourceId: 'author-1' } },
|
|
126
|
+
{ workspaceId: 'ws-1', viewResource },
|
|
127
|
+
),
|
|
128
|
+
).rejects.toMatchObject({ type: 'REFERENCE_NOT_FOUND' })
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('rejects references whose document type does not match of', async () => {
|
|
132
|
+
const viewResource = jest.fn(async () => ({
|
|
133
|
+
id: 'author-1',
|
|
134
|
+
type: '@ossy/blog/schema/post',
|
|
135
|
+
belongsTo: 'ws-1',
|
|
136
|
+
}))
|
|
137
|
+
|
|
138
|
+
await expect(
|
|
139
|
+
assertResourceReferencesExist(
|
|
140
|
+
postSchema,
|
|
141
|
+
{ title: 'Hello', author: { resourceId: 'author-1' } },
|
|
142
|
+
{ workspaceId: 'ws-1', viewResource },
|
|
143
|
+
),
|
|
144
|
+
).rejects.toMatchObject({
|
|
145
|
+
status: 400,
|
|
146
|
+
type: 'REFERENCE_TYPE_MISMATCH',
|
|
147
|
+
message: '@ossy/blog/schema/post.author must reference a document of type "@ossy/blog/schema/author"',
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('validates each id in a multi-reference field', async () => {
|
|
152
|
+
const viewResource = jest.fn(async id => {
|
|
153
|
+
if (id === 'post-2') {
|
|
154
|
+
return { id: 'post-2', type: '@ossy/blog/schema/post', belongsTo: 'ws-1' }
|
|
155
|
+
}
|
|
156
|
+
return null
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
await expect(
|
|
160
|
+
assertResourceReferencesExist(
|
|
161
|
+
postSchema,
|
|
162
|
+
{
|
|
163
|
+
title: 'Hello',
|
|
164
|
+
author: { resourceId: 'author-1' },
|
|
165
|
+
related: [{ resourceId: 'post-2' }, { resourceId: 'missing-post' }],
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
workspaceId: 'ws-1',
|
|
169
|
+
viewResource: async id => {
|
|
170
|
+
if (id === 'author-1') {
|
|
171
|
+
return { id: 'author-1', type: '@ossy/blog/schema/author', belongsTo: 'ws-1' }
|
|
172
|
+
}
|
|
173
|
+
return viewResource(id)
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
),
|
|
177
|
+
).rejects.toMatchObject({
|
|
178
|
+
type: 'REFERENCE_NOT_FOUND',
|
|
179
|
+
message: '@ossy/blog/schema/post.related references missing resource "missing-post"',
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a schema field value should render as Markdown in detail views.
|
|
3
|
+
*
|
|
4
|
+
* @param {{ type?: string } | null | undefined} field
|
|
5
|
+
* @param {unknown} value
|
|
6
|
+
* @returns {boolean}
|
|
7
|
+
*/
|
|
8
|
+
export function shouldRenderFieldAsMarkdown (field, value) {
|
|
9
|
+
return field?.type === 'richtext'
|
|
10
|
+
&& typeof value === 'string'
|
|
11
|
+
&& value.trim().length > 0
|
|
12
|
+
}
|
package/src/server.js
CHANGED
|
@@ -3,3 +3,16 @@ export * from './resource-stream.helpers.js'
|
|
|
3
3
|
export * from './resources.events.js'
|
|
4
4
|
export * from './resources.queries.js'
|
|
5
5
|
export { attachResourceMediaUrls } from './resources.attach-media-urls.js'
|
|
6
|
+
export {
|
|
7
|
+
attachResourceReferenceLinks,
|
|
8
|
+
documentReferenceHref,
|
|
9
|
+
} from './resources.attach-reference-links.js'
|
|
10
|
+
export {
|
|
11
|
+
assertResourceUploadAccess,
|
|
12
|
+
assertUploadAccessForStorageKey,
|
|
13
|
+
resourceIdFromStorageKey,
|
|
14
|
+
} from './resource-upload-access.js'
|
|
15
|
+
export {
|
|
16
|
+
assertResourceReferencesExist,
|
|
17
|
+
collectReferenceChecks,
|
|
18
|
+
} from './resources.validate-references.js'
|
package/src/sv.translations.json
CHANGED
|
@@ -45,5 +45,8 @@
|
|
|
45
45
|
"resources.createDirectory.namePlaceholder": "Mappnamn",
|
|
46
46
|
"resources.createDirectory.submit": "Skapa mapp",
|
|
47
47
|
"resources.createDirectory.errorEmpty": "Mappnamn får inte vara tomt",
|
|
48
|
-
"resources.createDirectory.errorOssyPrefix": "Mappnamn får inte börja med @ossy"
|
|
48
|
+
"resources.createDirectory.errorOssyPrefix": "Mappnamn får inte börja med @ossy",
|
|
49
|
+
"resources.panel.download": "Ladda ner",
|
|
50
|
+
"@ossy/resources/schema/markdown.body.label": "Innehåll",
|
|
51
|
+
"@ossy/resources/schema/markdown.body.description": "Markdown-innehåll (rubriker, listor, länkar, kod)."
|
|
49
52
|
}
|