@ossy/resources 3.4.0 → 3.5.1

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.
Files changed (37) hide show
  1. package/package.json +4 -4
  2. package/src/PlatformFileField.jsx +120 -85
  3. package/src/PlatformReferenceField.jsx +216 -0
  4. package/src/ResourceContentPage.jsx +2 -2
  5. package/src/ResourceGrid.jsx +44 -4
  6. package/src/ResourceList.jsx +38 -4
  7. package/src/ResourcePanel.jsx +19 -2
  8. package/src/ResourceSelectionCheckbox.jsx +68 -0
  9. package/src/SchemaDetailView.jsx +18 -18
  10. package/src/SchemaPresenter.jsx +6 -4
  11. package/src/Upload.jsx +28 -13
  12. package/src/create.task.js +4 -0
  13. package/src/en.translations.json +4 -1
  14. package/src/get-resource.api.js +15 -0
  15. package/src/index.js +2 -0
  16. package/src/markdown.component.jsx +20 -0
  17. package/src/markdown.schema.js +13 -0
  18. package/src/markdown.spec.js +43 -0
  19. package/src/platform-reference-field.component.jsx +5 -0
  20. package/src/reference-field.helpers.js +57 -0
  21. package/src/reference-field.helpers.spec.js +100 -0
  22. package/src/resource-download.js +194 -0
  23. package/src/resource-download.spec.js +93 -0
  24. package/src/resource-read.helpers.js +5 -1
  25. package/src/resource-selection.js +91 -0
  26. package/src/resource-selection.spec.js +77 -0
  27. package/src/resource-upload-access.js +63 -0
  28. package/src/resource-upload-access.spec.js +100 -0
  29. package/src/resource.helpers.js +30 -5
  30. package/src/resources.attach-reference-links.js +100 -0
  31. package/src/resources.attach-reference-links.spec.js +118 -0
  32. package/src/resources.validate-references.js +104 -0
  33. package/src/resources.validate-references.spec.js +182 -0
  34. package/src/schema-field-display.js +12 -0
  35. package/src/server.js +13 -0
  36. package/src/sv.translations.json +4 -1
  37. package/src/update-content.task.js +4 -0
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it, jest } from '@jest/globals'
2
+ import {
3
+ commitResourceValue,
4
+ resourceIdFromValue,
5
+ resourceIdsFromValue,
6
+ resourcePickerLabel,
7
+ sortResourcesForPicker,
8
+ toReferenceArray,
9
+ } from './reference-field.helpers.js'
10
+
11
+ describe('reference-field.helpers', () => {
12
+ describe('resourceIdFromValue', () => {
13
+ it('reads resourceId from a stored reference object', () => {
14
+ expect(resourceIdFromValue({ resourceId: 'abc' })).toBe('abc')
15
+ })
16
+
17
+ it('returns null for missing or invalid values', () => {
18
+ expect(resourceIdFromValue(null)).toBe(null)
19
+ expect(resourceIdFromValue('abc')).toBe(null)
20
+ expect(resourceIdFromValue({ resourceId: 1 })).toBe(null)
21
+ expect(resourceIdFromValue([{ resourceId: 'abc' }])).toBe(null)
22
+ })
23
+ })
24
+
25
+ describe('resourceIdsFromValue', () => {
26
+ it('reads resourceIds from a multi-reference array', () => {
27
+ expect(resourceIdsFromValue([{ resourceId: 'a' }, { resourceId: 'b' }])).toEqual(['a', 'b'])
28
+ })
29
+
30
+ it('skips invalid entries and non-arrays', () => {
31
+ expect(resourceIdsFromValue(null)).toEqual([])
32
+ expect(resourceIdsFromValue({ resourceId: 'a' })).toEqual([])
33
+ expect(resourceIdsFromValue([{ resourceId: 'a' }, null, { resourceId: 1 }, { resourceId: 'b' }])).toEqual([
34
+ 'a',
35
+ 'b',
36
+ ])
37
+ })
38
+ })
39
+
40
+ describe('toReferenceArray', () => {
41
+ it('maps ids to stored reference objects', () => {
42
+ expect(toReferenceArray(['a', 'b'])).toEqual([{ resourceId: 'a' }, { resourceId: 'b' }])
43
+ })
44
+ })
45
+
46
+ describe('resourcePickerLabel', () => {
47
+ it('prefers trimmed name over id', () => {
48
+ expect(resourcePickerLabel({ id: 'r1', name: ' Author ' })).toBe('Author')
49
+ })
50
+
51
+ it('falls back to id when name is empty', () => {
52
+ expect(resourcePickerLabel({ id: 'r1', name: ' ' })).toBe('r1')
53
+ expect(resourcePickerLabel({ id: 'r1' })).toBe('r1')
54
+ })
55
+ })
56
+
57
+ describe('sortResourcesForPicker', () => {
58
+ it('sorts by label case-insensitively', () => {
59
+ const sorted = sortResourcesForPicker([
60
+ { id: '2', name: 'zeta' },
61
+ { id: '1', name: 'Alpha' },
62
+ { id: '3', name: 'beta' },
63
+ ])
64
+ expect(sorted.map(r => r.id)).toEqual(['1', '3', '2'])
65
+ })
66
+
67
+ it('returns empty array for non-arrays', () => {
68
+ expect(sortResourcesForPicker(null)).toEqual([])
69
+ })
70
+ })
71
+
72
+ describe('commitResourceValue', () => {
73
+ it('emits a file-replace shaped change event', () => {
74
+ const onChange = jest.fn()
75
+ commitResourceValue('author', { resourceId: 'r1' }, onChange)
76
+ expect(onChange).toHaveBeenCalledWith({
77
+ target: {
78
+ id: 'author',
79
+ name: 'author',
80
+ dataset: { ossyFileResourceReplace: 'true' },
81
+ value: JSON.stringify({ resourceId: 'r1' }),
82
+ },
83
+ })
84
+ })
85
+
86
+ it('emits arrays for multi-reference values', () => {
87
+ const onChange = jest.fn()
88
+ const value = toReferenceArray(['a', 'b'])
89
+ commitResourceValue('authors', value, onChange)
90
+ expect(onChange).toHaveBeenCalledWith({
91
+ target: {
92
+ id: 'authors',
93
+ name: 'authors',
94
+ dataset: { ossyFileResourceReplace: 'true' },
95
+ value: JSON.stringify([{ resourceId: 'a' }, { resourceId: 'b' }]),
96
+ },
97
+ })
98
+ })
99
+ })
100
+ })
@@ -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
- return attachResourceMediaUrls(resource, {
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
+ }