reject uploads without waiting for whole file content (chrome doesn't support expect-100 mechanism)
Massimo Melina committed
Jan 22, 2023 at 07:56 UTC
0af246b8e182fadaa13aed26fd9134a13aa4d76f
5 files changed
+60
-15
frontend/src/api.ts
+1
-1
@@ -79,7 +79,7 @@ function getCsrf() {
79
return getCookie('csrf')
80
}
81
82
-export async function getNotification(channel: string, cb: (name: string, data:any) => void) {
82
+export async function getNotification(channel: string, cb: (name: string, data:any) => void): Promise<EventSource> {
83
return new Promise(resolve => {
84
const ret = apiEvents('get_notifications', { channel }, (type, entries) => {
85
if (type === 'connected')
frontend/src/upload.ts
+34
-11
@@ -7,6 +7,7 @@ import _ from 'lodash'
7
import { proxy, ref, subscribe, useSnapshot } from 'valtio'
8
import { alertDialog } from './dialog'
9
import { reloadList } from './useFetchList'
10
+import { getNotification } from './api'
11
12
export const uploadState = proxy<{
13
done: number
@@ -156,27 +157,49 @@ function iconBtn(icon: string, onClick: () => any, { small=true, style={}, ...pr
157
158
/// Manage upload queue
159
159
-let req: XMLHttpRequest | undefined
160
subscribe(uploadState, () => {
161
const [cur] = uploadState.qs
162
- if (cur && !uploadState.uploading && !uploadState.paused)
163
- startUpload(cur.files[0], cur.to)
162
+ if (!cur?.files.length) {
163
+ notificationChannel = '' // renew channel at each queue for improved security
164
+ notificationSource.close()
165
+ return
166
+ }
167
+ if (cur?.files.length && !uploadState.uploading && !uploadState.paused)
168
+ startUpload(cur.files[0], cur.to).then()
169
})
170
166
-function startUpload(f: File | undefined, to: string) {
167
- if (!f) return
171
+let req: XMLHttpRequest | undefined
172
+let overrideStatus = 0
173
+let notificationChannel = ''
174
+let notificationSource: EventSource
175
+
176
+async function startUpload(f: File, to: string) {
177
+ if (!notificationChannel) {
178
+ notificationChannel = 'upload-' + Math.random().toString(36).slice(2)
179
+ notificationSource = await getNotification(notificationChannel, (name, data) => {
180
+ if (name !== 'upload.status') return
181
+ const {uploading} = uploadState
182
+ if (!uploading) return
183
+ overrideStatus = data?.[path(uploading)]
184
+ if (overrideStatus >= 400)
185
+ abortCurrentUpload()
186
+ })
187
+ }
188
+ overrideStatus = 0
189
uploadState.uploading = f
190
req = new XMLHttpRequest()
191
req.onloadend = () => {
192
if (req?.readyState !== 4) return
172
- if (req.status >= 400 )
173
- error()
174
- else
175
- done()
193
+ const status = overrideStatus || req.status
194
+ if (status) // 0 = user-aborted
195
+ if (status >= 400)
196
+ error()
197
+ else
198
+ done()
199
next()
200
}
201
req.upload.onprogress = (e:any) => uploadState.progress = e.loaded / e.total * 100
179
- req.open("POST", to, true)
202
+ req.open("POST", to + '?notificationChannel=' + notificationChannel, true)
203
const form = new FormData()
204
form.append('file', f, path(f))
205
req.send(form)
@@ -207,4 +230,4 @@ function startUpload(f: File | undefined, to: string) {
230
231
function abortCurrentUpload() {
232
req?.abort()
210
-}
\ No newline at end of file
233
+}
src/apiMiddleware.ts
+2
-2
@@ -37,9 +37,9 @@ export function apiMiddleware(apis: ApiHandlers) : Koa.Middleware {
37
res = asyncGeneratorToReadable(res)
38
if (res instanceof Readable) { // Readable, we'll go SSE-mode
39
res.pipe(createSSE(ctx))
40
- const stillRes = res // satisfy ts
40
+ const resAsReadable = res // satisfy ts
41
ctx.req.on('close', () => // by closing the generated stream, creator of the stream will know the request is over without having to access anything else
42
- stillRes.destroy())
42
+ resAsReadable.destroy())
43
return
44
}
45
if (res instanceof ApiError) {
src/frontEndApis.ts
+21
-1
@@ -1,9 +1,11 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
-import { ApiHandlers } from './apiMiddleware'
3
+import { ApiHandlers, SendListReadable } from './apiMiddleware'
4
import { file_list } from './api.file_list'
5
import * as api_auth from './api.auth'
6
import { defineConfig } from './config'
7
+import events from './events'
8
+import Koa from 'koa'
9
10
const customHeader = defineConfig('custom_header')
11
@@ -13,5 +15,23 @@ export const frontEndApis: ApiHandlers = {
15
16
config() {
17
return Object.fromEntries([customHeader].map(x => [x.key(), x.get()]))
18
+ },
19
+
20
+ get_notifications({ channel }, ctx) {
21
+ const list = new SendListReadable()
22
+ list.ready() // on chrome109 EventSource doesn't emit 'open' until something is sent
23
+ return list.events(ctx, {
24
+ [NOTIFICATION_PREFIX + channel](name, data) {
25
+ list.custom({ name, data })
26
+ }
27
+ })
28
}
29
}
30
+
31
+export function notifyClient(ctx: Koa.Context, name: string, data: any) {
32
+ const {notificationChannel} = ctx.query
33
+ if (notificationChannel)
34
+ events.emit(NOTIFICATION_PREFIX + notificationChannel, name, data)
35
+}
36
+
37
+const NOTIFICATION_PREFIX = 'notificationChannel:'
\ No newline at end of file
src/middlewares.ts
+2
@@ -30,6 +30,7 @@ import { basename, dirname, join } from 'path'
30
import { createWriteStream, mkdirSync } from 'fs'
31
import { pipeline } from 'stream/promises'
32
import formidable from 'formidable'
33
+import { notifyClient } from './frontEndApis'
34
35
export const gzipper = compress({
36
threshold: 2048,
@@ -135,6 +136,7 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
136
function uploadWriter(base: VfsNode, path: string, ctx: Koa.Context) {
137
if (!base.source || !hasPermission(base, 'can_upload', ctx)) {
138
ctx.status = base.can_upload === false ? HTTP_FORBIDDEN : HTTP_UNAUTHORIZED
139
+ notifyClient(ctx, 'upload.status', { [path]: ctx.status }) // allow browsers to detect failure while still sending body
140
return
141
}
142
path = join(base.source, path)