@samitouri / QOSami-HFS / commits / fd854bb5

better error code for API move_files when the uri_to is not a folder

Massimo Melina committed Jan 21, 2026 at 22:42 UTC fd854bb5b3ed2ffe7ffdf04a3ccff63778b0263c
4 files changed +18 -11
package.json
+1 -1
@@ -20,7 +20,7 @@
20 "server-for-test": "node dist/src --cwd tests/work --config tests --debug",
21 "server-for-test-dev": "cross-env DEV=1 FRONTEND_PROXY=3005 ADMIN_PROXY=3006 nodemon --ignore tests/ --watch src -e ts,tsx --exec tsx src -- --cwd tests/work --config tests",
22 "test": "node --import tsx --test tests/test.ts",
23 - "test-with-server": "npm run port-is-free && tsc && rm -rf tests/work && (node dist/src --cwd tests/work --config tests & echo $! > .server_pid) && sleep 2 && node --import tsx --test tests/test.ts; _exit=$?; if [ -f ./.server_pid ]; then SERVER_PID=$(cat ./.server_pid); kill \"$SERVER_PID\" 2>/dev/null || true; rm -f ./.server_pid; fi; exit $_exit",
23 + "test-with-server": "npm run port-is-free && tsc && rm -rf tests/work tests/tmp && (node dist/src --cwd tests/work --config tests & echo $! > .server_pid) && sleep 2 && node --import tsx --test tests/test.ts; _exit=$?; if [ -f ./.server_pid ]; then SERVER_PID=$(cat ./.server_pid); kill \"$SERVER_PID\" 2>/dev/null || true; rm -f ./.server_pid; fi; exit $_exit",
24 "port-is-free": "node -e \"process.exit(await fetch('http://localhost:81').then(() => console.log('BUSY') || 1, () => 0))\"",
25 "test-ui": "npx playwright test frontend && npx playwright test serial",
26 "test-with-ui": "npx playwright test --ui",
src/frontEndApis.ts
+3 -1
@@ -114,7 +114,9 @@ export const frontEndApis: ApiHandlers = {
114 apiAssertTypes({ array: { uri_from }, string: { uri_to } })
115 ctx.logExtra(null, { target: uri_from.map(decodeURI), destination: decodeURI(uri_to) })
116 const destNode = await urlToNode(uri_to, ctx)
117 - const err = !destNode ? HTTP_NOT_FOUND : statusCodeForMissingPerm(destNode, 'can_upload', ctx)
117 + const err = !destNode ? HTTP_NOT_FOUND
118 + : !nodeIsFolder(destNode) ? HTTP_METHOD_NOT_ALLOWED
119 + : statusCodeForMissingPerm(destNode, 'can_upload', ctx)
120 if (err)
121 return new ApiError(err)
122 return {
src/vfs.ts
+5 -1
@@ -161,7 +161,11 @@ async function isHiddenFile(path: string) {
161
162 export async function getNodeByName(name: string, parent: VfsNode, assumeMissingToBeFolder=false) {
163 // does the tree node have a child that goes by this name, otherwise attempt disk
164 - const child = parent.children?.find(isSameFilenameAs(name)) || await childFromDisk()
164 + let child = parent.children?.find(isSameFilenameAs(name))
165 + if (child) // found as vfs node
166 + await setIsFolder(child) // in case it's pointing to a folder that didn't exist at loading time
167 + else
168 + child = await childFromDisk()
169 return child && applyParentToChild(child, parent, name)
170
171 async function childFromDisk() {
tests/test.ts
+9 -8
@@ -182,9 +182,9 @@ describe('basics', () => {
182 })
183 })
184 test('upload.post.absolute filename', () => {
185 - const absPath = resolve(ROOT, `abs-${randomId(6)}.txt`)
185 + const absPath = resolve(__dirname, `abs-${randomId(6)}.txt`)
186 const absForBody = absPath.replace(/\\\\/g, '/')
187 - const storedPath = resolve(ROOT, 'tmp', basename(absPath))
187 + const storedPath = resolve(__dirname, 'tmp', basename(absPath))
188 return execP(`curl -s -u ${auth} -H "x-hfs-wait: 1" -F "upload=@${SAMPLE_FILE_PATH};filename=${absForBody}" ${BASE_URL}${UPLOAD_ROOT} -w "\\nSTATUS:%{http_code}"`).then(async x => {
189 const out = x.trimEnd()
190 const idx = out.lastIndexOf('\nSTATUS:')
@@ -254,6 +254,7 @@ describe('after-login', () => {
254 })
255 test('upload.never', reqUpload('/random', 403))
256 test('upload.ok', reqUpload(UPLOAD_DEST, 200))
257 + test('move.dest is file', reqApi('move_files', { uri_from: [UPLOAD_DEST], uri_to: UPLOAD_DEST }, 405))
258 test('upload.dot name', reqUpload(`${UPLOAD_ROOT}%2e`, 418))
259 test('upload.unreadable', reqUpload(`${UPLOAD_ROOT}%0a`, 418))
260 test('upload.temp hash traversal', req(`${UPLOAD_ROOT}%2e%2e?get=${UPLOAD_TEMP_HASH}`, 404))
@@ -270,7 +271,7 @@ describe('after-login', () => {
271 test('zip.no-list but archive', req('/zipNoList/?get=zip', 403, { jar: {} }))
272 test('upload but not delete', async () => {
273 const name = `cant-delete`
273 - await mkdir(resolve(ROOT, name), { recursive: true })
274 + await mkdir(resolve(__dirname, name), { recursive: true })
275 await reqApi('add_vfs', { parent: UPLOAD_ROOT, source: `../${name}`, name, can_upload: ['admins'], can_delete: false }, 200)()
276 try {
277 const dest = `${UPLOAD_ROOT}${name}/no-delete.txt`
@@ -279,7 +280,7 @@ describe('after-login', () => {
280 }
281 finally {
282 await reqApi('del_vfs', { uris: [UPLOAD_ROOT + name] }, 200)().catch(() => {})
282 - await rmAny(resolve(ROOT, name))
283 + await rmAny(resolve(__dirname, name))
284 }
285 })
286 test('move.overwrite needs delete', async () => {
@@ -297,13 +298,13 @@ describe('after-login', () => {
298 throw "file overwritten"
299 }
300 finally {
300 - await rmAny(resolve(ROOT, UPLOAD_DIR, destFile))
301 + await rmAny(resolve(__dirname, UPLOAD_DIR, destFile))
302 await rmAny(destDir)
303 }
304 })
305 test('upload.path bypass', async () => {
306 const name = 'no-upload'
306 - const targetDir = resolve(ROOT, 'tmp', name)
307 + const targetDir = resolve(__dirname, 'tmp', name)
308 try {
309 await execP(`curl -g -s -u ${auth} -F "upload=@${SAMPLE_FILE_PATH};filename=${name}/evil.txt" ${BASE_URL}${UPLOAD_ROOT}`)
310 if (existsSync(resolve(targetDir, 'evil.txt')))
@@ -365,7 +366,7 @@ describe('after-login', () => {
366 })
367 test('rename.backslash', async () => {
368 await reqApi('rename', { uri: UPLOAD_DEST, dest: 'sub\\file' }, process.platform === 'win32' ? 403 : 200)()
368 - const d = resolve(ROOT, UPLOAD_DIR)
369 + const d = resolve(__dirname, UPLOAD_DIR)
370 await rename(resolve(d, 'sub\\file'), resolve(d, basename(UPLOAD_DEST))).catch(() => {})
371 })
372 const renameTo = 'z'
@@ -588,7 +589,7 @@ function throwIf(msg: any) {
589 }
590
591 async function ensureCantOverwriteDir() {
591 - const baseDir = resolve(ROOT, 'tmp', CANT_OVERWRITE_NAME)
592 + const baseDir = resolve(__dirname, 'tmp', CANT_OVERWRITE_NAME)
593 await mkdir(baseDir, { recursive: true })
594 return baseDir
595 }