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)