@startupjs-ui/file-input 0.2.0 → 0.2.2

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 CHANGED
@@ -3,6 +3,28 @@
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.2](https://github.com/startupjs/startupjs-ui/compare/v0.2.1...v0.2.2) (2026-05-11)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+ * **file-input:** fix typings of the files plugin ([2bf0191](https://github.com/startupjs/startupjs-ui/commit/2bf0191c7b389982f0956925f27b28e38de6427d))
12
+
13
+
14
+
15
+
16
+
17
+ ## [0.2.1](https://github.com/startupjs/startupjs-ui/compare/v0.2.0...v0.2.1) (2026-05-11)
18
+
19
+
20
+ ### Features
21
+
22
+ * **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))
23
+
24
+
25
+
26
+
27
+
6
28
  # [0.2.0](https://github.com/startupjs/startupjs-ui/compare/v0.1.23...v0.2.0) (2026-05-04)
7
29
 
8
30
 
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,76 @@
1
+ import { Signal, type CollectionSpec } 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
+ export declare class 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
+ export declare class FileModel extends Signal<FileDoc> {
61
+ getUrl (): string
62
+ getDownloadUrl (): string
63
+ getUploadUrl (): string
64
+ getDeleteUrl (): string
65
+ getBlob (): Promise<Blob>
66
+ }
67
+
68
+ declare module 'teamplay' {
69
+ interface TeamplayPluginCollections {
70
+ '@startupjs-ui/file-input/files': {
71
+ files: CollectionSpec<FileDoc, typeof FilesModel, typeof FileModel>
72
+ }
73
+ }
74
+ }
75
+
76
+ 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
- const blob = Buffer.concat(buffers)
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 BASE_URL + getFileUrl(fileId, extension)
351
+ return getFileUrlWithAccessToken(fileId, extension)
288
352
  }
289
353
 
290
354
  getDownloadUrl (fileId, extension) {
291
- return BASE_URL + getFileUrl(fileId, extension) + '?download=true'
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 BASE_URL + getFileUrl(this.getId(), this.extension.get())
369
+ return getFileUrlWithAccessToken(this.getId(), this.extension.get())
306
370
  }
307
371
 
308
372
  getDownloadUrl () {
309
- return this.getUrl() + '?download=true'
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.0",
3
+ "version": "0.2.2",
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": "./files.plugin.js"
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": "0c586b841cba1c9d820542f6eca07470f5ea2659"
52
+ "gitHead": "1cbacbc3c6a919fc0ce6d3e2e335bcfe18a940d8"
50
53
  }