@ossy/resources 3.3.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 +6 -6
- package/src/ImageResource.jsx +11 -3
- package/src/PlatformFileField.jsx +120 -85
- package/src/PlatformReferenceField.jsx +216 -0
- package/src/ResourceContentPage.jsx +5 -2
- package/src/ResourceGrid.jsx +321 -0
- package/src/ResourceGrid.stories.jsx +95 -0
- package/src/ResourceList.jsx +78 -8
- package/src/ResourcePanel.jsx +19 -2
- package/src/ResourceSelectionCheckbox.jsx +68 -0
- package/src/ResourcesPage.jsx +3 -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 +4 -0
- package/src/markdown.component.jsx +20 -0
- package/src/markdown.schema.js +13 -0
- package/src/markdown.spec.js +43 -0
- package/src/patch-content.action.js +1 -0
- package/src/patch-content.task.js +28 -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,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Download helpers for workspace resources (`/r/{id}?download=1`).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const DIRECTORY_TYPES = new Set(['directory', '@ossy/platform/schema/directory'])
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {unknown} value
|
|
9
|
+
* @returns {boolean}
|
|
10
|
+
*/
|
|
11
|
+
export function isTruthyDownloadQuery (value) {
|
|
12
|
+
if (value === true || value === 1) return true
|
|
13
|
+
if (typeof value !== 'string') return false
|
|
14
|
+
const normalized = value.trim().toLowerCase()
|
|
15
|
+
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'download'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {{ query?: Record<string, unknown> } | null | undefined} req
|
|
20
|
+
* @returns {boolean}
|
|
21
|
+
*/
|
|
22
|
+
export function wantsAttachmentDownload (req) {
|
|
23
|
+
const query = req?.query ?? {}
|
|
24
|
+
return isTruthyDownloadQuery(query.download)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {object | null | undefined} resource
|
|
29
|
+
* @returns {boolean}
|
|
30
|
+
*/
|
|
31
|
+
export function isDirectoryResource (resource) {
|
|
32
|
+
return DIRECTORY_TYPES.has(resource?.type)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Non-directory resources can be downloaded (file bytes or JSON content).
|
|
37
|
+
*
|
|
38
|
+
* @param {object | null | undefined} resource
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
export function isDownloadableResource (resource) {
|
|
42
|
+
if (!resource?.id) return false
|
|
43
|
+
if (isDirectoryResource(resource)) return false
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* App-relative download URL for a resource.
|
|
49
|
+
*
|
|
50
|
+
* @param {string | { id?: string }} resourceOrId
|
|
51
|
+
* @returns {string | null}
|
|
52
|
+
*/
|
|
53
|
+
export function resourceDownloadHref (resourceOrId) {
|
|
54
|
+
const id = typeof resourceOrId === 'string' ? resourceOrId : resourceOrId?.id
|
|
55
|
+
if (!id) return null
|
|
56
|
+
return `/r/${encodeURIComponent(id)}?download=1`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Sanitize a filename for Content-Disposition (ASCII fallback).
|
|
61
|
+
*
|
|
62
|
+
* @param {string | null | undefined} name
|
|
63
|
+
* @param {{ fallback?: string, ensureExtension?: string }} [options]
|
|
64
|
+
* @returns {string}
|
|
65
|
+
*/
|
|
66
|
+
export function sanitizeDownloadFilename (name, { fallback = 'download', ensureExtension } = {}) {
|
|
67
|
+
let base = String(name || '')
|
|
68
|
+
.replace(/[\r\n\0\"\\]/g, '')
|
|
69
|
+
.replace(/[\/\\?%*:|"<>]/g, '-')
|
|
70
|
+
.trim()
|
|
71
|
+
if (!base) base = fallback
|
|
72
|
+
if (ensureExtension) {
|
|
73
|
+
const ext = ensureExtension.startsWith('.') ? ensureExtension : `.${ensureExtension}`
|
|
74
|
+
if (!base.toLowerCase().endsWith(ext.toLowerCase())) {
|
|
75
|
+
base = `${base}${ext}`
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return base.slice(0, 180)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Build a Content-Disposition attachment header value.
|
|
83
|
+
*
|
|
84
|
+
* @param {string | null | undefined} filename
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
export function contentDispositionAttachment (filename) {
|
|
88
|
+
const safe = sanitizeDownloadFilename(filename)
|
|
89
|
+
const encoded = encodeURIComponent(safe)
|
|
90
|
+
return `attachment; filename="${safe}"; filename*=UTF-8''${encoded}`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Filename used when streaming a download response.
|
|
95
|
+
*
|
|
96
|
+
* @param {object | null | undefined} resource
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
export function downloadFilenameForResource (resource) {
|
|
100
|
+
const hasFileKey = Boolean(resource?.content?.Key)
|
|
101
|
+
if (hasFileKey) {
|
|
102
|
+
return sanitizeDownloadFilename(resource?.name, { fallback: 'download' })
|
|
103
|
+
}
|
|
104
|
+
return sanitizeDownloadFilename(resource?.name, { fallback: 'resource', ensureExtension: '.json' })
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Trigger a browser download for a resource (client-side).
|
|
109
|
+
*
|
|
110
|
+
* @param {object | null | undefined} resource
|
|
111
|
+
* @returns {boolean} whether a download was started
|
|
112
|
+
*/
|
|
113
|
+
export function triggerResourceDownload (resource) {
|
|
114
|
+
if (typeof document === 'undefined') return false
|
|
115
|
+
if (!isDownloadableResource(resource)) return false
|
|
116
|
+
const href = resourceDownloadHref(resource)
|
|
117
|
+
if (!href) return false
|
|
118
|
+
const a = document.createElement('a')
|
|
119
|
+
a.href = href
|
|
120
|
+
a.download = downloadFilenameForResource(resource)
|
|
121
|
+
a.rel = 'noopener'
|
|
122
|
+
document.body.appendChild(a)
|
|
123
|
+
a.click()
|
|
124
|
+
a.remove()
|
|
125
|
+
return true
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Fetch a resource as a blob and trigger a local file download.
|
|
130
|
+
* Used for batch downloads so later files are not blocked after the user-gesture window ends.
|
|
131
|
+
*
|
|
132
|
+
* @param {object | null | undefined} resource
|
|
133
|
+
* @returns {Promise<boolean>}
|
|
134
|
+
*/
|
|
135
|
+
export async function triggerResourceBlobDownload (resource) {
|
|
136
|
+
if (typeof document === 'undefined' || typeof fetch !== 'function') return false
|
|
137
|
+
if (!isDownloadableResource(resource)) return false
|
|
138
|
+
const href = resourceDownloadHref(resource)
|
|
139
|
+
if (!href) return false
|
|
140
|
+
|
|
141
|
+
const response = await fetch(href, { credentials: 'same-origin' })
|
|
142
|
+
if (!response.ok) {
|
|
143
|
+
throw Object.assign(new Error(`Download failed (${response.status})`), { status: response.status })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const blob = await response.blob()
|
|
147
|
+
const objectUrl = URL.createObjectURL(blob)
|
|
148
|
+
try {
|
|
149
|
+
const a = document.createElement('a')
|
|
150
|
+
a.href = objectUrl
|
|
151
|
+
a.download = downloadFilenameForResource(resource)
|
|
152
|
+
a.rel = 'noopener'
|
|
153
|
+
document.body.appendChild(a)
|
|
154
|
+
a.click()
|
|
155
|
+
a.remove()
|
|
156
|
+
} finally {
|
|
157
|
+
// Keep the object URL alive briefly so the browser can start the download.
|
|
158
|
+
setTimeout(() => URL.revokeObjectURL(objectUrl), 2_000)
|
|
159
|
+
}
|
|
160
|
+
return true
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* @param {Array<object | null | undefined> | null | undefined} resources
|
|
165
|
+
* @returns {object[]}
|
|
166
|
+
*/
|
|
167
|
+
export function filterDownloadableResources (resources) {
|
|
168
|
+
return (resources || []).filter(isDownloadableResource)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Trigger browser downloads for many resources via blob fetches.
|
|
173
|
+
* Anchor-click staggering fails after the first file because browsers revoke
|
|
174
|
+
* the user-gesture token once the click handler yields.
|
|
175
|
+
*
|
|
176
|
+
* @param {Array<object | null | undefined> | null | undefined} resources
|
|
177
|
+
* @param {{ delayMs?: number }} [options]
|
|
178
|
+
* @returns {Promise<number>} number of downloads started
|
|
179
|
+
*/
|
|
180
|
+
export async function triggerResourceDownloads (resources, { delayMs = 300 } = {}) {
|
|
181
|
+
const downloadable = filterDownloadableResources(resources)
|
|
182
|
+
let started = 0
|
|
183
|
+
for (let i = 0; i < downloadable.length; i += 1) {
|
|
184
|
+
try {
|
|
185
|
+
if (await triggerResourceBlobDownload(downloadable[i])) started += 1
|
|
186
|
+
} catch (error) {
|
|
187
|
+
console.error('[resources] batch download failed', downloadable[i]?.id, error)
|
|
188
|
+
}
|
|
189
|
+
if (delayMs > 0 && i < downloadable.length - 1) {
|
|
190
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return started
|
|
194
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import {
|
|
3
|
+
contentDispositionAttachment,
|
|
4
|
+
downloadFilenameForResource,
|
|
5
|
+
filterDownloadableResources,
|
|
6
|
+
isDirectoryResource,
|
|
7
|
+
isDownloadableResource,
|
|
8
|
+
isTruthyDownloadQuery,
|
|
9
|
+
resourceDownloadHref,
|
|
10
|
+
sanitizeDownloadFilename,
|
|
11
|
+
wantsAttachmentDownload,
|
|
12
|
+
} from './resource-download.js'
|
|
13
|
+
|
|
14
|
+
describe('resource-download', () => {
|
|
15
|
+
describe('isTruthyDownloadQuery', () => {
|
|
16
|
+
it('accepts common truthy download flags', () => {
|
|
17
|
+
expect(isTruthyDownloadQuery('1')).toBe(true)
|
|
18
|
+
expect(isTruthyDownloadQuery('true')).toBe(true)
|
|
19
|
+
expect(isTruthyDownloadQuery('yes')).toBe(true)
|
|
20
|
+
expect(isTruthyDownloadQuery('download')).toBe(true)
|
|
21
|
+
expect(isTruthyDownloadQuery(true)).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('rejects falsy values', () => {
|
|
25
|
+
expect(isTruthyDownloadQuery('0')).toBe(false)
|
|
26
|
+
expect(isTruthyDownloadQuery('false')).toBe(false)
|
|
27
|
+
expect(isTruthyDownloadQuery('')).toBe(false)
|
|
28
|
+
expect(isTruthyDownloadQuery(undefined)).toBe(false)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('wantsAttachmentDownload', () => {
|
|
33
|
+
it('reads download from req.query', () => {
|
|
34
|
+
expect(wantsAttachmentDownload({ query: { download: '1' } })).toBe(true)
|
|
35
|
+
expect(wantsAttachmentDownload({ query: {} })).toBe(false)
|
|
36
|
+
expect(wantsAttachmentDownload(null)).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('resource classification', () => {
|
|
41
|
+
it('detects directories', () => {
|
|
42
|
+
expect(isDirectoryResource({ type: 'directory' })).toBe(true)
|
|
43
|
+
expect(isDirectoryResource({ type: '@ossy/platform/schema/directory' })).toBe(true)
|
|
44
|
+
expect(isDirectoryResource({ type: '@ossy/platform/schema/file' })).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('marks non-directory resources as downloadable', () => {
|
|
48
|
+
expect(isDownloadableResource({ id: 'abc', type: '@ossy/platform/schema/file' })).toBe(true)
|
|
49
|
+
expect(isDownloadableResource({ id: 'abc', type: 'directory' })).toBe(false)
|
|
50
|
+
expect(isDownloadableResource({ type: '@ossy/platform/schema/file' })).toBe(false)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('resourceDownloadHref', () => {
|
|
55
|
+
it('builds /r/{id}?download=1', () => {
|
|
56
|
+
expect(resourceDownloadHref('res_1')).toBe('/r/res_1?download=1')
|
|
57
|
+
expect(resourceDownloadHref({ id: 'res_2' })).toBe('/r/res_2?download=1')
|
|
58
|
+
expect(resourceDownloadHref({})).toBe(null)
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('filenames', () => {
|
|
63
|
+
it('sanitizes unsafe characters', () => {
|
|
64
|
+
expect(sanitizeDownloadFilename('a/b:c*.png')).toBe('a-b-c-.png')
|
|
65
|
+
expect(sanitizeDownloadFilename('evil\\name')).toBe('evilname')
|
|
66
|
+
expect(sanitizeDownloadFilename('')).toBe('download')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('adds .json for document resources without Key', () => {
|
|
70
|
+
expect(downloadFilenameForResource({ name: 'Author', content: { title: 'x' } }))
|
|
71
|
+
.toBe('Author.json')
|
|
72
|
+
expect(downloadFilenameForResource({ name: 'photo.png', content: { Key: 'abc' } }))
|
|
73
|
+
.toBe('photo.png')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('builds Content-Disposition attachment header', () => {
|
|
77
|
+
const header = contentDispositionAttachment('resume.pdf')
|
|
78
|
+
expect(header).toContain('attachment;')
|
|
79
|
+
expect(header).toContain('filename="resume.pdf"')
|
|
80
|
+
expect(header).toContain("filename*=UTF-8''resume.pdf")
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('batch helpers', () => {
|
|
85
|
+
it('filters downloadable resources', () => {
|
|
86
|
+
expect(filterDownloadableResources([
|
|
87
|
+
{ id: '1', type: '@ossy/platform/schema/file' },
|
|
88
|
+
{ id: '2', type: 'directory' },
|
|
89
|
+
{ type: '@ossy/platform/schema/file' },
|
|
90
|
+
]).map((r) => r.id)).toEqual(['1'])
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -3,6 +3,7 @@ import { StorageClient } from '@ossy/platform'
|
|
|
3
3
|
import { derivativeObjectKey } from '@ossy/platform/storage-keys'
|
|
4
4
|
import { Workspace } from '@ossy/workspaces/server'
|
|
5
5
|
import { attachResourceMediaUrls } from './resources.attach-media-urls.js'
|
|
6
|
+
import { attachResourceReferenceLinks } from './resources.attach-reference-links.js'
|
|
6
7
|
import { viewResource } from './resource-stream.helpers.js'
|
|
7
8
|
|
|
8
9
|
export function resolveResourceId({ payload, req } = {}) {
|
|
@@ -88,10 +89,13 @@ export async function loadResourceForRead(resourceId, req) {
|
|
|
88
89
|
|
|
89
90
|
await assertResourceAccess(resource, req)
|
|
90
91
|
|
|
91
|
-
|
|
92
|
+
const withMedia = await attachResourceMediaUrls(resource, {
|
|
92
93
|
userId: req?.userId,
|
|
93
94
|
workspaceId: req?.workspaceId,
|
|
94
95
|
})
|
|
96
|
+
return attachResourceReferenceLinks(withMedia, {
|
|
97
|
+
workspaceId: req?.workspaceId ?? resource.belongsTo,
|
|
98
|
+
})
|
|
95
99
|
}
|
|
96
100
|
|
|
97
101
|
/**
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-select helpers for storage resource lists (Cmd/Ctrl toggle, Shift range).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {MouseEvent | { metaKey?: boolean, ctrlKey?: boolean, shiftKey?: boolean } | null | undefined} event
|
|
7
|
+
* @returns {boolean}
|
|
8
|
+
*/
|
|
9
|
+
export function isToggleSelectModifier (event) {
|
|
10
|
+
return Boolean(event?.metaKey || event?.ctrlKey)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {MouseEvent | { shiftKey?: boolean } | null | undefined} event
|
|
15
|
+
* @returns {boolean}
|
|
16
|
+
*/
|
|
17
|
+
export function isRangeSelectModifier (event) {
|
|
18
|
+
return Boolean(event?.shiftKey)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {MouseEvent | { metaKey?: boolean, ctrlKey?: boolean, shiftKey?: boolean } | null | undefined} event
|
|
23
|
+
* @returns {boolean}
|
|
24
|
+
*/
|
|
25
|
+
export function isMultiSelectGesture (event) {
|
|
26
|
+
return isToggleSelectModifier(event) || isRangeSelectModifier(event)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Inclusive range of ids between two anchors in the visible ordered list.
|
|
31
|
+
*
|
|
32
|
+
* @param {string[]} orderedIds
|
|
33
|
+
* @param {string | null | undefined} fromId
|
|
34
|
+
* @param {string | null | undefined} toId
|
|
35
|
+
* @returns {string[]}
|
|
36
|
+
*/
|
|
37
|
+
export function idsInRange (orderedIds, fromId, toId) {
|
|
38
|
+
if (!orderedIds?.length || !toId) return toId ? [toId] : []
|
|
39
|
+
const end = orderedIds.indexOf(toId)
|
|
40
|
+
if (end < 0) return [toId]
|
|
41
|
+
const start = fromId ? orderedIds.indexOf(fromId) : end
|
|
42
|
+
if (start < 0) return [toId]
|
|
43
|
+
const from = Math.min(start, end)
|
|
44
|
+
const to = Math.max(start, end)
|
|
45
|
+
return orderedIds.slice(from, to + 1)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Apply a selection gesture to the current multi-select set.
|
|
50
|
+
*
|
|
51
|
+
* @param {{
|
|
52
|
+
* selectedIds: Iterable<string>,
|
|
53
|
+
* orderedIds: string[],
|
|
54
|
+
* targetId: string,
|
|
55
|
+
* event?: { metaKey?: boolean, ctrlKey?: boolean, shiftKey?: boolean } | null,
|
|
56
|
+
* anchorId?: string | null,
|
|
57
|
+
* }} params
|
|
58
|
+
* @returns {{ nextSelectedIds: Set<string>, nextAnchorId: string | null }}
|
|
59
|
+
*/
|
|
60
|
+
export function applyResourceSelectionGesture ({
|
|
61
|
+
selectedIds,
|
|
62
|
+
orderedIds,
|
|
63
|
+
targetId,
|
|
64
|
+
event,
|
|
65
|
+
anchorId = null,
|
|
66
|
+
}) {
|
|
67
|
+
const current = new Set(selectedIds || [])
|
|
68
|
+
|
|
69
|
+
if (isRangeSelectModifier(event)) {
|
|
70
|
+
const range = idsInRange(orderedIds, anchorId || targetId, targetId)
|
|
71
|
+
for (const id of range) current.add(id)
|
|
72
|
+
return {
|
|
73
|
+
nextSelectedIds: current,
|
|
74
|
+
nextAnchorId: anchorId || targetId,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (isToggleSelectModifier(event)) {
|
|
79
|
+
if (current.has(targetId)) current.delete(targetId)
|
|
80
|
+
else current.add(targetId)
|
|
81
|
+
return {
|
|
82
|
+
nextSelectedIds: current,
|
|
83
|
+
nextAnchorId: targetId,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
nextSelectedIds: new Set([targetId]),
|
|
89
|
+
nextAnchorId: targetId,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import {
|
|
3
|
+
applyResourceSelectionGesture,
|
|
4
|
+
idsInRange,
|
|
5
|
+
isMultiSelectGesture,
|
|
6
|
+
isRangeSelectModifier,
|
|
7
|
+
isToggleSelectModifier,
|
|
8
|
+
} from './resource-selection.js'
|
|
9
|
+
|
|
10
|
+
describe('resource-selection', () => {
|
|
11
|
+
const orderedIds = ['a', 'b', 'c', 'd']
|
|
12
|
+
|
|
13
|
+
describe('modifiers', () => {
|
|
14
|
+
it('detects toggle and range gestures', () => {
|
|
15
|
+
expect(isToggleSelectModifier({ metaKey: true })).toBe(true)
|
|
16
|
+
expect(isToggleSelectModifier({ ctrlKey: true })).toBe(true)
|
|
17
|
+
expect(isRangeSelectModifier({ shiftKey: true })).toBe(true)
|
|
18
|
+
expect(isMultiSelectGesture({ metaKey: true })).toBe(true)
|
|
19
|
+
expect(isMultiSelectGesture({ shiftKey: true })).toBe(true)
|
|
20
|
+
expect(isMultiSelectGesture({})).toBe(false)
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('idsInRange', () => {
|
|
25
|
+
it('returns inclusive slice between anchors', () => {
|
|
26
|
+
expect(idsInRange(orderedIds, 'b', 'd')).toEqual(['b', 'c', 'd'])
|
|
27
|
+
expect(idsInRange(orderedIds, 'd', 'b')).toEqual(['b', 'c', 'd'])
|
|
28
|
+
expect(idsInRange(orderedIds, null, 'c')).toEqual(['c'])
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('applyResourceSelectionGesture', () => {
|
|
33
|
+
it('replaces selection on plain click', () => {
|
|
34
|
+
const result = applyResourceSelectionGesture({
|
|
35
|
+
selectedIds: ['a', 'b'],
|
|
36
|
+
orderedIds,
|
|
37
|
+
targetId: 'c',
|
|
38
|
+
event: {},
|
|
39
|
+
anchorId: 'a',
|
|
40
|
+
})
|
|
41
|
+
expect([...result.nextSelectedIds]).toEqual(['c'])
|
|
42
|
+
expect(result.nextAnchorId).toBe('c')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('toggles with meta/ctrl', () => {
|
|
46
|
+
const add = applyResourceSelectionGesture({
|
|
47
|
+
selectedIds: ['a'],
|
|
48
|
+
orderedIds,
|
|
49
|
+
targetId: 'c',
|
|
50
|
+
event: { metaKey: true },
|
|
51
|
+
anchorId: 'a',
|
|
52
|
+
})
|
|
53
|
+
expect(new Set(add.nextSelectedIds)).toEqual(new Set(['a', 'c']))
|
|
54
|
+
|
|
55
|
+
const remove = applyResourceSelectionGesture({
|
|
56
|
+
selectedIds: ['a', 'c'],
|
|
57
|
+
orderedIds,
|
|
58
|
+
targetId: 'a',
|
|
59
|
+
event: { ctrlKey: true },
|
|
60
|
+
anchorId: 'c',
|
|
61
|
+
})
|
|
62
|
+
expect([...remove.nextSelectedIds]).toEqual(['c'])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('extends with shift from the anchor', () => {
|
|
66
|
+
const result = applyResourceSelectionGesture({
|
|
67
|
+
selectedIds: ['a'],
|
|
68
|
+
orderedIds,
|
|
69
|
+
targetId: 'c',
|
|
70
|
+
event: { shiftKey: true },
|
|
71
|
+
anchorId: 'a',
|
|
72
|
+
})
|
|
73
|
+
expect([...result.nextSelectedIds]).toEqual(['a', 'b', 'c'])
|
|
74
|
+
expect(result.nextAnchorId).toBe('a')
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Aggregate } from '@ossy/event-store'
|
|
2
|
+
import { Workspace } from '@ossy/workspaces/server'
|
|
3
|
+
import { viewResource } from './resource-stream.helpers.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Storage keys are `{resourceId}` or `{resourceId}:{variant}`.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} key
|
|
9
|
+
* @returns {string}
|
|
10
|
+
*/
|
|
11
|
+
export function resourceIdFromStorageKey(key) {
|
|
12
|
+
if (!key || typeof key !== 'string') {
|
|
13
|
+
throw Object.assign(new Error('Invalid storage key'), { status: 400 })
|
|
14
|
+
}
|
|
15
|
+
const colon = key.indexOf(':')
|
|
16
|
+
return colon === -1 ? key : key.slice(0, colon)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Assert the caller may PUT bytes for an existing resource (local-storage upload).
|
|
21
|
+
* Requires authenticated workspace membership — public access never grants upload.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} resource
|
|
24
|
+
* @param {{ userId?: string, workspaceId?: string }} req
|
|
25
|
+
*/
|
|
26
|
+
export async function assertResourceUploadAccess(resource, req) {
|
|
27
|
+
const userId = req?.userId
|
|
28
|
+
const workspaceId = req?.workspaceId
|
|
29
|
+
|
|
30
|
+
if (!userId || userId === 'anonymous') {
|
|
31
|
+
throw Object.assign(new Error('Unauthorized'), { status: 401 })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!workspaceId) {
|
|
35
|
+
throw Object.assign(new Error('Forbidden'), { status: 403 })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!resource?.id || resource.belongsTo !== workspaceId) {
|
|
39
|
+
throw Object.assign(new Error('Forbidden'), { status: 403 })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const workspace = await Aggregate.Of(Workspace, workspaceId).then(Aggregate.View())
|
|
43
|
+
if (!workspace?.users?.includes(userId)) {
|
|
44
|
+
throw Object.assign(new Error('Forbidden'), { status: 403 })
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Load resource for a storage key and assert upload permission.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} storageKey
|
|
52
|
+
* @param {{ userId?: string, workspaceId?: string }} req
|
|
53
|
+
* @returns {Promise<object>}
|
|
54
|
+
*/
|
|
55
|
+
export async function assertUploadAccessForStorageKey(storageKey, req) {
|
|
56
|
+
const resourceId = resourceIdFromStorageKey(storageKey)
|
|
57
|
+
const resource = await viewResource(resourceId)
|
|
58
|
+
if (!resource?.id) {
|
|
59
|
+
throw Object.assign(new Error('Resource not found'), { status: 404 })
|
|
60
|
+
}
|
|
61
|
+
await assertResourceUploadAccess(resource, req)
|
|
62
|
+
return resource
|
|
63
|
+
}
|
|
@@ -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}`),
|