@startupjs-ui/file-input 0.2.0 → 0.2.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.
- package/CHANGELOG.md +11 -0
- package/README.mdx +31 -0
- package/files.plugin.d.ts +79 -0
- package/files.plugin.js +91 -9
- package/package.json +6 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,17 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
## [0.2.1](https://github.com/startupjs/startupjs-ui/compare/v0.2.0...v0.2.1) (2026-05-11)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **file-input:** provide server-side hooks for the 'files' plugin server config to check access to file operations and to transform the file during upload. Add types to the 'files' plugin to work with teamplay's type augmentation. ([bd527ad](https://github.com/startupjs/startupjs-ui/commit/bd527adcc2be0762af8439561cc39dcb44a7f991))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
6
17
|
# [0.2.0](https://github.com/startupjs/startupjs-ui/compare/v0.1.23...v0.2.0) (2026-05-04)
|
|
7
18
|
|
|
8
19
|
|
package/README.mdx
CHANGED
|
@@ -93,6 +93,37 @@ FileInput supports multiple storage backends. By default it uses SQLite, but if
|
|
|
93
93
|
- Requires `AZURE_BLOB_STORAGE_CONNECTION_STRING`.
|
|
94
94
|
- Install the Azure SDK: `yarn add @azure/storage-blob`.
|
|
95
95
|
|
|
96
|
+
## Server access hooks
|
|
97
|
+
|
|
98
|
+
By default, file URLs are public to anyone who knows the `fileId`. The `files` model is read-only for direct client access: reads are allowed, while create/update/delete are done through the upload and delete API routes.
|
|
99
|
+
|
|
100
|
+
Use `$file.getUrl()`, `$file.getDownloadUrl()`, `$.files.getUrl(fileId)`, or `$.files.getDownloadUrl(fileId)` to build file URLs. When StartupJS JWT auth is active and `$.session.token` exists, these helpers automatically append `access_token` to the URL so server `canRead` checks can see `req.session`.
|
|
101
|
+
|
|
102
|
+
Use plugin server options to add app-specific checks:
|
|
103
|
+
|
|
104
|
+
```js
|
|
105
|
+
export default {
|
|
106
|
+
plugins: {
|
|
107
|
+
files: {
|
|
108
|
+
server: {
|
|
109
|
+
canRead: ({ source, session, fileId, file }) => true,
|
|
110
|
+
canUpload: ({ session, fileId, file, meta, blob }) => true,
|
|
111
|
+
canDelete: ({ session, fileId, file }) => true,
|
|
112
|
+
transformUpload: ({ meta, blob }) => ({
|
|
113
|
+
meta: {
|
|
114
|
+
...meta,
|
|
115
|
+
filename: meta.filename?.toLowerCase()
|
|
116
|
+
},
|
|
117
|
+
blob
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`canRead` is reused for both file API reads (`source: 'api'`) and direct `$.files[fileId]` model reads (`source: 'model'`). Return `true` to allow the operation; any other value denies it. If you build file URLs manually, include your own auth token or use the model URL helpers above.
|
|
126
|
+
|
|
96
127
|
## Sandbox
|
|
97
128
|
|
|
98
129
|
<Sandbox
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { CollectionSpec, Signal, SignalModelConstructor } from 'startupjs'
|
|
2
|
+
|
|
3
|
+
export interface FileDoc {
|
|
4
|
+
storageType: string
|
|
5
|
+
mimeType: string
|
|
6
|
+
filename?: string
|
|
7
|
+
encoding?: string
|
|
8
|
+
extension?: string
|
|
9
|
+
createdAt: number
|
|
10
|
+
updatedAt: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface FileAccessContext {
|
|
14
|
+
source: 'api' | 'model'
|
|
15
|
+
session?: any
|
|
16
|
+
fileId: string
|
|
17
|
+
file?: FileDoc
|
|
18
|
+
req?: any
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface FileUploadContext {
|
|
22
|
+
source: 'api'
|
|
23
|
+
session?: any
|
|
24
|
+
fileId?: string
|
|
25
|
+
file?: FileDoc
|
|
26
|
+
req: any
|
|
27
|
+
blob: unknown
|
|
28
|
+
meta: Partial<FileDoc>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface FileDeleteContext {
|
|
32
|
+
source: 'api'
|
|
33
|
+
session?: any
|
|
34
|
+
fileId: string
|
|
35
|
+
file: FileDoc
|
|
36
|
+
req: any
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface FileUploadTransformResult {
|
|
40
|
+
fileId?: string
|
|
41
|
+
blob?: unknown
|
|
42
|
+
meta?: Partial<FileDoc>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface FilesPluginServerOptions {
|
|
46
|
+
canRead?: (context: FileAccessContext) => boolean | Promise<boolean>
|
|
47
|
+
canUpload?: (context: FileUploadContext) => boolean | Promise<boolean>
|
|
48
|
+
canDelete?: (context: FileDeleteContext) => boolean | Promise<boolean>
|
|
49
|
+
transformUpload?: (context: FileUploadContext) => FileUploadTransformResult | void | Promise<FileUploadTransformResult | void>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface FilesModel extends Signal<FileDoc[]> {
|
|
53
|
+
addNew (file: Omit<FileDoc, 'createdAt' | 'updatedAt'>): Promise<string>
|
|
54
|
+
getUrl (fileId: string, extension?: string): string
|
|
55
|
+
getDownloadUrl (fileId: string, extension?: string): string
|
|
56
|
+
getUploadUrl (fileId?: string): string
|
|
57
|
+
getDeleteUrl (fileId: string): string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface FileModel extends Signal<FileDoc> {
|
|
61
|
+
getUrl (): string
|
|
62
|
+
getDownloadUrl (): string
|
|
63
|
+
getUploadUrl (): string
|
|
64
|
+
getDeleteUrl (): string
|
|
65
|
+
getBlob (): Promise<Blob>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type FilesModelConstructor = SignalModelConstructor<FileDoc[], FilesModel>
|
|
69
|
+
type FileModelConstructor = SignalModelConstructor<FileDoc, FileModel>
|
|
70
|
+
|
|
71
|
+
declare module 'teamplay' {
|
|
72
|
+
interface TeamplayPluginCollections {
|
|
73
|
+
'@startupjs-ui/file-input/files': {
|
|
74
|
+
files: CollectionSpec<FileDoc, FilesModelConstructor, FileModelConstructor>
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export {}
|
package/files.plugin.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $, BASE_URL, serverOnly, Signal, sub } from 'startupjs'
|
|
1
|
+
import { $, BASE_URL, accessControl, serverOnly, Signal, sub } from 'startupjs'
|
|
2
2
|
import { createPlugin } from 'startupjs/registry'
|
|
3
3
|
import busboy from 'busboy'
|
|
4
4
|
import sharp from 'sharp'
|
|
@@ -10,14 +10,26 @@ export default createPlugin({
|
|
|
10
10
|
name: 'files',
|
|
11
11
|
enabled: true,
|
|
12
12
|
order: 'system ui',
|
|
13
|
-
isomorphic: () => ({
|
|
13
|
+
isomorphic: (_options, plugin) => ({
|
|
14
14
|
models: models => {
|
|
15
15
|
return {
|
|
16
16
|
...models,
|
|
17
17
|
files: {
|
|
18
18
|
default: FilesModel,
|
|
19
19
|
schema,
|
|
20
|
-
...models.files
|
|
20
|
+
...models.files,
|
|
21
|
+
access: accessControl(models.files?.access || {
|
|
22
|
+
read: ({ session, docId, doc }) => {
|
|
23
|
+
const canRead = getServerOptions(plugin).canRead
|
|
24
|
+
if (!canRead) return true
|
|
25
|
+
return canRead({
|
|
26
|
+
source: 'model',
|
|
27
|
+
session,
|
|
28
|
+
fileId: docId,
|
|
29
|
+
file: doc
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
}, { force: true })
|
|
21
33
|
},
|
|
22
34
|
'files.*': {
|
|
23
35
|
default: FileModel,
|
|
@@ -26,7 +38,7 @@ export default createPlugin({
|
|
|
26
38
|
}
|
|
27
39
|
}
|
|
28
40
|
}),
|
|
29
|
-
server: () => ({
|
|
41
|
+
server: (options = {}) => ({
|
|
30
42
|
serverRoutes: expressApp => {
|
|
31
43
|
expressApp.get(GET_FILE_URL, async (req, res) => {
|
|
32
44
|
let { fileId } = req.params
|
|
@@ -45,6 +57,13 @@ export default createPlugin({
|
|
|
45
57
|
if (!mimeType) return res.status(500).send(ERRORS.fileMimeTypeNotSet)
|
|
46
58
|
const isVideo = mimeType.startsWith('video/') || isVideoRequest
|
|
47
59
|
if (!storageType) return res.status(500).send(ERRORS.fileStorageTypeNotSet)
|
|
60
|
+
if (!await isAllowed(options.canRead, {
|
|
61
|
+
source: 'api',
|
|
62
|
+
req,
|
|
63
|
+
session: req.session,
|
|
64
|
+
fileId,
|
|
65
|
+
file
|
|
66
|
+
}, res)) return
|
|
48
67
|
|
|
49
68
|
// handle client-side caching of files
|
|
50
69
|
const clientEtag = req.get('If-None-Match')
|
|
@@ -218,7 +237,7 @@ export default createPlugin({
|
|
|
218
237
|
stream.on('data', data => buffers.push(data))
|
|
219
238
|
|
|
220
239
|
stream.on('end', async () => {
|
|
221
|
-
|
|
240
|
+
let blob = Buffer.concat(buffers)
|
|
222
241
|
meta = { filename, mimeType, encoding, storageType }
|
|
223
242
|
if (!blob) return res.status(500).send('No file was uploaded')
|
|
224
243
|
|
|
@@ -227,6 +246,27 @@ export default createPlugin({
|
|
|
227
246
|
const extension = meta.filename?.match(/\.([^.]+)$/)?.[1]
|
|
228
247
|
if (extension) meta.extension = extension
|
|
229
248
|
try {
|
|
249
|
+
const file = fileId ? (await sub($.files[fileId])).get() : undefined
|
|
250
|
+
const accessContext = {
|
|
251
|
+
source: 'api',
|
|
252
|
+
req,
|
|
253
|
+
session: req.session,
|
|
254
|
+
fileId,
|
|
255
|
+
file,
|
|
256
|
+
blob,
|
|
257
|
+
meta
|
|
258
|
+
}
|
|
259
|
+
if (!await isAllowed(options.canUpload, accessContext, res)) return
|
|
260
|
+
|
|
261
|
+
if (options.transformUpload) {
|
|
262
|
+
const transformed = await options.transformUpload(accessContext)
|
|
263
|
+
if (transformed) {
|
|
264
|
+
if ('fileId' in transformed) fileId = transformed.fileId
|
|
265
|
+
if ('blob' in transformed) blob = transformed.blob
|
|
266
|
+
if ('meta' in transformed) meta = transformed.meta
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
230
270
|
fileId = await uploadBuffer(blob, { fileId, meta })
|
|
231
271
|
} catch (err) {
|
|
232
272
|
console.error(err)
|
|
@@ -247,6 +287,13 @@ export default createPlugin({
|
|
|
247
287
|
if (!file) return res.status(404).send(ERRORS.fileNotFound)
|
|
248
288
|
const { storageType } = file
|
|
249
289
|
if (!storageType) return res.status(500).send(ERRORS.fileStorageTypeNotSet)
|
|
290
|
+
if (!await isAllowed(options.canDelete, {
|
|
291
|
+
source: 'api',
|
|
292
|
+
req,
|
|
293
|
+
session: req.session,
|
|
294
|
+
fileId,
|
|
295
|
+
file
|
|
296
|
+
}, res)) return
|
|
250
297
|
try {
|
|
251
298
|
await deleteFile(storageType, fileId)
|
|
252
299
|
await $file.del()
|
|
@@ -260,6 +307,23 @@ export default createPlugin({
|
|
|
260
307
|
})
|
|
261
308
|
})
|
|
262
309
|
|
|
310
|
+
function getServerOptions (plugin) {
|
|
311
|
+
return plugin.optionsByEnv?.server || {}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function isAllowed (hook, context, res) {
|
|
315
|
+
if (!hook) return true
|
|
316
|
+
try {
|
|
317
|
+
if (await hook(context)) return true
|
|
318
|
+
} catch (err) {
|
|
319
|
+
console.error(err)
|
|
320
|
+
res.status(500).send('Error checking file access')
|
|
321
|
+
return false
|
|
322
|
+
}
|
|
323
|
+
res.status(403).send(ERRORS.accessDenied)
|
|
324
|
+
return false
|
|
325
|
+
}
|
|
326
|
+
|
|
263
327
|
const schema = {
|
|
264
328
|
storageType: { type: 'string', required: true },
|
|
265
329
|
mimeType: { type: 'string', required: true },
|
|
@@ -284,11 +348,11 @@ class FilesModel extends Signal {
|
|
|
284
348
|
}
|
|
285
349
|
|
|
286
350
|
getUrl (fileId, extension) {
|
|
287
|
-
return
|
|
351
|
+
return getFileUrlWithAccessToken(fileId, extension)
|
|
288
352
|
}
|
|
289
353
|
|
|
290
354
|
getDownloadUrl (fileId, extension) {
|
|
291
|
-
return
|
|
355
|
+
return getFileUrlWithAccessToken(fileId, extension, { download: true })
|
|
292
356
|
}
|
|
293
357
|
|
|
294
358
|
getUploadUrl (fileId) {
|
|
@@ -302,11 +366,11 @@ class FilesModel extends Signal {
|
|
|
302
366
|
|
|
303
367
|
class FileModel extends Signal {
|
|
304
368
|
getUrl () {
|
|
305
|
-
return
|
|
369
|
+
return getFileUrlWithAccessToken(this.getId(), this.extension.get())
|
|
306
370
|
}
|
|
307
371
|
|
|
308
372
|
getDownloadUrl () {
|
|
309
|
-
return this.
|
|
373
|
+
return getFileUrlWithAccessToken(this.getId(), this.extension.get(), { download: true })
|
|
310
374
|
}
|
|
311
375
|
|
|
312
376
|
getUploadUrl () {
|
|
@@ -322,7 +386,25 @@ class FileModel extends Signal {
|
|
|
322
386
|
})
|
|
323
387
|
}
|
|
324
388
|
|
|
389
|
+
function getFileUrlWithAccessToken (fileId, extension, query = {}) {
|
|
390
|
+
const token = $.session.token.get()
|
|
391
|
+
return addQuery(BASE_URL + getFileUrl(fileId, extension), {
|
|
392
|
+
...query,
|
|
393
|
+
...(token ? { access_token: token } : {})
|
|
394
|
+
})
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function addQuery (url, query) {
|
|
398
|
+
const search = Object.entries(query)
|
|
399
|
+
.filter(([, value]) => value != null && value !== false)
|
|
400
|
+
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
|
|
401
|
+
.join('&')
|
|
402
|
+
if (!search) return url
|
|
403
|
+
return url + (url.includes('?') ? '&' : '?') + search
|
|
404
|
+
}
|
|
405
|
+
|
|
325
406
|
const ERRORS = {
|
|
407
|
+
accessDenied: 'Access denied',
|
|
326
408
|
fileNotFound: 'File not found',
|
|
327
409
|
fileMimeTypeNotSet: 'File mimeType is not set. This should never happen',
|
|
328
410
|
fileStorageTypeNotSet: 'File storageType is not set. This should never happen'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startupjs-ui/file-input",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
"./deleteFile": "./deleteFile.ts",
|
|
15
15
|
"./uploadFile": "./uploadFile.ts",
|
|
16
16
|
"./providers": "./providers/index.js",
|
|
17
|
-
"./files.plugin":
|
|
17
|
+
"./files.plugin": {
|
|
18
|
+
"types": "./files.plugin.d.ts",
|
|
19
|
+
"default": "./files.plugin.js"
|
|
20
|
+
}
|
|
18
21
|
},
|
|
19
22
|
"dependencies": {
|
|
20
23
|
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
|
@@ -46,5 +49,5 @@
|
|
|
46
49
|
"optional": true
|
|
47
50
|
}
|
|
48
51
|
},
|
|
49
|
-
"gitHead": "
|
|
52
|
+
"gitHead": "38511829e3e4f084aecc79ae563e83286e319249"
|
|
50
53
|
}
|