@startupjs-ui/file-input 0.1.3
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 +22 -0
- package/README.mdx +82 -0
- package/constants.d.ts +8 -0
- package/constants.js +16 -0
- package/deleteFile.ts +16 -0
- package/files.plugin.js +360 -0
- package/index.d.ts +36 -0
- package/index.tsx +37 -0
- package/input.expo.tsx +101 -0
- package/input.tsx +12 -0
- package/package.json +45 -0
- package/providers/azureblob.js +158 -0
- package/providers/index.js +60 -0
- package/providers/mongo.js +187 -0
- package/providers/sqlite.js +113 -0
- package/uploadFile.ts +72 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Change Log
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
|
+
|
|
6
|
+
## [0.1.3](https://github.com/startupjs/startupjs-ui/compare/v0.1.2...v0.1.3) (2025-12-29)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @startupjs-ui/file-input
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## [0.1.2](https://github.com/startupjs/startupjs-ui/compare/v0.1.1...v0.1.2) (2025-12-29)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
* add mdx and docs packages. Refactor docs to get rid of any @startupjs/ui usage and use startupjs-ui instead ([703c926](https://github.com/startupjs/startupjs-ui/commit/703c92636efb0421ffd11783f692fc892b74018f))
|
|
20
|
+
* **docs:** support static web bundling (each route as a separate html page) ([5e4738c](https://github.com/startupjs/startupjs-ui/commit/5e4738c50157ae37e14d8dc36cb43d6a45a008ad))
|
|
21
|
+
* **file-input:** refactor FileInput component ([ab87527](https://github.com/startupjs/startupjs-ui/commit/ab87527a6a9dba34b3106034cc33d311f88f8e93))
|
|
22
|
+
* **file-input:** update example ([e019ed2](https://github.com/startupjs/startupjs-ui/commit/e019ed26886d7b7e9d499e76e3c1eca802864774))
|
package/README.mdx
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { $, useSub } from 'startupjs'
|
|
3
|
+
import FileInput, { _PropsJsonSchema as FileInputPropsJsonSchema } from './index'
|
|
4
|
+
import Div from '@startupjs-ui/div'
|
|
5
|
+
import Avatar from '@startupjs-ui/avatar'
|
|
6
|
+
import Span from '@startupjs-ui/span'
|
|
7
|
+
import { Sandbox } from '@startupjs-ui/docs'
|
|
8
|
+
|
|
9
|
+
# FileInput
|
|
10
|
+
|
|
11
|
+
FileInput lets users pick a file (or image) and upload it to your StartupJS backend. The `value` is a `fileId` pointing to a document in the `files` model.
|
|
12
|
+
|
|
13
|
+
```jsx
|
|
14
|
+
import { FileInput } from 'startupjs-ui'
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Basic usage
|
|
18
|
+
|
|
19
|
+
```jsx example
|
|
20
|
+
const [fileId, setFileId] = useState()
|
|
21
|
+
return (
|
|
22
|
+
<FileInput
|
|
23
|
+
value={fileId}
|
|
24
|
+
onChange={setFileId}
|
|
25
|
+
/>
|
|
26
|
+
)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Image picker
|
|
30
|
+
|
|
31
|
+
```jsx
|
|
32
|
+
const $avatarFileId = $()
|
|
33
|
+
const $file = useSub($.files[$avatarFileId.get() ?? '__DUMMY__'])
|
|
34
|
+
return (
|
|
35
|
+
<Div gap>
|
|
36
|
+
<Span>Your avatar:</Span>
|
|
37
|
+
{
|
|
38
|
+
$avatarFileId.get()
|
|
39
|
+
? <Avatar src={$file.getUrl() + '?' + $file.updatedAt.get()}>{name}</Avatar>
|
|
40
|
+
: <Span italic>No avatar uploaded</Span>
|
|
41
|
+
}
|
|
42
|
+
<FileInput
|
|
43
|
+
image
|
|
44
|
+
value={$avatarFileId.get()}
|
|
45
|
+
onChange={fileId => $avatarFileId.set(fileId)}
|
|
46
|
+
/>
|
|
47
|
+
</Div>
|
|
48
|
+
)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**Note:** the trick with appending `?` and `updatedAt` timestamp to the URL is optional. It might be needed in some cases (like on the page of changing the profile picture of a user) to force reloading the image after it has been changed, otherwise the cached version may be shown in the browser.
|
|
52
|
+
|
|
53
|
+
## Storage providers
|
|
54
|
+
|
|
55
|
+
FileInput supports multiple storage backends. By default it uses SQLite, but if `MONGO_URL` is configured (MongoDB is used by StartupJS), the default storage becomes MongoDB (GridFS). You can force a specific provider with `DEFAULT_STORAGE_TYPE`.
|
|
56
|
+
|
|
57
|
+
### SQLite (default)
|
|
58
|
+
|
|
59
|
+
- Default when no `MONGO_URL` is configured.
|
|
60
|
+
- Force with `DEFAULT_STORAGE_TYPE=sqlite`.
|
|
61
|
+
|
|
62
|
+
### MongoDB (GridFS)
|
|
63
|
+
|
|
64
|
+
- Default when `MONGO_URL` is configured.
|
|
65
|
+
- Force with `DEFAULT_STORAGE_TYPE=mongo`.
|
|
66
|
+
|
|
67
|
+
### Azure Blob Storage
|
|
68
|
+
|
|
69
|
+
- Force with `DEFAULT_STORAGE_TYPE=azureblob`.
|
|
70
|
+
- Requires `AZURE_BLOB_STORAGE_CONNECTION_STRING`.
|
|
71
|
+
- Install the Azure SDK: `yarn add @azure/storage-blob`.
|
|
72
|
+
|
|
73
|
+
## Sandbox
|
|
74
|
+
|
|
75
|
+
<Sandbox
|
|
76
|
+
Component={FileInput}
|
|
77
|
+
propsJsonSchema={FileInputPropsJsonSchema}
|
|
78
|
+
props={{
|
|
79
|
+
onChange: fileId => alert('Uploaded fileId: ' + fileId)
|
|
80
|
+
}}
|
|
81
|
+
block
|
|
82
|
+
/>
|
package/constants.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const GET_FILE_URL: string
|
|
2
|
+
export function getFileUrl (id: string, extension?: string): string
|
|
3
|
+
|
|
4
|
+
export const UPLOAD_SINGLE_FILE_URL: string
|
|
5
|
+
export function getUploadFileUrl (id?: string): string
|
|
6
|
+
|
|
7
|
+
export const DELETE_FILE_URL: string
|
|
8
|
+
export function getDeleteFileUrl (id: string): string
|
package/constants.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const GET_FILE_URL = '/api/__ui__/files/get/:fileId'
|
|
2
|
+
export const getFileUrl = (id, extension) => {
|
|
3
|
+
if (!id) throw Error('[ui/FileInput] getFileUrl: fileId is required')
|
|
4
|
+
return GET_FILE_URL.replace(':fileId', id) + (extension ? `.${extension}` : '')
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const UPLOAD_SINGLE_FILE_URL = '/api/__ui__/files/upload/single/:fileId?'
|
|
8
|
+
export const getUploadFileUrl = id => {
|
|
9
|
+
return UPLOAD_SINGLE_FILE_URL.replace('/:fileId?', id ? `/${id}` : '')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const DELETE_FILE_URL = '/api/__ui__/files/delete/:fileId'
|
|
13
|
+
export const getDeleteFileUrl = id => {
|
|
14
|
+
if (!id) throw Error('[ui/FileInput] getDeleteFileUrl: fileId is required')
|
|
15
|
+
return DELETE_FILE_URL.replace(':fileId', id)
|
|
16
|
+
}
|
package/deleteFile.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { axios, BASE_URL } from 'startupjs'
|
|
2
|
+
import alert from '@startupjs-ui/dialogs/alert'
|
|
3
|
+
import { getDeleteFileUrl } from './constants.js'
|
|
4
|
+
|
|
5
|
+
export default async function deleteFile (fileId?: string): Promise<boolean | undefined> {
|
|
6
|
+
if (!fileId) return
|
|
7
|
+
try {
|
|
8
|
+
const res = await axios.post(BASE_URL + getDeleteFileUrl(fileId))
|
|
9
|
+
fileId = res.data?.fileId
|
|
10
|
+
if (!fileId) throw Error('File delete failed. No deleted fileId returned from server')
|
|
11
|
+
return true
|
|
12
|
+
} catch (err) {
|
|
13
|
+
console.error(err)
|
|
14
|
+
await alert('Error deleting file')
|
|
15
|
+
}
|
|
16
|
+
}
|
package/files.plugin.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { $, BASE_URL, serverOnly, Signal, sub } from 'startupjs'
|
|
2
|
+
import { createPlugin } from 'startupjs/registry'
|
|
3
|
+
import busboy from 'busboy'
|
|
4
|
+
import sharp from 'sharp'
|
|
5
|
+
import { DELETE_FILE_URL, GET_FILE_URL, getDeleteFileUrl, getFileUrl, getUploadFileUrl, UPLOAD_SINGLE_FILE_URL } from './constants.js'
|
|
6
|
+
import { deleteFile, getDefaultStorageType, getFileBlob, getFileSize, saveFileBlob } from './providers/index.js'
|
|
7
|
+
|
|
8
|
+
export default createPlugin({
|
|
9
|
+
name: 'files',
|
|
10
|
+
enabled: true,
|
|
11
|
+
order: 'system ui',
|
|
12
|
+
isomorphic: () => ({
|
|
13
|
+
models: models => {
|
|
14
|
+
return {
|
|
15
|
+
...models,
|
|
16
|
+
files: {
|
|
17
|
+
default: FilesModel,
|
|
18
|
+
schema,
|
|
19
|
+
...models.files
|
|
20
|
+
},
|
|
21
|
+
'files.*': {
|
|
22
|
+
default: FileModel,
|
|
23
|
+
...models['files.*']
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}),
|
|
28
|
+
server: () => ({
|
|
29
|
+
serverRoutes: expressApp => {
|
|
30
|
+
expressApp.get(GET_FILE_URL, async (req, res) => {
|
|
31
|
+
let { fileId } = req.params
|
|
32
|
+
|
|
33
|
+
// Extract video file detection early for Range support
|
|
34
|
+
const isVideoRequest = req.url.includes('.mp4') || req.url.includes('.mov') || req.url.includes('.avi')
|
|
35
|
+
// if id has extension, remove it
|
|
36
|
+
// (extension is sometimes added for client libraries to properly handle the file)
|
|
37
|
+
fileId = fileId.replace(/\\.[^.]+$/, '')
|
|
38
|
+
// url might have ?download=true which means we should force download
|
|
39
|
+
const download = (req.query?.download != null)
|
|
40
|
+
const $file = await sub($.files[fileId])
|
|
41
|
+
const file = $file.get()
|
|
42
|
+
if (!file) return res.status(404).send(ERRORS.fileNotFound)
|
|
43
|
+
const { mimeType, storageType, filename, updatedAt } = file
|
|
44
|
+
if (!mimeType) return res.status(500).send(ERRORS.fileMimeTypeNotSet)
|
|
45
|
+
const isVideo = mimeType.startsWith('video/') || isVideoRequest
|
|
46
|
+
if (!storageType) return res.status(500).send(ERRORS.fileStorageTypeNotSet)
|
|
47
|
+
|
|
48
|
+
// handle client-side caching of files
|
|
49
|
+
const clientEtag = req.get('If-None-Match')
|
|
50
|
+
const etag = `"${updatedAt}"`
|
|
51
|
+
// lastModified and ifModifiedSince both use UTC time with seconds precision
|
|
52
|
+
const ifModifiedSince = req.get('If-Modified-Since')
|
|
53
|
+
const lastModified = new Date(updatedAt).toUTCString()
|
|
54
|
+
|
|
55
|
+
function setCacheHeaders () {
|
|
56
|
+
res.setHeader('Etag', etag)
|
|
57
|
+
res.setHeader('Last-Modified', lastModified)
|
|
58
|
+
if (process.env.NODE_ENV === 'production') {
|
|
59
|
+
res.setHeader('Cache-Control', `public, max-age=${5 * 60}`) // cache on client for 5 mins
|
|
60
|
+
} else {
|
|
61
|
+
res.setHeader('Cache-Control', 'no-cache') // always validate cache in development
|
|
62
|
+
}
|
|
63
|
+
// the following headers are set by expo (metro) dev server.
|
|
64
|
+
// We don't want them since we're setting our own cache headers
|
|
65
|
+
// and a single Cache-Control header fully replaces them.
|
|
66
|
+
res.removeHeader('Pragma')
|
|
67
|
+
res.removeHeader('Surrogate-Control')
|
|
68
|
+
res.removeHeader('Expires')
|
|
69
|
+
|
|
70
|
+
// Add Range support for video files (required for iOS AVPlayer)
|
|
71
|
+
if (isVideo) {
|
|
72
|
+
res.setHeader('Accept-Ranges', 'bytes')
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (
|
|
77
|
+
clientEtag === etag ||
|
|
78
|
+
(ifModifiedSince && +new Date(ifModifiedSince) >= +new Date(lastModified))
|
|
79
|
+
) {
|
|
80
|
+
setCacheHeaders()
|
|
81
|
+
return res.status(304).send() // Not Modified
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
// Performance optimization: True streaming for Range requests
|
|
86
|
+
if (isVideo && req.headers.range) {
|
|
87
|
+
const range = req.headers.range
|
|
88
|
+
console.log('[StartupJS Files] Processing Range request with TRUE streaming:', { fileId, range })
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const parts = range.replace(/bytes=/, '').split('-')
|
|
92
|
+
const start = parseInt(parts[0], 10) || 0
|
|
93
|
+
|
|
94
|
+
// Get file size efficiently without loading full file
|
|
95
|
+
const fileSize = await getFileSize(storageType, fileId)
|
|
96
|
+
let end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1
|
|
97
|
+
|
|
98
|
+
console.log('[StartupJS Files] Processing range:', { range, start, end, fileSize, parts })
|
|
99
|
+
|
|
100
|
+
// Fix off-by-one error in range validation
|
|
101
|
+
if (start >= fileSize || start > end) {
|
|
102
|
+
console.log('[StartupJS Files] Invalid range:', { start, end, fileSize })
|
|
103
|
+
res.status(416)
|
|
104
|
+
res.setHeader('Content-Range', `bytes */${fileSize}`)
|
|
105
|
+
return res.send('Range Not Satisfiable')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Adjust end to file bounds
|
|
109
|
+
if (end >= fileSize) {
|
|
110
|
+
end = fileSize - 1
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// TRUE STREAMING: get only the requested range from storage
|
|
114
|
+
const rangeBlob = await getFileBlob(storageType, fileId, { start, end })
|
|
115
|
+
|
|
116
|
+
// Handle empty responses from MongoDB GridFS
|
|
117
|
+
// This can happen for the last byte due to GridFS chunking behavior
|
|
118
|
+
if (rangeBlob.length === 0) {
|
|
119
|
+
// For the last byte, return a fake byte to satisfy video players
|
|
120
|
+
// HTTP 416 for last byte can prevent video playback entirely
|
|
121
|
+
if (start === fileSize - 1) {
|
|
122
|
+
console.log('[StartupJS Files] Last byte unavailable, returning fake byte for video compatibility:', { start, end, fileSize })
|
|
123
|
+
res.status(206)
|
|
124
|
+
res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`)
|
|
125
|
+
res.setHeader('Content-Length', '1')
|
|
126
|
+
res.type(mimeType)
|
|
127
|
+
setCacheHeaders()
|
|
128
|
+
return res.send(Buffer.from([0x00])) // Fake last byte for video compatibility
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
console.log('[StartupJS Files] Empty response from GridFS, returning 416:', { start, end, fileSize })
|
|
132
|
+
res.status(416)
|
|
133
|
+
res.setHeader('Content-Range', `bytes */${fileSize}`)
|
|
134
|
+
return res.send('Range Not Satisfiable')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const chunksize = (end - start) + 1
|
|
138
|
+
|
|
139
|
+
res.status(206) // Partial Content
|
|
140
|
+
res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`)
|
|
141
|
+
res.setHeader('Content-Length', rangeBlob.length.toString())
|
|
142
|
+
res.type(mimeType)
|
|
143
|
+
|
|
144
|
+
if (download) res.setHeader('Content-Disposition', `attachment; filename="${filename}"`)
|
|
145
|
+
setCacheHeaders()
|
|
146
|
+
|
|
147
|
+
console.log('[StartupJS Files] Sending TRUE streaming response:', {
|
|
148
|
+
start,
|
|
149
|
+
end,
|
|
150
|
+
chunksize,
|
|
151
|
+
totalSize: fileSize,
|
|
152
|
+
actualChunkSize: rangeBlob.length,
|
|
153
|
+
contentLength: rangeBlob.length.toString(),
|
|
154
|
+
isLastByte: start === fileSize - 1
|
|
155
|
+
})
|
|
156
|
+
return res.send(rangeBlob)
|
|
157
|
+
} catch (err) {
|
|
158
|
+
console.error('[StartupJS Files] Range request error:', err)
|
|
159
|
+
// Fallback to full file if range processing fails
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Load file for non-Range requests (download functionality preserved)
|
|
164
|
+
const blob = await getFileBlob(storageType, fileId)
|
|
165
|
+
const fileBuffer = (blob instanceof Buffer) ? blob : Buffer.from(blob) // avoid unnecessary copy
|
|
166
|
+
|
|
167
|
+
// set the Content-Type header
|
|
168
|
+
res.type(mimeType)
|
|
169
|
+
|
|
170
|
+
// force the file to be downloaded by setting the Content-Disposition header
|
|
171
|
+
if (download) res.setHeader('Content-Disposition', `attachment; filename="${filename}"`)
|
|
172
|
+
|
|
173
|
+
setCacheHeaders()
|
|
174
|
+
|
|
175
|
+
// send the actual file
|
|
176
|
+
res.send(fileBuffer)
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.error(err)
|
|
179
|
+
res.status(500).send('Error getting file')
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// this handles both creating and updating a file
|
|
184
|
+
expressApp.post(UPLOAD_SINGLE_FILE_URL, async (req, res) => {
|
|
185
|
+
let { fileId, storageType } = req.params
|
|
186
|
+
try {
|
|
187
|
+
storageType ??= await getDefaultStorageType()
|
|
188
|
+
} catch (err) {
|
|
189
|
+
console.error(err)
|
|
190
|
+
return res.status(500).send('Error getting default storage type')
|
|
191
|
+
}
|
|
192
|
+
const bb = busboy({ headers: req.headers })
|
|
193
|
+
|
|
194
|
+
let blob
|
|
195
|
+
let meta
|
|
196
|
+
bb.on('file', (fieldname, file, { filename, mimeType, encoding }) => {
|
|
197
|
+
if (blob) return res.status(500).send('Only one file is allowed')
|
|
198
|
+
|
|
199
|
+
const buffers = []
|
|
200
|
+
let stream = file
|
|
201
|
+
|
|
202
|
+
if (mimeType.startsWith('image/')) {
|
|
203
|
+
// If it's an image, pipe it through sharp for resizing and conversion
|
|
204
|
+
stream = file.pipe(sharp()
|
|
205
|
+
.rotate()
|
|
206
|
+
.resize(1000, 1000, {
|
|
207
|
+
fit: sharp.fit.inside,
|
|
208
|
+
withoutEnlargement: true
|
|
209
|
+
})
|
|
210
|
+
.toFormat('jpeg', { quality: 80 })) // Convert to JPEG with 85% quality
|
|
211
|
+
|
|
212
|
+
filename = filename.replace(/\\.[^.]+$/, '.jpg') // Change extension to .jpg
|
|
213
|
+
mimeType = 'image/jpeg'
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Regardless of whether it's an image or not, collect the data
|
|
217
|
+
stream.on('data', data => buffers.push(data))
|
|
218
|
+
|
|
219
|
+
stream.on('end', async () => {
|
|
220
|
+
blob = Buffer.concat(buffers)
|
|
221
|
+
meta = { filename, mimeType, encoding, storageType } // Update meta here to ensure it includes modifications for images
|
|
222
|
+
|
|
223
|
+
if (!blob) return res.status(500).send('No file was uploaded')
|
|
224
|
+
|
|
225
|
+
// extract extension from filename
|
|
226
|
+
console.log('meta.filename', meta.filename)
|
|
227
|
+
const extension = meta.filename?.match(/\\.([^.]+)$/)?.[1]
|
|
228
|
+
if (extension) meta.extension = extension
|
|
229
|
+
const create = !fileId
|
|
230
|
+
if (!fileId) fileId = $.id()
|
|
231
|
+
// try to save file to sqlite first to do an early exit if it fails
|
|
232
|
+
try {
|
|
233
|
+
await saveFileBlob(storageType, fileId, blob)
|
|
234
|
+
} catch (err) {
|
|
235
|
+
console.error(err)
|
|
236
|
+
return res.status(500).send('Error saving file')
|
|
237
|
+
}
|
|
238
|
+
if (create) {
|
|
239
|
+
const doc = { id: fileId, ...meta }
|
|
240
|
+
// if some of the meta fields were undefined, remove them from the doc
|
|
241
|
+
for (const key in meta) {
|
|
242
|
+
if (meta[key] == null) delete doc[key]
|
|
243
|
+
}
|
|
244
|
+
await $.files.addNew(doc)
|
|
245
|
+
} else {
|
|
246
|
+
const $file = await sub($.files[fileId])
|
|
247
|
+
|
|
248
|
+
// when changing storageType we should delete the file from the old storageType
|
|
249
|
+
const oldStorageType = $file.storageType.get()
|
|
250
|
+
if (oldStorageType !== meta.storageType) {
|
|
251
|
+
try {
|
|
252
|
+
await deleteFile(oldStorageType, fileId)
|
|
253
|
+
} catch (err) {
|
|
254
|
+
console.error(err)
|
|
255
|
+
return res.status(500).send(`Error deleting file from old storageType ${oldStorageType}`)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const doc = { ...$file.get(), ...meta, updatedAt: Date.now() }
|
|
260
|
+
// if some of the meta fields were undefined, remove them from the doc
|
|
261
|
+
for (const key in meta) {
|
|
262
|
+
if (meta[key] == null) delete doc[key]
|
|
263
|
+
}
|
|
264
|
+
await $file.set(doc)
|
|
265
|
+
}
|
|
266
|
+
console.log(`Uploaded file to ${storageType}`, fileId)
|
|
267
|
+
res.json({ fileId })
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
return req.pipe(bb)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
expressApp.post(DELETE_FILE_URL, async (req, res) => {
|
|
275
|
+
const { fileId } = req.params
|
|
276
|
+
const $file = await sub($.files[fileId])
|
|
277
|
+
const file = $file.get()
|
|
278
|
+
if (!file) return res.status(404).send(ERRORS.fileNotFound)
|
|
279
|
+
const { storageType } = file
|
|
280
|
+
if (!storageType) return res.status(500).send(ERRORS.fileStorageTypeNotSet)
|
|
281
|
+
try {
|
|
282
|
+
await deleteFile(storageType, fileId)
|
|
283
|
+
await $file.del()
|
|
284
|
+
res.json({ fileId })
|
|
285
|
+
} catch (err) {
|
|
286
|
+
console.error(err)
|
|
287
|
+
res.status(500).send('Error deleting file')
|
|
288
|
+
}
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
const schema = {
|
|
295
|
+
storageType: { type: 'string', required: true },
|
|
296
|
+
mimeType: { type: 'string', required: true },
|
|
297
|
+
filename: { type: 'string' }, // original filename with extension
|
|
298
|
+
encoding: { type: 'string' },
|
|
299
|
+
extension: { type: 'string' },
|
|
300
|
+
createdAt: { type: 'number', required: true },
|
|
301
|
+
// updatedAt is used to determine whether the underlying file
|
|
302
|
+
// stored in the storageType provider has changed.
|
|
303
|
+
// This is used to properly cache files on the client side.
|
|
304
|
+
updatedAt: { type: 'number', required: true }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
class FilesModel extends Signal {
|
|
308
|
+
async addNew (file) {
|
|
309
|
+
const now = Date.now()
|
|
310
|
+
return await this.add({
|
|
311
|
+
...file,
|
|
312
|
+
createdAt: now,
|
|
313
|
+
updatedAt: now
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
getUrl (fileId, extension) {
|
|
318
|
+
return BASE_URL + getFileUrl(fileId, extension)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
getDownloadUrl (fileId, extension) {
|
|
322
|
+
return BASE_URL + getFileUrl(fileId, extension) + '?download=true'
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
getUploadUrl (fileId) {
|
|
326
|
+
return BASE_URL + getUploadFileUrl(fileId)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
getDeleteUrl (fileId) {
|
|
330
|
+
return BASE_URL + getDeleteFileUrl(fileId)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
class FileModel extends Signal {
|
|
335
|
+
getUrl () {
|
|
336
|
+
return BASE_URL + getFileUrl(this.getId(), this.extension.get())
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
getDownloadUrl () {
|
|
340
|
+
return this.getUrl() + '?download=true'
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
getUploadUrl () {
|
|
344
|
+
return BASE_URL + getUploadFileUrl(this.getId())
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
getDeleteUrl () {
|
|
348
|
+
return BASE_URL + getDeleteFileUrl(this.getId())
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
getBlob = serverOnly(function () {
|
|
352
|
+
return getFileBlob(this.storageType.get(), this.getId())
|
|
353
|
+
})
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const ERRORS = {
|
|
357
|
+
fileNotFound: 'File not found',
|
|
358
|
+
fileMimeTypeNotSet: 'File mimeType is not set. This should never happen',
|
|
359
|
+
fileStorageTypeNotSet: 'File storageType is not set. This should never happen'
|
|
360
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
// DO NOT MODIFY THIS FILE - IT IS AUTOMATICALLY GENERATED ON COMMITS.
|
|
3
|
+
|
|
4
|
+
import { type ReactNode, type RefObject } from 'react';
|
|
5
|
+
export { default } from './input';
|
|
6
|
+
export declare const _PropsJsonSchema: {};
|
|
7
|
+
export interface FileInputProps {
|
|
8
|
+
/** Ref object to access imperative methods (`pickFile`, `deleteFile`, `uploadFile`) */
|
|
9
|
+
ref?: RefObject<FileInputRef>;
|
|
10
|
+
/** Current fileId (stored in `files` model on the server) */
|
|
11
|
+
value?: string;
|
|
12
|
+
/** MIME types accepted by the picker (passed to `expo-document-picker`) */
|
|
13
|
+
mimeTypes?: string | string[];
|
|
14
|
+
/** When true, opens image picker instead of document picker */
|
|
15
|
+
image?: boolean;
|
|
16
|
+
/** Upload selected file immediately after picking @default true */
|
|
17
|
+
uploadImmediately?: boolean;
|
|
18
|
+
/** Hook called before upload starts (can be async) */
|
|
19
|
+
beforeUpload?: () => any;
|
|
20
|
+
/** Hook called after upload finishes (can be async) */
|
|
21
|
+
afterUpload?: () => any;
|
|
22
|
+
/** Called with new fileId after successful upload */
|
|
23
|
+
onChange?: (fileId?: string) => void;
|
|
24
|
+
/** Custom renderer instead of the default Upload/Change/Delete buttons */
|
|
25
|
+
render?: () => ReactNode;
|
|
26
|
+
/** Custom styles (reserved for future use) */
|
|
27
|
+
style?: any;
|
|
28
|
+
}
|
|
29
|
+
export interface FileInputRef {
|
|
30
|
+
/** Opens the native picker and (optionally) uploads the selected file */
|
|
31
|
+
pickFile: () => Promise<any>;
|
|
32
|
+
/** Deletes the currently selected file */
|
|
33
|
+
deleteFile: () => Promise<void>;
|
|
34
|
+
/** Uploads a picked asset to server and returns new fileId */
|
|
35
|
+
uploadFile: (asset: any, fileId?: string) => Promise<string | undefined>;
|
|
36
|
+
}
|
package/index.tsx
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type ReactNode, type RefObject } from 'react'
|
|
2
|
+
|
|
3
|
+
export { default } from './input'
|
|
4
|
+
|
|
5
|
+
export const _PropsJsonSchema = {/* FileInputProps */}
|
|
6
|
+
|
|
7
|
+
export interface FileInputProps {
|
|
8
|
+
/** Ref object to access imperative methods (`pickFile`, `deleteFile`, `uploadFile`) */
|
|
9
|
+
ref?: RefObject<FileInputRef>
|
|
10
|
+
/** Current fileId (stored in `files` model on the server) */
|
|
11
|
+
value?: string
|
|
12
|
+
/** MIME types accepted by the picker (passed to `expo-document-picker`) */
|
|
13
|
+
mimeTypes?: string | string[]
|
|
14
|
+
/** When true, opens image picker instead of document picker */
|
|
15
|
+
image?: boolean
|
|
16
|
+
/** Upload selected file immediately after picking @default true */
|
|
17
|
+
uploadImmediately?: boolean
|
|
18
|
+
/** Hook called before upload starts (can be async) */
|
|
19
|
+
beforeUpload?: () => any
|
|
20
|
+
/** Hook called after upload finishes (can be async) */
|
|
21
|
+
afterUpload?: () => any
|
|
22
|
+
/** Called with new fileId after successful upload */
|
|
23
|
+
onChange?: (fileId?: string) => void
|
|
24
|
+
/** Custom renderer instead of the default Upload/Change/Delete buttons */
|
|
25
|
+
render?: () => ReactNode
|
|
26
|
+
/** Custom styles (reserved for future use) */
|
|
27
|
+
style?: any
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface FileInputRef {
|
|
31
|
+
/** Opens the native picker and (optionally) uploads the selected file */
|
|
32
|
+
pickFile: () => Promise<any>
|
|
33
|
+
/** Deletes the currently selected file */
|
|
34
|
+
deleteFile: () => Promise<void>
|
|
35
|
+
/** Uploads a picked asset to server and returns new fileId */
|
|
36
|
+
uploadFile: (asset: any, fileId?: string) => Promise<string | undefined>
|
|
37
|
+
}
|
package/input.expo.tsx
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { useImperativeHandle, type ReactNode } from 'react'
|
|
2
|
+
import { pug, observer } from 'startupjs'
|
|
3
|
+
import Button from '@startupjs-ui/button'
|
|
4
|
+
import Div from '@startupjs-ui/div'
|
|
5
|
+
import { themed } from '@startupjs-ui/core'
|
|
6
|
+
import confirm from '@startupjs-ui/dialogs/confirm'
|
|
7
|
+
import * as DocumentPicker from 'expo-document-picker'
|
|
8
|
+
import * as ImagePicker from 'expo-image-picker'
|
|
9
|
+
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt'
|
|
10
|
+
import deleteFileApi from './deleteFile'
|
|
11
|
+
import uploadFileApi from './uploadFile'
|
|
12
|
+
import type { FileInputProps, FileInputRef } from './index'
|
|
13
|
+
|
|
14
|
+
export default observer(themed('FileInput', FileInput))
|
|
15
|
+
|
|
16
|
+
function FileInput ({
|
|
17
|
+
value: initialFileId,
|
|
18
|
+
mimeTypes,
|
|
19
|
+
image,
|
|
20
|
+
uploadImmediately = true,
|
|
21
|
+
beforeUpload,
|
|
22
|
+
afterUpload,
|
|
23
|
+
onChange = () => undefined,
|
|
24
|
+
render,
|
|
25
|
+
ref
|
|
26
|
+
}: FileInputProps): ReactNode {
|
|
27
|
+
let fileId = initialFileId
|
|
28
|
+
|
|
29
|
+
useImperativeHandle(ref, (): FileInputRef => {
|
|
30
|
+
return {
|
|
31
|
+
pickFile,
|
|
32
|
+
deleteFile: handleDeleteFile,
|
|
33
|
+
uploadFile: uploadFileApi
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
async function pickFile () {
|
|
38
|
+
let result: any
|
|
39
|
+
if (image) {
|
|
40
|
+
result = await ImagePicker.launchImageLibraryAsync({
|
|
41
|
+
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
|
42
|
+
allowsEditing: true,
|
|
43
|
+
aspect: [4, 3],
|
|
44
|
+
quality: 1
|
|
45
|
+
})
|
|
46
|
+
} else {
|
|
47
|
+
result = await DocumentPicker.getDocumentAsync({ type: mimeTypes as any })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const cancelled = result?.cancelled ?? result?.canceled
|
|
51
|
+
const assets = result?.assets
|
|
52
|
+
if (cancelled || !assets) return
|
|
53
|
+
|
|
54
|
+
if (!uploadImmediately) return assets[0]
|
|
55
|
+
|
|
56
|
+
if (beforeUpload) {
|
|
57
|
+
const beforeUploadResult = beforeUpload()
|
|
58
|
+
if (beforeUploadResult?.then) await beforeUploadResult
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let handled
|
|
62
|
+
for (const asset of assets) {
|
|
63
|
+
if (handled) throw Error('Only one file is allowed')
|
|
64
|
+
fileId = await uploadFileApi(asset, fileId)
|
|
65
|
+
if (!fileId) return
|
|
66
|
+
onChange(fileId)
|
|
67
|
+
handled = true
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (afterUpload) {
|
|
71
|
+
const afterUploadResult = afterUpload()
|
|
72
|
+
if (afterUploadResult?.then) await afterUploadResult
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function handleDeleteFile () {
|
|
77
|
+
if (!fileId) return
|
|
78
|
+
if (!await confirm('Are you sure you want to delete this file?')) return
|
|
79
|
+
const deleted = await deleteFileApi(fileId)
|
|
80
|
+
if (!deleted) return
|
|
81
|
+
onChange(undefined)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function renderDefault (): ReactNode {
|
|
85
|
+
return pug`
|
|
86
|
+
if fileId
|
|
87
|
+
Div(row)
|
|
88
|
+
Button(onPress=pickFile) Change
|
|
89
|
+
Button(pushed onPress=handleDeleteFile variant='text' icon=faTrashAlt)
|
|
90
|
+
else
|
|
91
|
+
Button(onPress=pickFile) Upload file
|
|
92
|
+
`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return pug`
|
|
96
|
+
if render
|
|
97
|
+
= render()
|
|
98
|
+
else
|
|
99
|
+
= renderDefault()
|
|
100
|
+
`
|
|
101
|
+
}
|
package/input.tsx
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type ReactNode } from 'react'
|
|
2
|
+
import { observer } from 'startupjs'
|
|
3
|
+
import { themed } from '@startupjs-ui/core'
|
|
4
|
+
import type { FileInputProps } from './index'
|
|
5
|
+
|
|
6
|
+
export default observer(themed('FileInput', FileInput))
|
|
7
|
+
|
|
8
|
+
function FileInput (props: FileInputProps): ReactNode {
|
|
9
|
+
throw Error(`
|
|
10
|
+
<FileInput /> is only available in Expo projects
|
|
11
|
+
`)
|
|
12
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@startupjs-ui/file-input",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"main": "index.tsx",
|
|
8
|
+
"types": "index.d.ts",
|
|
9
|
+
"type": "module",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./index.tsx",
|
|
12
|
+
"./constants": "./constants.js",
|
|
13
|
+
"./deleteFile": "./deleteFile.ts",
|
|
14
|
+
"./uploadFile": "./uploadFile.ts",
|
|
15
|
+
"./providers": "./providers/index.js",
|
|
16
|
+
"./files.plugin": "./files.plugin.js"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@fortawesome/free-solid-svg-icons": "^5.12.0",
|
|
20
|
+
"@startupjs-ui/button": "^0.1.3",
|
|
21
|
+
"@startupjs-ui/core": "^0.1.3",
|
|
22
|
+
"@startupjs-ui/dialogs": "^0.1.3",
|
|
23
|
+
"@startupjs-ui/div": "^0.1.3",
|
|
24
|
+
"busboy": "^1.6.0",
|
|
25
|
+
"expo-document-picker": ">=14.0.0",
|
|
26
|
+
"expo-image-picker": ">=17.0.0",
|
|
27
|
+
"sharp": "^0.34.5"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@azure/storage-blob": "*",
|
|
31
|
+
"mongodb": "*",
|
|
32
|
+
"react": "*",
|
|
33
|
+
"react-native": "*",
|
|
34
|
+
"startupjs": "*"
|
|
35
|
+
},
|
|
36
|
+
"peerDependenciesMeta": {
|
|
37
|
+
"@azure/storage-blob": {
|
|
38
|
+
"optional": true
|
|
39
|
+
},
|
|
40
|
+
"mongodb": {
|
|
41
|
+
"optional": true
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"gitHead": "fd964ebc3892d3dd0a6c85438c0af619cc50c3f0"
|
|
45
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { BlobServiceClient } from '@azure/storage-blob'
|
|
2
|
+
|
|
3
|
+
// Replace with your Azure Blob Storage connection string or Azurite connection string
|
|
4
|
+
const AZURE_BLOB_STORAGE_CONNECTION_STRING = process.env.AZURE_BLOB_STORAGE_CONNECTION_STRING
|
|
5
|
+
const CONTAINER_NAME = 'files' // Container name for storing blobs
|
|
6
|
+
|
|
7
|
+
let blobServiceClient
|
|
8
|
+
let containerClient
|
|
9
|
+
|
|
10
|
+
export function validateSupport () {
|
|
11
|
+
if (!AZURE_BLOB_STORAGE_CONNECTION_STRING) {
|
|
12
|
+
throw new Error(ERRORS.azureNotAvailable)
|
|
13
|
+
}
|
|
14
|
+
// Initialize the BlobServiceClient and ContainerClient once
|
|
15
|
+
if (!blobServiceClient) {
|
|
16
|
+
blobServiceClient = BlobServiceClient.fromConnectionString(AZURE_BLOB_STORAGE_CONNECTION_STRING)
|
|
17
|
+
containerClient = blobServiceClient.getContainerClient(CONTAINER_NAME)
|
|
18
|
+
// Ensure container exists
|
|
19
|
+
containerClient.createIfNotExists().catch((err) => {
|
|
20
|
+
console.error('[Azure Blob Storage] Failed to create container:', err)
|
|
21
|
+
throw err
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function getFileBlob (fileId, range) {
|
|
27
|
+
validateSupport()
|
|
28
|
+
const blobClient = containerClient.getBlobClient(fileId)
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
// Check if blob exists
|
|
32
|
+
const properties = await blobClient.getProperties()
|
|
33
|
+
const actualFileSize = properties.contentLength
|
|
34
|
+
|
|
35
|
+
if (range) {
|
|
36
|
+
console.log('[Azure Blob Storage] Using Range request for optimal streaming:', { fileId, range })
|
|
37
|
+
|
|
38
|
+
// Validate range boundaries
|
|
39
|
+
if (range.start >= actualFileSize || range.start < 0) {
|
|
40
|
+
console.log('[Azure Blob Storage] Range start out of bounds:', { start: range.start, actualFileSize })
|
|
41
|
+
throw new Error('Range start out of bounds')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Ensure end is within file bounds
|
|
45
|
+
const adjustedEnd = Math.min(range.end, actualFileSize - 1)
|
|
46
|
+
|
|
47
|
+
// Ensure end is not before start
|
|
48
|
+
if (adjustedEnd < range.start) {
|
|
49
|
+
console.log('[Azure Blob Storage] Invalid range:', { start: range.start, end: adjustedEnd, actualFileSize })
|
|
50
|
+
throw new Error('Invalid range')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
console.log('[Azure Blob Storage] Downloading blob with range:', {
|
|
54
|
+
start: range.start,
|
|
55
|
+
end: adjustedEnd,
|
|
56
|
+
actualFileSize,
|
|
57
|
+
originalEnd: range.end
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// Download blob with range
|
|
61
|
+
const response = await blobClient.download(range.start, adjustedEnd - range.start + 1)
|
|
62
|
+
const chunks = []
|
|
63
|
+
|
|
64
|
+
for await (const chunk of response.readableStreamBody) {
|
|
65
|
+
chunks.push(chunk)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const result = Buffer.concat(chunks)
|
|
69
|
+
const expectedSize = adjustedEnd - range.start + 1
|
|
70
|
+
|
|
71
|
+
console.log('[Azure Blob Storage] Range response:', {
|
|
72
|
+
expected: expectedSize,
|
|
73
|
+
actual: result.length,
|
|
74
|
+
start: range.start,
|
|
75
|
+
end: adjustedEnd,
|
|
76
|
+
fileId
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
if (result.length === 0) {
|
|
80
|
+
console.warn('[Azure Blob Storage] Empty range response - this may indicate a problem')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return result
|
|
84
|
+
} else {
|
|
85
|
+
// Regular download for non-Range requests
|
|
86
|
+
const response = await blobClient.download()
|
|
87
|
+
const chunks = []
|
|
88
|
+
|
|
89
|
+
for await (const chunk of response.readableStreamBody) {
|
|
90
|
+
chunks.push(chunk)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return Buffer.concat(chunks)
|
|
94
|
+
}
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (error.statusCode === 404) {
|
|
97
|
+
throw new Error(ERRORS.fileNotFound)
|
|
98
|
+
}
|
|
99
|
+
console.error('[Azure Blob Storage] Error downloading blob:', error)
|
|
100
|
+
throw error
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function getFileSize (fileId) {
|
|
105
|
+
validateSupport()
|
|
106
|
+
const blobClient = containerClient.getBlobClient(fileId)
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const properties = await blobClient.getProperties()
|
|
110
|
+
return properties.contentLength
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error.statusCode === 404) {
|
|
113
|
+
throw new Error(ERRORS.fileNotFound)
|
|
114
|
+
}
|
|
115
|
+
console.error('[Azure Blob Storage] Error getting blob size:', error)
|
|
116
|
+
throw error
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function saveFileBlob (fileId, blob) {
|
|
121
|
+
validateSupport()
|
|
122
|
+
const blobClient = containerClient.getBlockBlobClient(fileId)
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
// Upload blob (overwrites if exists)
|
|
126
|
+
await blobClient.upload(blob, blob.length)
|
|
127
|
+
console.log('[Azure Blob Storage] Blob uploaded successfully:', fileId)
|
|
128
|
+
} catch (error) {
|
|
129
|
+
console.error('[Azure Blob Storage] Error uploading blob:', error)
|
|
130
|
+
throw error
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function deleteFile (fileId) {
|
|
135
|
+
validateSupport()
|
|
136
|
+
const blobClient = containerClient.getBlobClient(fileId)
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await blobClient.deleteIfExists()
|
|
140
|
+
console.log('[Azure Blob Storage] Blob deleted successfully:', fileId)
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (error.statusCode === 404) {
|
|
143
|
+
throw new Error(ERRORS.fileNotFound)
|
|
144
|
+
}
|
|
145
|
+
console.error('[Azure Blob Storage] Error deleting blob:', error)
|
|
146
|
+
throw error
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const ERRORS = {
|
|
151
|
+
azureNotAvailable: `
|
|
152
|
+
Azure Blob Storage connection is not available.
|
|
153
|
+
Make sure you have configured the AZURE_BLOB_STORAGE_CONNECTION_STRING environment variable.
|
|
154
|
+
`,
|
|
155
|
+
fileNotFound: `
|
|
156
|
+
File not found in Azure Blob Storage.
|
|
157
|
+
`
|
|
158
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { mongo, sqlite } from 'startupjs/server'
|
|
2
|
+
|
|
3
|
+
export async function getFileBlob (storageType, fileId, range) {
|
|
4
|
+
return (await getStorageProvider(storageType)).getFileBlob(fileId, range)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function getFileSize (storageType, fileId) {
|
|
8
|
+
return (await getStorageProvider(storageType)).getFileSize(fileId)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function saveFileBlob (storageType, fileId, blob) {
|
|
12
|
+
return (await getStorageProvider(storageType)).saveFileBlob(fileId, blob)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function deleteFile (storageType, fileId) {
|
|
16
|
+
return (await getStorageProvider(storageType)).deleteFile(fileId)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function getDefaultStorageType () {
|
|
20
|
+
const storage = process.env.DEFAULT_STORAGE_TYPE
|
|
21
|
+
|
|
22
|
+
if (storage) return storage
|
|
23
|
+
if (mongo) return 'mongo'
|
|
24
|
+
if (sqlite) return 'sqlite'
|
|
25
|
+
throw Error(ERRORS.noDefaultStorageProvider)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const moduleCache = {}
|
|
29
|
+
|
|
30
|
+
async function getStorageProvider (storageType) {
|
|
31
|
+
if (moduleCache[storageType]) return moduleCache[storageType]
|
|
32
|
+
|
|
33
|
+
let theModule
|
|
34
|
+
if (storageType === 'sqlite') {
|
|
35
|
+
theModule = await import('./sqlite.js')
|
|
36
|
+
} else if (storageType === 'mongo') {
|
|
37
|
+
theModule = await import('./mongo.js')
|
|
38
|
+
} else if (storageType === 'azureblob') {
|
|
39
|
+
theModule = await import('./azureblob.js')
|
|
40
|
+
} else {
|
|
41
|
+
throw Error(ERRORS.unsupportedStorageType(storageType))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await theModule.validateSupport?.()
|
|
45
|
+
|
|
46
|
+
moduleCache[storageType] = theModule
|
|
47
|
+
return theModule
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ERRORS = {
|
|
51
|
+
unsupportedStorageType: storageType => `
|
|
52
|
+
[@startupjs/ui] FileInput: You tried getting file from storageType '${storageType}',
|
|
53
|
+
but it's not supported.
|
|
54
|
+
This should never happen.
|
|
55
|
+
`,
|
|
56
|
+
noDefaultStorageProvider: `
|
|
57
|
+
[@startupjs/ui] FileInput: No default storage provider can be used.
|
|
58
|
+
Neither MongoDB is used in your project nor SQLite (persistent mingo).
|
|
59
|
+
`
|
|
60
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { mongo } from 'startupjs/server'
|
|
2
|
+
import { GridFSBucket } from 'mongodb'
|
|
3
|
+
|
|
4
|
+
let bucket
|
|
5
|
+
|
|
6
|
+
export function validateSupport () {
|
|
7
|
+
if (!mongo) throw Error(ERRORS.mongoNotAvailable)
|
|
8
|
+
// Initialize the GridFSBucket once
|
|
9
|
+
if (!bucket) {
|
|
10
|
+
bucket = new GridFSBucket(mongo)
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function getFileBlob (fileId, range) {
|
|
15
|
+
validateSupport()
|
|
16
|
+
const files = await bucket.find({ filename: fileId }).toArray()
|
|
17
|
+
if (!files || files.length === 0) {
|
|
18
|
+
throw new Error(ERRORS.fileNotFound)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
// Performance optimization: use Range requests for partial content
|
|
23
|
+
let downloadStream
|
|
24
|
+
if (range) {
|
|
25
|
+
console.log('[MongoDB GridFS] Using Range request for optimal streaming:', { fileId, range })
|
|
26
|
+
|
|
27
|
+
// Validate range boundaries
|
|
28
|
+
const actualFileSize = files[0].length
|
|
29
|
+
if (range.start >= actualFileSize || range.start < 0) {
|
|
30
|
+
console.log('[MongoDB GridFS] Range start out of bounds:', { start: range.start, actualFileSize })
|
|
31
|
+
return reject(new Error('Range start out of bounds'))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Ensure end is within file bounds
|
|
35
|
+
const adjustedEnd = Math.min(range.end, actualFileSize - 1)
|
|
36
|
+
|
|
37
|
+
// Ensure end is not before start
|
|
38
|
+
if (adjustedEnd < range.start) {
|
|
39
|
+
console.log('[MongoDB GridFS] Invalid range:', { start: range.start, end: adjustedEnd, actualFileSize })
|
|
40
|
+
return reject(new Error('Invalid range'))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
console.log('[MongoDB GridFS] Opening download stream with range:', {
|
|
44
|
+
start: range.start,
|
|
45
|
+
end: adjustedEnd,
|
|
46
|
+
actualFileSize,
|
|
47
|
+
originalEnd: range.end
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
downloadStream = bucket.openDownloadStreamByName(fileId, {
|
|
51
|
+
start: range.start,
|
|
52
|
+
end: adjustedEnd
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// Add error handling for stream errors
|
|
56
|
+
downloadStream.on('error', (error) => {
|
|
57
|
+
console.error('[MongoDB GridFS] Stream error:', error)
|
|
58
|
+
reject(error)
|
|
59
|
+
})
|
|
60
|
+
} else {
|
|
61
|
+
// Regular download for non-Range requests (download functionality)
|
|
62
|
+
downloadStream = bucket.openDownloadStreamByName(fileId)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const chunks = []
|
|
66
|
+
|
|
67
|
+
downloadStream.on('data', (chunk) => {
|
|
68
|
+
chunks.push(chunk)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
downloadStream.on('error', (err) => {
|
|
72
|
+
reject(err)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
downloadStream.on('end', () => {
|
|
76
|
+
const result = Buffer.concat(chunks)
|
|
77
|
+
if (range) {
|
|
78
|
+
const expectedSize = range.end - range.start + 1
|
|
79
|
+
console.log('[MongoDB GridFS] Range response:', {
|
|
80
|
+
expected: expectedSize,
|
|
81
|
+
actual: result.length,
|
|
82
|
+
start: range.start,
|
|
83
|
+
end: range.end,
|
|
84
|
+
fileId
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// Validate that we got the expected data
|
|
88
|
+
if (result.length === 0) {
|
|
89
|
+
console.warn('[MongoDB GridFS] Empty range response - this may indicate a problem')
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
resolve(result)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function getFileSize (fileId) {
|
|
98
|
+
validateSupport()
|
|
99
|
+
const files = await bucket.find({ filename: fileId }).toArray()
|
|
100
|
+
if (!files || files.length === 0) {
|
|
101
|
+
throw new Error(ERRORS.fileNotFound)
|
|
102
|
+
}
|
|
103
|
+
return files[0].length
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function saveFileBlob (fileId, blob) {
|
|
107
|
+
console.log('[MongoDB GridFS] Saving file:', {
|
|
108
|
+
fileId,
|
|
109
|
+
blobType: blob ? blob.constructor.name : 'undefined',
|
|
110
|
+
blobLength: blob instanceof Buffer ? blob.length : 'N/A'
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
validateSupport()
|
|
114
|
+
// Delete existing files with the same filename
|
|
115
|
+
const files = await bucket.find({ filename: fileId }).toArray()
|
|
116
|
+
for (const file of files) {
|
|
117
|
+
console.log('[MongoDB GridFS] Deleting existing files')
|
|
118
|
+
await bucket.delete(file._id)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
console.log('db', mongo)
|
|
122
|
+
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
console.log('[MongoDB GridFS] Opening upload stream')
|
|
125
|
+
const uploadStream = bucket.openUploadStream(fileId)
|
|
126
|
+
|
|
127
|
+
uploadStream.on('data', (chunk) => {
|
|
128
|
+
console.log('[MongoDB GridFS] Stream data received:', { fileId, chunkLength: chunk.length })
|
|
129
|
+
})
|
|
130
|
+
uploadStream.on('end', () => {
|
|
131
|
+
console.log('[MongoDB GridFS] Stream end event:', fileId)
|
|
132
|
+
})
|
|
133
|
+
uploadStream.on('close', () => {
|
|
134
|
+
console.log('[MongoDB GridFS] Stream closed:', fileId)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
uploadStream.on('error', (err) => {
|
|
138
|
+
console.warn('[MongoDB GridFS] error writing to stream', err)
|
|
139
|
+
reject(err)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
uploadStream.on('finish', () => {
|
|
143
|
+
console.log('[MongoDB GridFS] Finished writing to stream')
|
|
144
|
+
resolve()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
if (blob instanceof Buffer) {
|
|
149
|
+
uploadStream.write(blob)
|
|
150
|
+
uploadStream.end()
|
|
151
|
+
} else if (blob.readable) {
|
|
152
|
+
blob.pipe(uploadStream)
|
|
153
|
+
} else {
|
|
154
|
+
const err = new Error('Unsupported blob type')
|
|
155
|
+
console.error('[MongoDB GridFS] Error:', err)
|
|
156
|
+
reject(err)
|
|
157
|
+
}
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.error('[MongoDB GridFS] Error writing blob:', err)
|
|
160
|
+
reject(err)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
console.log('here we go')
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function deleteFile (fileId) {
|
|
168
|
+
validateSupport()
|
|
169
|
+
const files = await bucket.find({ filename: fileId }).toArray()
|
|
170
|
+
if (!files || files.length === 0) {
|
|
171
|
+
throw new Error(ERRORS.fileNotFound)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const file of files) {
|
|
175
|
+
await bucket.delete(file._id)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const ERRORS = {
|
|
180
|
+
mongoNotAvailable: `
|
|
181
|
+
[@startupjs/ui] FileInput: MongoDB connection is not available.
|
|
182
|
+
Make sure you have connected to MongoDB before using this function.
|
|
183
|
+
`,
|
|
184
|
+
fileNotFound: `
|
|
185
|
+
File not found in MongoDB GridFS.
|
|
186
|
+
`
|
|
187
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { sqlite } from 'startupjs/server'
|
|
2
|
+
|
|
3
|
+
export async function validateSupport () {
|
|
4
|
+
if (!sqlite) throw Error(ERRORS.disabled)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function getFileBlob (fileId, range) {
|
|
8
|
+
return await new Promise((resolve, reject) => {
|
|
9
|
+
sqlite.get('SELECT * FROM files WHERE id = ?', [fileId], (err, row) => {
|
|
10
|
+
if (err) return reject(err)
|
|
11
|
+
if (!row) return reject(new Error(ERRORS.fileNotFoundInSqlite))
|
|
12
|
+
if (!row.data) return reject(new Error(ERRORS.fileDataNotFoundInSqlite))
|
|
13
|
+
|
|
14
|
+
const blob = row.data
|
|
15
|
+
const actualFileSize = blob.length
|
|
16
|
+
|
|
17
|
+
if (range) {
|
|
18
|
+
console.log('[SQLite] Using Range request for optimal streaming:', { fileId, range })
|
|
19
|
+
|
|
20
|
+
// Validate range boundaries
|
|
21
|
+
if (range.start >= actualFileSize || range.start < 0) {
|
|
22
|
+
console.log('[SQLite] Range start out of bounds:', { start: range.start, actualFileSize })
|
|
23
|
+
return reject(new Error('Range start out of bounds'))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Ensure end is within file bounds
|
|
27
|
+
const adjustedEnd = Math.min(range.end, actualFileSize - 1)
|
|
28
|
+
|
|
29
|
+
// Ensure end is not before start
|
|
30
|
+
if (adjustedEnd < range.start) {
|
|
31
|
+
console.log('[SQLite] Invalid range:', { start: range.start, end: adjustedEnd, actualFileSize })
|
|
32
|
+
return reject(new Error('Invalid range'))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
console.log('[SQLite] Retrieving blob with range:', {
|
|
36
|
+
start: range.start,
|
|
37
|
+
end: adjustedEnd,
|
|
38
|
+
actualFileSize,
|
|
39
|
+
originalEnd: range.end
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// Extract the requested range from the blob
|
|
43
|
+
const result = blob.slice(range.start, adjustedEnd + 1)
|
|
44
|
+
const expectedSize = adjustedEnd - range.start + 1
|
|
45
|
+
|
|
46
|
+
console.log('[SQLite] Range response:', {
|
|
47
|
+
expected: expectedSize,
|
|
48
|
+
actual: result.length,
|
|
49
|
+
start: range.start,
|
|
50
|
+
end: adjustedEnd,
|
|
51
|
+
fileId
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
if (result.length === 0) {
|
|
55
|
+
console.warn('[SQLite] Empty range response - this may indicate a problem')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
resolve(result)
|
|
59
|
+
} else {
|
|
60
|
+
// Return full blob for non-range requests
|
|
61
|
+
resolve(blob)
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function saveFileBlob (fileId, blob) {
|
|
68
|
+
return await new Promise((resolve, reject) => {
|
|
69
|
+
sqlite.run('INSERT OR REPLACE INTO files (id, data) VALUES (?, ?)', [fileId, blob], err => {
|
|
70
|
+
if (err) return reject(err)
|
|
71
|
+
resolve()
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function deleteFile (fileId) {
|
|
77
|
+
return await new Promise((resolve, reject) => {
|
|
78
|
+
sqlite.run('DELETE FROM files WHERE id = ?', [fileId], err => {
|
|
79
|
+
if (err) return reject(err)
|
|
80
|
+
resolve()
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function getFileSize (fileId) {
|
|
86
|
+
await validateSupport()
|
|
87
|
+
return await new Promise((resolve, reject) => {
|
|
88
|
+
sqlite.get('SELECT data FROM files WHERE id = ?', [fileId], (err, row) => {
|
|
89
|
+
if (err) return reject(err)
|
|
90
|
+
if (!row) return reject(new Error(ERRORS.fileNotFoundInSqlite))
|
|
91
|
+
if (!row.data) return reject(new Error(ERRORS.fileDataNotFoundInSqlite))
|
|
92
|
+
resolve(row.data.length)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const ERRORS = {
|
|
98
|
+
disabled: `
|
|
99
|
+
[@startupjs/ui] FileInput: You tried getting file from SQLite,
|
|
100
|
+
but it's not used in your project.
|
|
101
|
+
('storageType' is set to 'sqlite' in the 'files' document).
|
|
102
|
+
This should never happen.
|
|
103
|
+
If you migrated your DB from local SQLite in dev
|
|
104
|
+
to MongoDB in production, you must reupload all your files from SQLite to MongoDB
|
|
105
|
+
while also changing 'storageType' to 'mongo' in your 'files' collection.
|
|
106
|
+
`,
|
|
107
|
+
fileNotFoundInSqlite: `
|
|
108
|
+
File not found in SQLite.
|
|
109
|
+
`,
|
|
110
|
+
fileDataNotFoundInSqlite: `
|
|
111
|
+
File data not found in SQLite.
|
|
112
|
+
`
|
|
113
|
+
}
|
package/uploadFile.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Platform } from 'react-native'
|
|
2
|
+
import { axios, BASE_URL } from 'startupjs'
|
|
3
|
+
import alert from '@startupjs-ui/dialogs/alert'
|
|
4
|
+
import { getUploadFileUrl } from './constants.js'
|
|
5
|
+
|
|
6
|
+
const isWeb = Platform.OS === 'web'
|
|
7
|
+
|
|
8
|
+
export default async function uploadFile (asset: any, fileId?: string): Promise<string | undefined> {
|
|
9
|
+
try {
|
|
10
|
+
const formData = new FormData()
|
|
11
|
+
const uri: string | undefined = asset?.uri
|
|
12
|
+
if (!uri) throw Error('File upload failed. No asset.uri provided')
|
|
13
|
+
let type = asset.mimeType
|
|
14
|
+
const name = asset.name || asset.fileName || getFilenameFromUri(uri)
|
|
15
|
+
if (!type) {
|
|
16
|
+
if (asset.type === 'image') type = getImageMimeType(uri || asset.fileName || asset.name || '')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (isWeb) {
|
|
20
|
+
// on web we'll receive it as a uri blob
|
|
21
|
+
const blob = await (await fetch(uri)).blob()
|
|
22
|
+
formData.append('file', blob, name)
|
|
23
|
+
} else {
|
|
24
|
+
formData.append('file', {
|
|
25
|
+
uri,
|
|
26
|
+
name,
|
|
27
|
+
type
|
|
28
|
+
} as any)
|
|
29
|
+
}
|
|
30
|
+
const res = await axios.post(BASE_URL + getUploadFileUrl(fileId), formData, {
|
|
31
|
+
headers: {
|
|
32
|
+
'Content-Type': 'multipart/form-data'
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
fileId = res.data?.fileId
|
|
36
|
+
if (!fileId) throw Error('File upload failed. No fileId returned from server')
|
|
37
|
+
console.log('Uploaded file:', fileId)
|
|
38
|
+
return fileId
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error(err)
|
|
41
|
+
await alert('Error uploading file')
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getFilenameFromUri (uri: string) {
|
|
46
|
+
if (uri.length > 1000) return 'file' // if it's a base64 encoded uri
|
|
47
|
+
return (uri.split(/[/\\]/).pop() ?? 'file').toLowerCase()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getImageMimeType (filename: string) {
|
|
51
|
+
// Extract the file extension from the filename
|
|
52
|
+
const extension = (filename.split('.').pop() ?? '').toLowerCase()
|
|
53
|
+
|
|
54
|
+
// Map of image extensions to MIME types
|
|
55
|
+
const mimeTypes: Record<string, string> = {
|
|
56
|
+
jpg: 'image/jpeg',
|
|
57
|
+
jpeg: 'image/jpeg',
|
|
58
|
+
png: 'image/png',
|
|
59
|
+
gif: 'image/gif',
|
|
60
|
+
bmp: 'image/bmp',
|
|
61
|
+
webp: 'image/webp',
|
|
62
|
+
tiff: 'image/tiff',
|
|
63
|
+
svg: 'image/svg+xml',
|
|
64
|
+
ico: 'image/vnd.microsoft.icon',
|
|
65
|
+
heic: 'image/heic',
|
|
66
|
+
heif: 'image/heif',
|
|
67
|
+
avif: 'image/avif'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Return the corresponding MIME type or default to image/{extension}
|
|
71
|
+
return mimeTypes[extension] || (extension ? `image/${extension}` : 'image')
|
|
72
|
+
}
|