delete

Massimo Melina committed Feb 15, 2023 at 11:14 UTC 88e5e658bca0a0063457a3d6e5e25f1814f04771
15 files changed +133 -35
README.md
+1
@@ -182,6 +182,7 @@ Valid keys in a node are:
182 - `can_see`: specify who can see this entry. Even if a user can download you can still make the file not appear in the list.
183 Remember that to see in the list you must also be able to download (read), or else you won't see it anyway. Value is a `WhoCan` descriptor, refer above.
184 - `can_upload` specify who can upload. Applies to folders with a source. Default is none.
185 +- `can_delete` specify who can delete. Applies to folders with a source. Default is none.
186 - `masks`: maps a file mask to a set of properties as the one documented in this section. E.g.
187 ```
188 masks:
admin/src/FileForm.ts
+2 -1
@@ -20,7 +20,7 @@ export default function FileForm({ file, defaultPerms, addToBar }: { file: VfsNo
20 const { parent, children, isRoot, ...rest } = file
21 const [values, setValues] = useState(rest)
22 useEffect(() => {
23 - setValues(Object.assign({ can_see: null, can_read: null, can_upload: null }, rest))
23 + setValues(Object.assign({ can_see: null, can_read: null, can_upload: null, can_delete: null }, rest))
24 }, [file]) //eslint-disable-line
25
26 const { source } = file
@@ -87,6 +87,7 @@ export default function FileForm({ file, defaultPerms, addToBar }: { file: VfsNo
87 can_read && perm('can_see', "Who can see", "You can hide and keep it downloadable if you have a direct link",
88 { accounts: Array.isArray(can_read) ? allAccounts.filter(x => can_read.includes(x.username)) : undefined }),
89 isDir && perm('can_upload', "Who can upload", hasSource ? '' : "Works only on folders with source"),
90 + isDir && perm('can_delete', "Who can delete", hasSource ? '' : "Works only on folders with source"),
91 hasSource && !realFolder && { k: 'size', comp: DisplayField, lg: 4, toField: formatBytes },
92 showTimestamps && { k: 'ctime', comp: DisplayField, md: 6, lg: 4, label: 'Created', toField: formatTimestamp },
93 showTimestamps && { k: 'mtime', comp: DisplayField, md: 6, lg: 4, label: 'Modified', toField: formatTimestamp },
admin/src/VfsPage.ts
+1
@@ -155,6 +155,7 @@ export interface VfsPerms {
155 can_see?: Who
156 can_read?: Who
157 can_upload?: Who
158 + can_delete?: Who
159 }
160 export interface VfsNode extends VfsPerms {
161 id: string
admin/src/VfsTree.ts
+3 -2
@@ -5,7 +5,7 @@ import { createElement as h, ReactElement, useRef, useState } from 'react'
5 import { TreeItem, TreeView } from '@mui/lab'
6 import {
7 ChevronRight, ExpandMore, TheaterComedy, Folder, Home,
8 - InsertDriveFileOutlined, Lock, RemoveRedEye, Web, Upload, Cloud
8 + InsertDriveFileOutlined, Lock, RemoveRedEye, Web, Upload, Cloud, Delete
9 } from '@mui/icons-material'
10 import { Box } from '@mui/material'
11 import { reloadVfs, VfsNode, Who } from './VfsPage'
@@ -86,7 +86,8 @@ export default function VfsTree({ id2node }:{ id2node: Map<string, VfsNode> }) {
86 isRoot ? iconTooltip(Home, "home, or root if you like")
87 : folder ? iconTooltip(FolderIcon, "Folder")
88 : iconTooltip(FileIcon, "File"),
89 - node.can_upload ? iconTooltip(Upload, "Upload permission")
89 + node.can_delete !== undefined && iconTooltip(Delete, "Delete permission"),
90 + node.can_upload !== undefined ? iconTooltip(Upload, "Upload permission")
91 : !isRoot && !node.source && iconTooltip(Cloud, "Virtual (no source)"),
92 isRestricted(node.can_see) && iconTooltip(RemoveRedEye, "Restrictions on who can see"),
93 isRestricted(node.can_read) && iconTooltip(Lock, "Restrictions on who can download"),
frontend/src/BrowseFiles.ts
+1 -1
@@ -41,7 +41,7 @@ export function BrowseFiles() {
41 }
42
43 function FilesList() {
44 - const { filteredList, list, loading, stoppedSearch, can_upload } = useSnapState()
44 + const { filteredList, list, loading, stoppedSearch, can_upload, can_delete } = useSnapState()
45 const midnight = useMidnight() // as an optimization we calculate this only once per list and pass it down
46 const pageSize = 100
47 const [page, setPage] = useState(0)
frontend/src/UserPanel.ts
+1
@@ -24,6 +24,7 @@ function Content() {
24 h(MenuButton, {
25 icon: 'password',
26 label: "Change password",
27 + onClickAnimation: false,
28 async onClick() {
29 const pwd = await promptDialog("Enter new password", { type: 'password' })
30 if (!pwd) return
frontend/src/menu.ts
+62 -14
@@ -1,20 +1,22 @@
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 { state, useSnapState } from './state'
4 -import { createElement as h, useEffect, useMemo } from 'react'
4 +import { createElement as h, Fragment, useEffect, useMemo, useState } from 'react'
5 import { alertDialog, confirmDialog, ConfirmOptions, promptDialog } from './dialog'
6 -import { hIcon, prefix, useStateMounted } from './misc'
6 +import { err2msg, hError, hIcon, onlyTruthy, prefix, useStateMounted } from './misc'
7 import { loginDialog } from './login'
8 import { showOptions } from './options'
9 import showUserPanel from './UserPanel'
10 -import { useNavigate } from 'react-router-dom'
10 +import { useLocation, useNavigate } from 'react-router-dom'
11 import _ from 'lodash'
12 import { closeDialog } from '@hfs/shared/dialogs'
13 import { showUpload, uploadState } from './upload'
14 import { useSnapshot } from 'valtio'
15 +import { apiCall } from './api'
16 +import { reloadList } from './useFetchList'
17
18 export function MenuPanel() {
17 - const { showFilter, remoteSearch, stopSearch, stoppedSearch, selected, can_upload } = useSnapState()
19 + const { showFilter, remoteSearch, stopSearch, stoppedSearch, selected, can_upload, can_delete } = useSnapState()
20 const { uploading, qs } = useSnapshot(uploadState)
21 useEffect(() => {
22 if (!showFilter)
@@ -27,12 +29,30 @@ export function MenuPanel() {
29 setStarted1secAgo(false)
30 setTimeout(() => setStarted1secAgo(true), 1000)
31 }, [stopSearch, setStarted1secAgo])
32 + const { pathname } = useLocation()
33 +
34 + useEffect(() => {
35 + if (localStorage.warn_can_delete) return
36 + localStorage.warn_can_delete = 1
37 + alertDialog("To delete, first click Select").then()
38 + }, [can_delete])
39
40 // passing files as string in the url should allow 1-2000 items before hitting the url limit of 64KB. Shouldn't be a problem, right?
41 const list = useMemo(() => Object.keys(selected).map(s => s.endsWith('/') ? s.slice(0,-1) : s).join('*'), [selected])
42 return h('div', { id: 'menu-panel' },
43 h('div', { id: 'menu-bar' },
44 h(LoginButton),
45 + showFilter && can_delete ? h(MenuButton, {
46 + icon: 'trash',
47 + label: "Delete",
48 + onClick: () => deleteFiles(Object.keys(selected), pathname)
49 + })
50 + : (can_upload || qs.length > 0) && h(MenuButton, {
51 + icon: 'upload',
52 + label: "Upload",
53 + className: uploading && 'ani-working',
54 + onClick: showUpload,
55 + }),
56 h(MenuButton, {
57 icon: 'check',
58 label: "Select",
@@ -71,12 +91,6 @@ export function MenuPanel() {
91 }
92 }
93 }),
74 - (can_upload || qs.length > 0) && h(MenuButton, {
75 - icon: 'upload',
76 - label: 'Upload',
77 - className: uploading && 'ani-working',
78 - onClick: showUpload,
79 - }),
94 ),
95 remoteSearch && h('div', { id: 'searched' },
96 (stopSearch ? 'Searching' : 'Searched') + ': ' + remoteSearch + prefix(' (', stoppedSearch && 'interrupted', ')')),
@@ -100,6 +114,7 @@ export function MenuPanel() {
114 } : {
115 icon: 'search',
116 label: "Search",
117 + onClickAnimation: false,
118 async onClick() {
119 state.remoteSearch = await promptDialog("Search this folder and sub-folders") || ''
120 }
@@ -114,12 +129,21 @@ interface MenuButtonProps {
129 toggled?: boolean,
130 className?: string,
131 onClick?: () => void
132 + onClickAnimation?: boolean
133 }
134
119 -export function MenuButton({ icon, label, tooltip, toggled, onClick, className = '' }: MenuButtonProps) {
120 - return h('button', { title: tooltip || label, onClick, className: className + ' ' + (toggled ? 'toggled' : '') },
121 - hIcon(icon),
122 - h('label', {}, label))
135 +export function MenuButton({ icon, label, tooltip, toggled, onClick, onClickAnimation, className = '' }: MenuButtonProps) {
136 + const [working, setWorking] = useState(false)
137 + return h('button', {
138 + title: tooltip || label,
139 + onClick() {
140 + if (!onClick) return
141 + if (onClickAnimation !== false)
142 + setWorking(true)
143 + Promise.resolve(onClick()).finally(() => setWorking(false))
144 + },
145 + className: [className, toggled && 'toggled', working && 'ani-working'].filter(Boolean).join(' ')
146 + }, hIcon(icon), h('label', {}, label) )
147 }
148
149 export function MenuLink({ href, target, confirm, confirmOptions, ...rest }: MenuButtonProps & { href: string, target?: string, confirm?: string, confirmOptions?: ConfirmOptions }) {
@@ -145,6 +169,30 @@ function LoginButton() {
169 } : {
170 icon: 'login',
171 label: 'Login',
172 + onClickAnimation: false,
173 onClick: () => loginDialog(navigate),
174 })
175 }
176 +
177 +async function deleteFiles(uris: string[], root: string) {
178 + const n = uris.length
179 + if (!n) {
180 + alertDialog("Select something to delete").then()
181 + return
182 + }
183 + if (!await confirmDialog(`Delete ${n} item(s)?`)) return
184 + const errors = onlyTruthy(await Promise.all(uris.map(uri =>
185 + apiCall('del', { path: root + uri }).then(() => null, err => ({ uri, err }))
186 + )))
187 + reloadList()
188 + const e = errors.length
189 + alertDialog(h(Fragment, {},
190 + `Deletion: ${n - e} completed`,
191 + e > 0 && `, ${e} failed`,
192 + h('div', { style: { textAlign: 'left', marginTop: '1em', } },
193 + ...errors.map(e => h(Fragment, {},
194 + hError(err2msg(e.err) + ': ' + e.uri),
195 + ))
196 + )
197 + )).then()
198 +}
\ No newline at end of file
frontend/src/misc.ts
+12
@@ -8,6 +8,18 @@ import { Dict } from '@hfs/shared'
8 import { state } from './state'
9 export * from '@hfs/shared'
10
11 +export const ERRORS: Record<number, string> = {
12 + 401: "Unauthorized",
13 + 403: "Forbidden",
14 + 404: "Not found",
15 + 500: "Server error",
16 +}
17 +
18 +export function err2msg(err: number | Error) {
19 + return typeof err === 'number' ? ERRORS[err]
20 + : (ERRORS[(err as any).code] || err.message || String(err))
21 +}
22 +
23 export function hIcon(name: string, props?:any) {
24 return h(Icon, { name, ...props })
25 }
frontend/src/state.ts
+2
@@ -29,7 +29,9 @@ export const state = proxy<{
29 loginRequired?: boolean, // force user to login before proceeding
30 messageOnly?: string, // no gui, just show this message
31 can_upload: boolean
32 + can_delete: boolean
33 }>({
34 + can_delete: false,
35 can_upload: false,
36 iconsClass: '',
37 username: '',
frontend/src/useFetchList.ts
+3 -6
@@ -8,6 +8,7 @@ import _ from 'lodash'
8 import { subscribeKey } from 'valtio/utils'
9 import { useIsMounted } from 'usehooks-ts'
10 import { alertDialog } from './dialog'
11 +import { ERRORS } from './misc'
12
13 const API = 'file_list'
14
@@ -42,6 +43,7 @@ export default function useFetchList() {
43 state.loading = true
44 state.error = undefined
45 state.can_upload = false
46 + state.can_delete = false
47 // buffering entries is necessary against burst of events that will hang the browser
48 const buffer: DirList = []
49 const flush = () => {
@@ -67,7 +69,7 @@ export default function useFetchList() {
69 case 'msg':
70 data.forEach(async (entry: any) => {
71 if (entry.props)
70 - return Object.assign(state, _.pick(entry.props, ['can_upload']))
72 + return Object.assign(state, _.pick(entry.props, ['can_upload', 'can_delete']))
73 if (entry.add)
74 return buffer.push(entry.add)
75 const { error } = entry
@@ -100,11 +102,6 @@ export default function useFetchList() {
102 }, [desiredPath, search, snap.username, snap.listReloader, snap.loginRequired])
103 }
104
103 -const ERRORS = {
104 - 401: "Unauthorized",
105 - 404: "Not found",
106 -}
107 -
105 export function reloadList() {
106 state.listReloader = Date.now()
107 }
src/api.file_list.ts
+4 -3
@@ -27,14 +27,15 @@ export const file_list: ApiHandler = async ({ path, offset, limit, search, omit,
27 const walker = walkNode(node, ctx, search ? Infinity : 0)
28 const onDirEntryHandlers = mapPlugins(plug => plug.onDirEntry)
29 const can_upload = hasPermission(node, 'can_upload', ctx)
30 + const can_delete = hasPermission(node, 'can_delete', ctx)
31 if (!sse)
32 return {
32 - can_upload,
33 + can_upload, can_delete,
34 list: await asyncGeneratorToArray(produceEntries())
35 }
36 setTimeout(async () => {
36 - if (can_upload)
37 - list.custom({ props: { can_upload } })
37 + if (can_upload || can_delete)
38 + list.custom({ props: { can_upload, can_delete } })
39 for await (const entry of produceEntries())
40 list.add(entry)
41 list.close()
src/frontEndApis.ts
+38 -4
@@ -7,9 +7,16 @@ import { defineConfig } from './config'
7 import events from './events'
8 import Koa from 'koa'
9 import { dirTraversal, isValidFileName } from './util-files'
10 -import { HTTP_BAD_REQUEST, HTTP_CONFLICT, HTTP_FORBIDDEN, HTTP_NOT_FOUND } from './const'
10 +import {
11 + HTTP_BAD_REQUEST,
12 + HTTP_CONFLICT,
13 + HTTP_FORBIDDEN,
14 + HTTP_NOT_FOUND,
15 + HTTP_SERVER_ERROR,
16 + HTTP_UNAUTHORIZED
17 +} from './const'
18 import { hasPermission, urlToNode } from './vfs'
12 -import { mkdir } from 'fs/promises'
19 +import { mkdir, rm } from 'fs/promises'
20 import { join } from 'path'
21
22 const customHeader = defineConfig('custom_header')
@@ -23,6 +30,7 @@ export const frontEndApis: ApiHandlers = {
30 },
31
32 get_notifications({ channel }, ctx) {
33 + apiAssertTypes({ string: { channel } })
34 const list = new SendListReadable()
35 list.ready() // on chrome109 EventSource doesn't emit 'open' until something is sent
36 return list.events(ctx, {
@@ -33,9 +41,10 @@ export const frontEndApis: ApiHandlers = {
41 },
42
43 async create_folder({ path, name }, ctx) {
44 + apiAssertTypes({ string: { path, name } })
45 if (!isValidFileName(name) || dirTraversal(name))
46 return new ApiError(HTTP_BAD_REQUEST, 'bad name')
38 - const parentNode = await urlToNode(path)
47 + const parentNode = await urlToNode(path, ctx)
48 if (!parentNode)
49 return new ApiError(HTTP_NOT_FOUND, 'parent not found')
50 const { source } = parentNode
@@ -50,6 +59,24 @@ export const frontEndApis: ApiHandlers = {
59 }
60 },
61
62 + async del({ path }, ctx) {
63 + apiAssertTypes({ string: { path } })
64 + const node = await urlToNode(path, ctx)
65 + if (!node)
66 + throw new ApiError(HTTP_NOT_FOUND)
67 + if (!node.source)
68 + throw new ApiError(HTTP_FORBIDDEN)
69 + if (!hasPermission(node, 'can_delete', ctx))
70 + throw new ApiError(HTTP_UNAUTHORIZED)
71 + try {
72 + await rm(node.source, { recursive: true })
73 + return {}
74 + }
75 + catch (e: any) {
76 + throw new ApiError(e.code || HTTP_SERVER_ERROR, e)
77 + }
78 + },
79 +
80 }
81
82 export function notifyClient(ctx: Koa.Context, name: string, data: any) {
@@ -58,4 +85,11 @@ export function notifyClient(ctx: Koa.Context, name: string, data: any) {
85 events.emit(NOTIFICATION_PREFIX + notificationChannel, name, data)
86 }
87
61 -const NOTIFICATION_PREFIX = 'notificationChannel:'
\ No newline at end of file
88 +const NOTIFICATION_PREFIX = 'notificationChannel:'
89 +
90 +function apiAssertTypes(paramsByType: { [type:string]: { [name:string]: any } }) {
91 + for (const [type,params] of Object.entries(paramsByType))
92 + for (const [name,val] of Object.entries(params))
93 + if (typeof val !== type)
94 + throw new ApiError(HTTP_BAD_REQUEST, 'bad ' + name)
95 +}
\ No newline at end of file
src/vfs.ts
+2
@@ -25,6 +25,7 @@ interface VfsPerm {
25 can_read: Who
26 can_see: Who // use this to hide something you can_read
27 can_upload: Who
28 + can_delete: Who
29 }
30
31 type Masks = Record<string, VfsNode>
@@ -46,6 +47,7 @@ export const defaultPerms: VfsPerm = {
47 can_see: WHO_ANYONE,
48 can_read: WHO_ANYONE,
49 can_upload: WHO_NO_ONE,
50 + can_delete: WHO_NO_ONE,
51 }
52
53 export const MIME_AUTO = 'auto'
tests/test.ts
+1 -1
@@ -3,7 +3,7 @@ import { wrapper } from 'axios-cookiejar-support'
3 import { CookieJar } from 'tough-cookie'
4 import { Done } from 'mocha'
5 import { srpSequence } from '@hfs/shared/srp'
6 -import { createReadStream, rmSync, unlinkSync } from 'fs'
6 +import { createReadStream, rmSync } from 'fs'
7 import { join } from 'path'
8 /*
9 import { PORT, srv } from '../src'
todo.md
-3
@@ -15,10 +15,8 @@
15 - show public ip use, https://github.com/sindresorhus/public-ip
16 - configure router with upnp. If it fails, suggest a guide. https://github.com/indutny/node-nat-upnp
17 - offer ddns registration/update
18 -- use dialogs instead of side-forms on mobile (admin/fs+accounts)
18 - blacklist of plugins (as a temporary measure until GitHub's intervention)
19 - admin/fs: sort items
21 -- admin/fs: render virtual folders differently
20 - admin/config: hide advanced settings
21 - admin/fs: support insert/delete key
22 - admin/fs: button "copy url to clipboard"
@@ -43,7 +41,6 @@
41 - plugin: make letsencrypt easier
42 - could be just automatic detection of files by certbot
43 - letsencrypt supports plugins to automatically configure webservers
46 -- delete
44 - updater (stop,unzip,start)
45 - node.comment
46 - config: max connections/downloads (total/per-ip)