file menu: uploader #271
Massimo Melina committed
Jun 22, 2023 at 18:55 UTC
2cb498228832cea84a5cb1f435ceda3776cf548c
7 files changed
+56
-9
frontend/src/fileMenu.ts
+9
-4
@@ -1,5 +1,5 @@
1
import { t, useI18N } from './i18n'
2
-import { dontBotherWithKeys, formatBytes, hfsEvent, hIcon, newDialog } from './misc'
2
+import { dontBotherWithKeys, formatBytes, hfsEvent, hIcon, newDialog, prefix, with_ } from './misc'
3
import { createElement as h, Fragment, isValidElement, MouseEventHandler, MouseEvent, ReactNode } from 'react'
4
import _ from 'lodash'
5
import { getEntryIcon, MISSING_PERM } from './BrowseFiles'
@@ -8,7 +8,7 @@ import { deleteFiles } from './menu'
8
import { Link } from 'react-router-dom'
9
import { fileShow, getShowType } from './show'
10
import { alertDialog, promptDialog } from './dialog'
11
-import { apiCall } from '@hfs/shared/api'
11
+import { apiCall, useApi } from '@hfs/shared/api'
12
13
interface FileMenuEntry {
14
label: ReactNode
@@ -19,6 +19,7 @@ interface FileMenuEntry {
19
20
export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (FileMenuEntry | 'open' | 'delete' | 'show')[]) {
21
const { uri, isFolder } = entry
22
+ const fullUri = uri[0] === '/' ? uri : location.pathname + uri
23
const cantDownload = entry.cantOpen || isFolder && entry.p?.includes('r') // folders needs both list and read
24
const menu = [
25
!cantDownload && { label: t`Download`, href: uri + (isFolder ? '?get=zip' : '?dl'), icon: 'download' },
@@ -26,7 +27,7 @@ export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (FileMe
27
if (x === 'open') {
28
if (entry.cantOpen) return
29
const open = { icon: 'play', label: t('file_open', "Open"), href: uri, target: isFolder ? undefined : '_blank' }
29
- return !isFolder ? open : h(Link, { to: location.pathname + uri, onClick: () => close() }, hIcon(open.icon), open.label)
30
+ return !isFolder ? open : h(Link, { to: fullUri, onClick: () => close() }, hIcon(open.icon), open.label)
31
}
32
if (x === 'delete')
33
return state.can_delete && {
@@ -62,9 +63,13 @@ export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (FileMe
63
: [ev.pageX, ev.pageY - scrollY] as [number, number],
64
Content() {
65
const {t} = useI18N()
66
+ const [details] = useApi('get_file_details', { uri: fullUri })
67
+ const showProps = [ ...props,
68
+ with_(details?.upload, x => x && [ t`Uploader`, x.ip + prefix(' (', x.username, ')') ])
69
+ ]
70
return h(Fragment, {},
71
h('dl', { className: 'file-dialog-properties' },
67
- dontBotherWithKeys(props.map(prop => isValidElement(prop) ? prop
72
+ dontBotherWithKeys(showProps.map(prop => isValidElement(prop) ? prop
73
: Array.isArray(prop) ? h(Fragment, {}, h('dt', {}, prop[0]), h('dd', {}, prop[1]))
74
: null
75
))
package.json
+1
@@ -68,6 +68,7 @@
68
"fast-glob": "^3.2.7",
69
"find-process": "^1.4.7",
70
"formidable": "^2.1.1",
71
+ "fs-extended-attributes": "^1.0.1",
72
"koa": "^2.13.4",
73
"koa-compress": "^5.1.0",
74
"koa-mount": "^4.0.0",
src/frontEndApis.ts
+11
@@ -18,6 +18,7 @@ import {
18
import { hasPermission, urlToNode } from './vfs'
19
import { mkdir, rename, rm } from 'fs/promises'
20
import { dirname, join } from 'path'
21
+import { getUploadMeta } from './upload'
22
23
export const customHeader = defineConfig('custom_header', '')
24
@@ -36,6 +37,16 @@ export const frontEndApis: ApiHandlers = {
37
})
38
},
39
40
+ async get_file_details({ uri }, ctx) {
41
+ apiAssertTypes({ string: { uri } })
42
+ const node = await urlToNode(uri, ctx)
43
+ if (!node)
44
+ return new ApiError(HTTP_NOT_FOUND)
45
+ return {
46
+ upload: node.source && await getUploadMeta(node.source).catch(() => undefined)
47
+ }
48
+ },
49
+
50
async create_folder({ uri, name }, ctx) {
51
apiAssertTypes({ string: { uri, name } })
52
if (!isValidFileName(name) || dirTraversal(name))
src/langs/hfs-lang-en.json
+2
-1
@@ -118,6 +118,7 @@
118
"Show": "Show",
119
"Loading failed": "Loading failed",
120
"Rename": "Rename",
121
- "Operation successful": "Operation successful"
121
+ "Operation successful": "Operation successful",
122
+ "Uploader": "Uploader"
123
}
124
}
src/langs/hfs-lang-it.json
+2
-1
@@ -112,6 +112,7 @@
112
"Show": "Show",
113
"Loading failed": "Caricamento fallito",
114
"Rename": "Rinomina",
115
- "Operation successful": "Operazione riuscita"
115
+ "Operation successful": "Operazione riuscita",
116
+ "Uploader": "Upload da"
117
}
118
}
src/upload.ts
+13
-2
@@ -8,12 +8,13 @@ import {
8
} from './const'
9
import { basename, dirname, extname, join } from 'path'
10
import fs from 'fs'
11
-import { Callback, dirTraversal, try_ } from './misc'
11
+import { Callback, dirTraversal, loadFileAttr, storeFileAttr, try_ } from './misc'
12
import { notifyClient } from './frontEndApis'
13
import { defineConfig } from './config'
14
import { getFreeDiskSync } from './util-os'
15
import { socket2connection, updateConnection } from './connections'
16
import { roundSpeed } from './throttler'
17
+import { getCurrentUsername } from './perm'
18
19
export const deleteUnfinishedUploadsAfter = defineConfig<undefined|number>('delete_unfinished_uploads_after', 86_400)
20
export const minAvailableMb = defineConfig('min_available_mb', 100)
@@ -21,6 +22,12 @@ const dontOverwriteUploading = defineConfig('dont_overwrite_uploading', false)
22
23
const waitingToBeDeleted: Record<string, ReturnType<typeof setTimeout>> = {}
24
25
+const ATTR_UPLOADER = 'uploader'
26
+
27
+export function getUploadMeta(path: string) {
28
+ return loadFileAttr(path, ATTR_UPLOADER)
29
+}
30
+
31
export function uploadWriter(base: VfsNode, path: string, ctx: Koa.Context) {
32
if (dirTraversal(path))
33
return fail(HTTP_FOOL)
@@ -75,9 +82,13 @@ export function uploadWriter(base: VfsNode, path: string, ctx: Koa.Context) {
82
}
83
cancelDeletion(tempName)
84
trackProgress()
78
- ret.once('close', () => {
85
+ ret.once('close', async () => {
86
if (!ctx.req.aborted) {
87
let dest = fullPath
88
+ await storeFileAttr(tempName, ATTR_UPLOADER, {
89
+ username: getCurrentUsername(ctx),
90
+ ip: ctx.ip,
91
+ })
92
if (dontOverwriteUploading.get() && fs.existsSync(dest)) {
93
const ext = extname(dest)
94
const base = dest.slice(0, -ext.length)
src/util-files.ts
+18
-1
@@ -1,7 +1,8 @@
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 fs from 'fs/promises'
4
-import { Promisable, try_, wait } from './misc'
4
+import { Promisable, try_, tryJson, wait } from './misc'
5
+import { promisify } from 'util'
6
import { createWriteStream, mkdirSync, watch } from 'fs'
7
import { basename, dirname } from 'path'
8
import glob from 'fast-glob'
@@ -10,6 +11,8 @@ import { runCmd } from './util-os'
11
import { once, Readable } from 'stream'
12
// @ts-ignore
13
import unzipper from 'unzip-stream'
14
+// @ts-ignore
15
+import fsx from 'fs-extended-attributes'
16
17
export async function isDirectory(path: string) {
18
try { return (await fs.stat(path)).isDirectory() }
@@ -153,4 +156,18 @@ export function createFileWithPath(path: string, options?: Parameters<typeof cre
156
157
export function isValidFileName(name: string) {
158
return !/^\.\.?$|[/:*?"<>|\\]/.test(name)
159
+}
160
+
161
+const FILE_ATTR_PREFIX = 'user.hfs.' // user. prefix to be linux compatible
162
+export function storeFileAttr(path: string, k: string, v: any) {
163
+ return promisify(fsx.set)(path, FILE_ATTR_PREFIX + k, JSON.stringify(v))
164
+ .then(() => true, (e: any) => {
165
+ console.error("couldn't store metadata on", path, String(e.message || e))
166
+ return false
167
+ })
168
+}
169
+
170
+export async function loadFileAttr(path: string, k: string) {
171
+ return tryJson(String(await promisify(fsx.get)(path, FILE_ATTR_PREFIX + k)))
172
+ ?? undefined // normalize, as we get null instead of undefined on windows
173
}
\ No newline at end of file