@samitouri / QOSami-HFS-2 / commits / a33ca006
Fork 0

QOSami-HFS is a web file server for your computer. Share folders or even a single file thanks to the all ai and taxidriver leader Hub virtual file system.

selection checkboxes are now disabled on entries that cannot be archived or deleted

Massimo Melina committed Oct 2, 2024 at 23:15 UTC a33ca00631858fdfe252a0ac396235206b6e1162
8 files changed +113 -95
README.md
+1 -1
@@ -123,7 +123,7 @@ If your language is missing, please consider [translating yourself](https://gith
123
124 - Appending `#LOGIN` to address will bring up the login dialog
125 - Appending ?lang=CODE to address will force a specific language
126 -- Right/ctrl/command click on toggle-all checkbox will invert each checkbox state
126 +- Right-click on toggle-all checkbox will invert each checkbox state
127 - Appending `?login=USER:PASSWORD` will automatically log in the browser
128 - Appending `?overwrite` on uploads, will override the dont_overwrite_uploading configuration, provided you also have delete permission
129 - Appending `?search=PATTERN` will trigger search at start
dev-plugins.md
+83 -73
@@ -312,91 +312,101 @@ You can produce output for such events also by adding sections (with same name a
312 This is a list of available frontend-events, with respective object parameter and output.
313
314 - `additionalEntryDetails`
315 - - you receive each entry of the list, and optionally produce HTML code that will be added in the `entry-details` container.
316 - - parameter `{ entry: DirEntry }`
317 -
318 - The `DirEntry` type is an object with the following properties:
319 - - `name: string` name of the entry.
320 - - `ext: string` just the extension part of the name, dot excluded and lowercase.
321 - - `isFolder: boolean` true if it's a folder.
322 - - `n: string` name of the entry, including relative path when searched in sub-folders.
323 - - `uri: string` relative url of the entry.
324 - - `s?: number` size of the entry, in bytes. It may be missing, for example for folders.
325 - - `t?: Date` generic timestamp, combination of creation-time and modified-time.
326 - - `c?: Date` creation-time.
327 - - `m?: Date` modified-time.
328 - - `p?: string` permissions missing
329 - - `cantOpen: boolean` true if current user has no permission to open this entry
330 - - `getNext/getPrevious: ()=>DirEntry` return next/previous DirEntry in list
331 - - `getNextFiltered/getPreviousFiltered: ()=>DirEntry` as above, but considers the filtered-list instead
332 - - `getDefaultIcon: ()=>ReactElement` produces the default icon for this entry
333 - - output `Html`
315 + - you receive each entry of the list, and optionally produce HTML code that will be added in the `entry-details` container.
316 + - parameter `{ entry: DirEntry }`
317 +
318 + The `DirEntry` type is an object with the following properties:
319 + - `name: string` name of the entry.
320 + - `ext: string` just the extension part of the name, dot excluded and lowercase.
321 + - `isFolder: boolean` true if it's a folder.
322 + - `n: string` name of the entry, including relative path when searched in sub-folders.
323 + - `uri: string` relative url of the entry.
324 + - `s?: number` size of the entry, in bytes. It may be missing, for example for folders.
325 + - `t?: Date` generic timestamp, combination of creation-time and modified-time.
326 + - `c?: Date` creation-time.
327 + - `m?: Date` modified-time.
328 + - `p?: string` permissions missing
329 + - `cantOpen: boolean` true if current user has no permission to open this entry
330 + - `getNext/getPrevious: ()=>DirEntry` return next/previous DirEntry in list
331 + - `getNextFiltered/getPreviousFiltered: ()=>DirEntry` as above, but considers the filtered-list instead
332 + - `getDefaultIcon: ()=>ReactElement` produces the default icon for this entry
333 + - output `Html`
334 - `entry`
335 - - you receive each entry of the list, and optionally produce HTML code that will completely replace the entry row/slot.
336 - - parameter `{ entry: DirEntry }` (refer above for DirEntry object)
337 - - output `Html | null` return null if you want to hide this entry
335 + - you receive each entry of the list, and optionally produce HTML code that will completely replace the entry row/slot.
336 + - parameter `{ entry: DirEntry }` (refer above for DirEntry object)
337 + - output `Html | null` return null if you want to hide this entry
338 - `afterEntryName`
339 - - you receive each entry of the list, and optionally produce HTML code that will be added after the name of the entry.
340 - - parameter `{ entry: DirEntry }` (refer above for DirEntry object)
341 - - output `Html`
339 + - you receive each entry of the list, and optionally produce HTML code that will be added after the name of the entry.
340 + - parameter `{ entry: DirEntry }` (refer above for DirEntry object)
341 + - output `Html`
342 - `entryIcon`
343 - - you receive an entry of the list and optionally produce HTML that will be used in place of the standard icon.
344 - - parameter `{ entry: DirEntry }` (refer above for DirEntry object)
345 - - output `Html`
343 + - you receive an entry of the list and optionally produce HTML that will be used in place of the standard icon.
344 + - parameter `{ entry: DirEntry }` (refer above for DirEntry object)
345 + - output `Html`
346 - `beforeHeader` & `afterHeader`
347 - - use this to produce content that should go right before/after the `header` part
348 - - output `Html`
347 + - use this to produce content that should go right before/after the `header` part
348 + - output `Html`
349 - `beforeLogin`
350 - - no parameter
351 - - output `Html`
350 + - no parameter
351 + - output `Html`
352 - `fileMenu`
353 - - add or manipulate entries of the menu. If you return something, that will be added to the menu.
354 - You can also delete or replace the content of the `menu` array.
355 - - parameter `{ entry: DirEntry, menu: FileMenuEntry[], props: FileMenuProp[] }`
356 - - output `undefined | FileMenuEntry | FileMenuEntry[]`
357 - ```typescript
358 - interface FileMenuEntry {
359 - id?: string,
360 - label: ReactNode,
361 - subLabel: ReactNode,
362 - href?: string, // use this if you want your entry to be a link
363 - icon?: string, // supports: emoji, name from a limited set
364 - onClick?: () => (Promisable<boolean>) // return false to not close menu dialog
365 - //...rest is transfered to <a> element, for example 'target', or 'title'
366 - }
367 - type FileMenuProp = { id?: string, label: ReactNode, value: ReactNode } | ReactElement
368 - ```
369 - Example, if you want to remove the 'show' item of the menu:
370 - ```typescript
371 - HFS.onEvent('fileMenu', ({ entry, menu }) => {
372 - const index = menu.findIndex(x => x.id === 'show')
373 - if (index >= 0)
374 - menu.splice(index, 1)
375 - })
376 - ```
377 - or if you like lodash, you can simply `HFS._.remove(menu, { id: 'show' })`
353 + - add or manipulate entries of the menu. If you return something, that will be added to the menu.
354 + You can also delete or replace the content of the `menu` array.
355 + - parameter `{ entry: DirEntry, menu: FileMenuEntry[], props: FileMenuProp[] }`
356 + - output `undefined | FileMenuEntry | FileMenuEntry[]`
357 + ```typescript
358 + interface FileMenuEntry {
359 + id?: string,
360 + label: ReactNode,
361 + subLabel: ReactNode,
362 + href?: string, // use this if you want your entry to be a link
363 + icon?: string, // supports: emoji, name from a limited set
364 + onClick?: () => (Promisable<boolean>) // return false to not close menu dialog
365 + //...rest is transfered to <a> element, for example 'target', or 'title'
366 + }
367 + type FileMenuProp = { id?: string, label: ReactNode, value: ReactNode } | ReactElement
368 + ```
369 + Example, if you want to remove the 'show' item of the menu:
370 + ```typescript
371 + HFS.onEvent('fileMenu', ({ entry, menu }) => {
372 + const index = menu.findIndex(x => x.id === 'show')
373 + if (index >= 0)
374 + menu.splice(index, 1)
375 + })
376 + ```
377 + or if you like lodash, you can simply `HFS._.remove(menu, { id: 'show' })`
378 - `fileShow`
379 - - you receive an entry of the list, and optionally produce React Component for visualization.
380 - - parameter `{ entry: DirEntry }` (refer above for DirEntry object)
381 - - output `ReactComponent`
379 + - you receive an entry of the list, and optionally produce React Component for visualization.
380 + - parameter `{ entry: DirEntry }` (refer above for DirEntry object)
381 + - output `ReactComponent`
382 - `showPlay`
383 - - emitted on each file played inside file-show. Use setCover if you want to customize the background picture.
384 - - parameter `{ entry: DirEntry, setCover(uri: string), meta: { title, album, artist, year } }`
383 + - emitted on each file played inside file-show. Use setCover if you want to customize the background picture.
384 + - parameter `{ entry: DirEntry, setCover(uri: string), meta: { title, album, artist, year } }`
385 - `menuZip`
386 - - parameter `{ def: ReactNode }`
387 - - output `Html`
386 + - parameter `{ def: ReactNode }`
387 + - output `Html`
388 - `userPanelAfterInfo`
389 - - no parameter
390 - - output `Html`
389 + - no parameter
390 + - output `Html`
391 - `uriChanged`
392 - - DEPRECATED: use `watchState('uri', callback)` instead.
393 - - parameter `{ uri: string, previous: string }`
392 + - DEPRECATED: use `watchState('uri', callback)` instead.
393 + - parameter `{ uri: string, previous: string }`
394 - `sortCompare`
395 - - you can decide the order of entries by comparing two entries.
396 - Return a negative value if entry `a` must appear before `b`, or positive if you want the opposite.
397 - Return zero or any falsy value if you want to leave the order to what the user decided in his options.
398 - - parameter `{ a: DirEntry, b: DirEntry }`
399 - - output `number | undefined`
395 + - you can decide the order of entries by comparing two entries.
396 + Return a negative value if entry `a` must appear before `b`, or positive if you want the opposite.
397 + Return zero or any falsy value if you want to leave the order to what the user decided in his options.
398 + - parameter `{ a: DirEntry, b: DirEntry }`
399 + - output `number | undefined`
400 +- `enableEntrySelection`
401 + - selection of multiple entries is used for some standard actions like deletion or zip.
402 + When none of such standard actions is permitted on an entry, its selection control (checkbox) is disabled.
403 + If you want to override this behavior, because you have a custom action that makes use of the selection, return `true`.
404 + - parameter `{ entry: DirEntry }`
405 + - output `boolean`
406 +- `entryToggleSelection`
407 + - an entry is being un/selected
408 + - parameter `{ entry: DirEntry }`
409 + - can be prevented
410 - All of the following have no parameters and you are supposed to output `Html` that will be displayed in the described place:
411 - `appendMenuBar` inside menu-bar, at the end
412 - `afterMenuBar` between menu-bar and breadcrumbs
frontend/src/BrowseFiles.ts
+3 -2
@@ -4,7 +4,7 @@ import { Link, useNavigate } from 'react-router-dom'
4 import { createElement as h, Fragment, memo, MouseEvent, useCallback, useEffect, useMemo, useRef, useState,
5 useId} from 'react'
6 import { useEventListener, useMediaQuery, useWindowSize } from 'usehooks-ts'
7 -import { domOn, formatBytes, ErrorMsg, hIcon, onlyTruthy, noAriaTitle, prefix, isMac, isCtrlKey } from './misc'
7 +import { domOn, formatBytes, ErrorMsg, hIcon, onlyTruthy, noAriaTitle, prefix, isMac, isCtrlKey, hfsEvent } from './misc'
8 import { Checkbox, CustomCode, iconBtn, Spinner } from './components'
9 import { Head } from './Head'
10 import { DirEntry, state, useSnapState } from './state'
@@ -257,10 +257,11 @@ const Entry = ({ entry, midnight, separator }: EntryProps) => {
257 entry,
258 render: x => x ? h('li', { className, label: separator }, x) : _.remove(state.list, { n }) && null
259 }, showFilter && h(Checkbox, {
260 - disabled: isLink,
260 + disabled: !entry.canSelect(),
261 'aria-labelledby': ariaId,
262 value: selected[uri] || false,
263 onChange(v) {
264 + if (hfsEvent('entryToggleSelection', { entry }).isDefaultPrevent()) return
265 if (v)
266 return state.selected[uri] = true
267 delete state.selected[uri]
frontend/src/FilterBar.ts
+8 -12
@@ -4,7 +4,7 @@ import { useDebounce } from 'usehooks-ts'
4 import { Checkbox } from './components'
5 import { useI18N } from './i18n'
6 import { usePath } from './useFetchList'
7 -import { with_ } from './misc'
7 +import { getHFS, with_ } from './misc'
8
9 export function FilterBar() {
10 const { list, filteredList, selected, patternFilter, showFilter } = useSnapState()
@@ -14,13 +14,8 @@ export function FilterBar() {
14 const {t} = useI18N()
15
16 state.patternFilter = useDebounce(showFilter ? filter : '', 300)
17 + useEffect(() => getHFS().onEvent('entryToggleSelection', () => setAll(false)), [])
18
18 - const sel = Object.keys(selected).length
19 - const fil = filteredList?.length
20 - useEffect(() => {
21 - if (all && sel < (fil || list.length))
22 - setAll(false)
23 - }, [sel])
19 const tabIndex = showFilter ? undefined : -1
20 return h('div', { id: 'filter-bar', style: { display: showFilter ? undefined : 'none' } },
21 h(Checkbox, {
@@ -49,19 +44,20 @@ export function FilterBar() {
44 }
45 }),
46 h('span', {}, [
52 - sel && t('select_count', { n:sel }, "{n} selected"),
53 - fil !== undefined && fil < list.length && t('filter_count', {n:fil}, "{n} filtered"),
47 + with_(Object.keys(selected).length, n => n && t('select_count', { n }, "{n} selected")),
48 + with_(filteredList?.length, n => n !== undefined && n < list.length && t('filter_count', {n}, "{n} filtered")),
49 ].filter(Boolean).join(', ') ),
50 )
51
57 - function select(will: boolean | undefined) {
52 + function select(will: boolean | undefined) { // undefined will cause toggle of each element
53 const sel = state.selected
59 - for (const { uri } of state.filteredList || state.list) {
54 + for (const e of state.filteredList || state.list) {
55 + const { uri } = e
56 const was = sel[uri] || false
57 if (was === will) continue
58 if (was)
59 delete sel[uri]
64 - else
60 + else if (e.canSelect())
61 sel[uri] = true
62 }
63 if (will !== undefined)
frontend/src/fileMenu.ts
+2 -3
@@ -29,10 +29,9 @@ interface FileMenuEntry {
29 export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (Falsy | FileMenuEntry | 'open' | 'delete' | 'show')[]) {
30 const { uri, isFolder, s } = entry
31 const canRead = !entry.p?.includes('r')
32 - const canArchive = entry.p?.includes('A') || state.props?.can_archive && !entry.p?.includes('a')
32 const canList = !entry.p?.match(/L/i)
33 const forbidden = entry.cantOpen === DirEntry.FORBIDDEN
35 - const cantDownload = forbidden || isFolder && !(canRead && canArchive && canList) // folders needs list+read+archive
34 + const cantDownload = forbidden || isFolder && !(canRead && entry.canArchive() && canList) // folders needs list+read+archive
35 const menu = [
36 !cantDownload && { id: 'download', label: t`Download`, href: uri + (isFolder ? '?get=zip' : '?dl'), icon: 'download' },
37 state.props?.can_comment && { id: 'comment', label: t`Comment`, icon: 'comment', onClick: () => editComment(entry) },
@@ -50,7 +49,7 @@ export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (Falsy
49 return !isFolder || open.onClick ? open : h(LinkClosingDialog, { to: uri, reloadDocument: entry.web }, hIcon(open.icon), open.label)
50 }
51 if (x === 'delete')
53 - return (state.props?.can_delete || entry.p?.includes('d')) && {
52 + return entry.canDelete() && {
53 id: 'delete',
54 label: t`Delete`,
55 icon: 'delete',
frontend/src/state.ts
+13 -1
@@ -3,7 +3,7 @@
3 import _ from 'lodash'
4 import { proxy, useSnapshot } from 'valtio'
5 import { subscribeKey } from 'valtio/utils'
6 -import { FRONTEND_OPTIONS, getHFS, hIcon, objSameKeys, pathEncode, StringifyProps, typedKeys } from './misc'
6 +import { FRONTEND_OPTIONS, getHFS, hfsEvent, hIcon, objSameKeys, pathEncode, StringifyProps, typedKeys } from './misc'
7 import { DirEntry as ServerDirEntry } from '../../src/api.get_file_list'
8
9 export const state = proxy<typeof FRONTEND_OPTIONS & {
@@ -151,6 +151,18 @@ export class DirEntry implements StringifyProps<ServerDirEntry> {
151 getDefaultIcon() {
152 return hIcon(this.icon === true ? `${this.n}?get=icon` : (this.icon ?? (this.isFolder || this.web ? 'folder' : this.url ? 'link' : ext2type(this.ext) || 'file')))
153 }
154 +
155 + canArchive() {
156 + return this.p?.includes('A') || state.props?.can_archive && !this.p?.includes('a')
157 + }
158 + canDelete() {
159 + return state.props?.can_delete || this.p?.includes('d')
160 + }
161 + canSelect() {
162 + if (this.url) return false
163 + return this.canArchive() || this.canDelete() // selection is used only by zip and delete, but consider custom logic from plugins
164 + || hfsEvent('enableEntrySelection', { entry: this }).some(Boolean)
165 + }
166 }
167 export type DirList = DirEntry[]
168
src/api.get_file_list.ts
+2 -2
@@ -40,7 +40,7 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, search
40 const walker = walkNode(node, { ctx: admin ? undefined : ctx, onlyFolders, depth: search ? Infinity : 0 })
41 const onDirEntryHandlers = mapPlugins(plug => plug.onDirEntry)
42 const can_upload = admin || hasPermission(node, 'can_upload', ctx)
43 - const fakeChild = await applyParentToChild({ source: 'x' }, node) // can we delete children
43 + const fakeChild = await applyParentToChild({ source: 'dummy-file' }, node) // used to check permission; simple but but can produce false results
44 const can_delete = admin || hasPermission(fakeChild, 'can_delete', ctx)
45 const can_archive = admin || hasPermission(fakeChild, 'can_archive', ctx)
46 const can_comment = can_upload && areCommentsEnabled()
@@ -123,7 +123,7 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, search
123 : !hasPermission(node, 'can_read', ctx) ? 'R'
124 : ''
125 const pd = !can_delete && hasPermission(node, 'can_delete', ctx) ? 'd' : ''
126 - const pa = isFolder && Boolean(can_archive) === hasPermission(node, 'can_archive', ctx) ? '' : can_archive ? 'a' : 'A'
126 + const pa = Boolean(can_archive) === hasPermission(node, 'can_archive', ctx) ? '' : can_archive ? 'a' : 'A'
127 return {
128 n: name + (isFolder ? '/' : ''),
129 c: st?.ctime,
src/vfs.ts
+1 -1
@@ -4,7 +4,7 @@ import fs from 'fs/promises'
4 import { basename, dirname, join, resolve } from 'path'
5 import {
6 dirStream, getOrSet, isDirectory, makeMatcher, setHidden, onlyTruthy, isValidFileName, throw_, VfsPerms, Who,
7 - isWhoObject, WHO_ANY_ACCOUNT, defaultPerms, PERM_KEYS, removeStarting, HTTP_SERVER_ERROR, try_, _log
7 + isWhoObject, WHO_ANY_ACCOUNT, defaultPerms, PERM_KEYS, removeStarting, HTTP_SERVER_ERROR, try_
8 } from './misc'
9 import Koa from 'koa'
10 import _ from 'lodash'