@samitouri / QOSami-HFS / commits / 2cb49822

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