admin/fs: add web link

Massimo Melina committed Nov 1, 2023 at 17:59 UTC 5b88ec640c800c513edf30dccda7abf8d270b933
13 files changed +90 -60
admin/src/ConfigFilePage.ts
+2 -4
@@ -3,7 +3,7 @@
3 import { createElement as h, Fragment, useEffect, useState } from 'react';
4 import { apiCall, useApiEx } from './api'
5 import { Alert, Box } from '@mui/material'
6 -import { Btn, Flex, IconBtn, isCtrlKey, KeepInScreen, modifiedSx, reloadBtn } from './misc';
6 +import { Btn, Flex, focusSelector, IconBtn, isCtrlKey, KeepInScreen, modifiedSx, reloadBtn } from './misc';
7 import { Save, ContentCopy, EditNote } from '@mui/icons-material'
8 import { TextEditor } from './TextEditor';
9 import { state } from './state';
@@ -36,9 +36,7 @@ export default function ConfigFilePage() {
36 labelFrom: 'sm',
37 onClick() {
38 setEdit(true)
39 - const el = document.querySelector('main textarea')
40 - //@ts-ignore
41 - setTimeout(() => el.focus(), 500)
39 + setTimeout(() => focusSelector('main textarea'), 500)
40 }
41 }, "Edit"),
42 h(Box, { flex: 1, minWidth: 'fit-content' }, h(DisplayField, { label: "File path", value: data?.fullPath }))
admin/src/FileForm.ts
+10 -9
@@ -55,11 +55,12 @@ export default function FileForm({ file, anyMask, addToBar, statusApi }: FileFor
55 }, [file])
56 const { source } = file
57 const isDir = file.type === 'folder'
58 + const isLink = values.url !== undefined
59 const hasSource = source !== undefined // we need a boolean
60 const realFolder = hasSource && isDir
61 const lg = useBreakpoint('lg')
61 - const showTimestamps = lg || hasSource
62 - const showSize = lg || (hasSource && !realFolder)
62 + const showTimestamps = !isLink && (lg || hasSource)
63 + const showSize = !isLink && lg || (hasSource && !realFolder)
64 const showAccept = file.accept! > '' || isDir && (file.can_upload ?? file.inherited?.can_upload)
65 const showWebsite = isDir
66 const barColors = useDialogBarColors()
@@ -74,7 +75,6 @@ export default function FileForm({ file, anyMask, addToBar, statusApi }: FileFor
75 return h(Form, {
76 values,
77 set(v, k) {
77 - if (k === 'link') return
78 setValues(values => {
79 const nameIsVirtual = k === 'source' && values.name && values.source?.endsWith(values.name)
80 const name = nameIsVirtual ? basename(v) : values.name // update name if virtual
@@ -126,14 +126,15 @@ export default function FileForm({ file, anyMask, addToBar, statusApi }: FileFor
126 },
127 fields: [
128 isRoot ? h(Alert,{ severity: 'info' }, "This is Home, the root of your shared files. Options set here will be applied to all files.")
129 - : { k: 'name', required: true, xl: 6, helperText: hasSource && "You can decide a name that's different from the one on your disk" },
130 - { k: 'source', label: "Source on disk", xl: true, comp: FileField, files: !isDir, folders: isDir,
131 - helperText: !values.source && "Not on disk, this is a virtual folder",
129 + : { k: 'name', required: true, xl: true, helperText: hasSource && "You can decide a name that's different from the one on your disk" },
130 + isLink ? { k: 'url', label: "URL", lg: 12, required: true }
131 + : { k: 'source', label: "Source on disk", xl: true, comp: FileField, files: !isDir, folders: isDir,
132 + helperText: !values.source && "Not on disk, this is a virtual folder",
133 },
133 - { k: 'id', comp: LinkField, statusApi, xs: 12 },
134 - perm('can_read', "Who can see but not download will be asked to login"),
134 + !isLink && { k: 'id', comp: LinkField, statusApi, xs: 12 },
135 + !isLink && perm('can_read', "Who can see but not download will be asked to login"),
136 perm('can_see', "If you can't see, you may still download with a direct link"),
136 - perm('can_archive', "Should this be included when user downloads as ZIP", { label: "Who can zip", lg: isDir ? true : 12 }),
137 + !isLink && perm('can_archive', "Should this be included when user downloads as ZIP", { label: "Who can zip", lg: isDir ? true : 12 }),
138 isDir && perm('can_list', "Permission to see content of folders", { contentText: "subfolders" }),
139 isDir && perm('can_delete', [needSourceWarning, "Those who can delete can also rename"]),
140 isDir && perm('can_upload', needSourceWarning, { contentText: "subfolders" }),
admin/src/VfsMenuBar.ts
+3 -2
@@ -4,7 +4,7 @@ import { createElement as h } from 'react'
4 import { Alert, Box } from '@mui/material'
5 import { Add, Microsoft } from '@mui/icons-material'
6 import { reloadVfs } from './VfsPage'
7 -import addFiles, { addVirtual } from './addFiles'
7 +import addFiles, { addLink, addVirtual } from './addFiles'
8 import MenuButton from './MenuButton'
9 import { Btn, reloadBtn } from './misc'
10 import { apiCall, useApi } from './api'
@@ -29,7 +29,8 @@ export default function VfsMenuBar({ status }: any) {
29 startIcon: h(Add),
30 items: [
31 { children: "from disk", onClick: addFiles },
32 - { children: "virtual folder", onClick: addVirtual }
32 + { children: "virtual folder", onClick: addVirtual },
33 + { children: "web-link", onClick: addLink },
34 ]
35 }, "Add"),
36 reloadBtn(() => reloadVfs()),
admin/src/VfsTree.ts
+4 -4
@@ -3,9 +3,8 @@
3 import { state, useSnapState } from './state'
4 import { createElement as h, ReactElement, useRef, useState } from 'react'
5 import { TreeItem, TreeView } from '@mui/x-tree-view'
6 -import {
7 - ChevronRight, ExpandMore, TheaterComedy, Folder, Home,
8 - InsertDriveFileOutlined, Lock, RemoveRedEye, Web, Upload, Cloud, Delete, HighlightOff
6 +import { ChevronRight, ExpandMore, TheaterComedy, Folder, Home, Link, InsertDriveFileOutlined, Lock,
7 + RemoveRedEye, Web, Upload, Cloud, Delete, HighlightOff
8 } from '@mui/icons-material'
9 import { Box } from '@mui/material'
10 import { reloadVfs, VfsNode } from './VfsPage'
@@ -84,6 +83,7 @@ export default function VfsTree({ id2node }:{ id2node: Map<string, VfsNode> }) {
83 h(Box, { display: 'flex', flex: 0, },
84 isRoot ? iconTooltip(Home, "home, or root if you like")
85 : folder ? iconTooltip(FolderIcon, "Folder")
86 + : node.url ? iconTooltip(Link, "Web-link")
87 : iconTooltip(FileIcon, "File"),
88 // attributes
89 h(Box, { sx: {
@@ -92,7 +92,7 @@ export default function VfsTree({ id2node }:{ id2node: Map<string, VfsNode> }) {
92 } },
93 node.can_delete !== undefined && iconTooltip(Delete, "Delete permission"),
94 node.can_upload !== undefined && iconTooltip(Upload, "Upload permission"),
95 - !isRoot && !node.source && iconTooltip(Cloud, "Virtual (no source)"),
95 + !isRoot && !node.source && !node.url && iconTooltip(Cloud, "Virtual (no source)"),
96 isRestricted(node.can_see) && iconTooltip(RemoveRedEye, "Restrictions on who can see"),
97 isRestricted(node.can_read) && iconTooltip(Lock, "Restrictions on who can download"),
98 node.default && iconTooltip(Web, "Act as website"),
admin/src/addFiles.ts
+16 -1
@@ -1,12 +1,13 @@
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 { alertDialog, newDialog, promptDialog } from './dialog'
3 +import { alertDialog, newDialog, promptDialog, toast } from './dialog'
4 import { createElement as h, Fragment } from 'react'
5 import { Box } from '@mui/material'
6 import { reloadVfs } from './VfsPage'
7 import { state } from './state'
8 import { apiCall } from './api'
9 import FilePicker from './FilePicker'
10 +import { focusSelector } from '@hfs/shared'
11
12 export default function addFiles() {
13 const { close } = newDialog({
@@ -57,6 +58,20 @@ export async function addVirtual() {
58 }
59 }
60
61 +export async function addLink() {
62 + try {
63 + const { id: parent } = getParent()
64 + const res = await apiCall('add_vfs', { parent, name: 'new link', url: 'https://google.com' })
65 + reloadVfs([ parent + encodeURI(res.name) ])
66 + toast("Link created", 'success', {
67 + onClose: () => focusSelector('input[name=url]')
68 + })
69 + }
70 + catch(e) {
71 + await alertDialog(e as Error)
72 + }
73 +}
74 +
75 function getParent() {
76 const f = state.selectedFiles[0] || state.vfs
77 return f.type === 'folder' ? f : f.parent!
admin/src/dialog.ts
+15 -21
@@ -1,25 +1,20 @@
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 {
4 - Box,
5 - Button,
6 - CircularProgress,
7 - Dialog as MuiDialog,
8 - DialogContent,
9 - DialogTitle,
10 - IconButton,
11 - Modal
3 +import { Box, Button, CircularProgress, Dialog as MuiDialog, DialogContent, DialogTitle, IconButton, Modal
4 } from '@mui/material'
13 -import {
14 - createElement as h, Dispatch, Fragment,
15 - isValidElement,
16 - ReactElement, ReactNode, SetStateAction,
17 - useEffect,
18 - useRef,
19 - useState
5 +import { createElement as h, Dispatch, Fragment, isValidElement, ReactElement, ReactNode, SetStateAction,
6 + useEffect, useRef, useState
7 } from 'react'
8 import { Check, Close, Error as ErrorIcon, Forward, Info, Warning } from '@mui/icons-material'
22 -import { newDialog, closeDialog, dialogsDefaults, DialogOptions, componentOrNode, pendingPromise } from '@hfs/shared'
9 +import {
10 + newDialog,
11 + closeDialog,
12 + dialogsDefaults,
13 + DialogOptions,
14 + componentOrNode,
15 + pendingPromise,
16 + focusSelector
17 +} from '@hfs/shared'
18 import { Form, FormProps } from '@hfs/mui-grid-form'
19 import { IconBtn, Flex, Center } from './misc'
20 import { useDark } from './theme'
@@ -36,9 +31,7 @@ dialogsDefaults.Container = function Container(d:DialogOptions) {
31 if (!el) return
32 el.focus()
33 if (mobile) return
39 - const input = el.querySelector('[autofocus]') || el.querySelector('input,textarea')
40 - if (input && input instanceof HTMLElement)
41 - input.focus()
34 + focusSelector('[autofocus]') || focusSelector('input,textarea')
35 })
36 return () => clearTimeout(h)
37 }, [ref.current])
@@ -215,9 +208,10 @@ export function waitDialog() {
208 return newDialog({ Content: () => h(CircularProgress, { size: '20vw'}), noFrame: true, closable: false }).close
209 }
210
218 -export function toast(msg: string | ReactElement, type: AlertType | ReactElement='info') {
211 +export function toast(msg: string | ReactElement, type: AlertType | ReactElement='info', options?: Partial<DialogOptions>) {
212 const ms = 3000
213 const dialog = newDialog({
214 + ...options,
215 Content,
216 dialogProps: {
217 fullScreen: false,
frontend/src/BrowseFiles.ts
+5 -2
@@ -175,7 +175,8 @@ interface EntryProps { entry: DirEntry, midnight: Date, separator?: string }
175 const Entry = memo(({ entry, midnight, separator }: EntryProps) => {
176 const { uri, isFolder } = entry
177 const { showFilter, selected, file_menu_on_link } = useSnapState()
178 - const containerDir = isFolder ? '' : uri.substring(0, uri.lastIndexOf('/')+1)
178 + const isLink = Boolean(entry.url)
179 + const containerDir = isFolder || isLink ? '' : uri.substring(0, (uri.lastIndexOf('/') || -1) +1)
180 const containerName = containerDir && entry.n.slice(0, -entry.name.length)
181 let className = isFolder ? 'folder' : 'file'
182 if (entry.cantOpen)
@@ -183,14 +184,16 @@ const Entry = memo(({ entry, midnight, separator }: EntryProps) => {
184 if (separator)
185 className += ' ' + PAGE_SEPARATOR_CLASS
186 const ico = getEntryIcon(entry)
186 - const onClick = !entry.web && file_menu_on_link && fileMenu || undefined
187 + const onClick = !isLink && !entry.web && file_menu_on_link && fileMenu || undefined
188 const small = useWindowSize().width < 800
189 const showingButton = !file_menu_on_link || isFolder && small
190 return h('li', { className, label: separator },
191 h(CustomCode, { name: 'entry', props: { entry }, ifEmpty: () => h(Fragment, {},
192 showFilter && h(Checkbox, {
193 + disabled: isLink,
194 value: selected[uri],
195 onChange(v){
196 + debugger
197 if (v)
198 return state.selected[uri] = true
199 delete state.selected[uri]
frontend/src/state.ts
+3 -2
@@ -86,6 +86,7 @@ export class DirEntry {
86 public readonly p?: string
87 public readonly icon?: string
88 public readonly web?: true
89 + public readonly url?: string
90 public comment?: string
91 // we memoize these value for speed
92 public readonly name: string
@@ -98,7 +99,7 @@ export class DirEntry {
99 constructor(n: string, rest?: any) {
100 Object.assign(this, rest) // we actually allow any custom property to be memorized
101 this.n = n // must do it after rest to avoid overwriting
101 - this.uri = (n[0] === '/' ? '' : location.pathname) + pathEncode(this.n)
102 + this.uri = rest?.url || ((n[0] === '/' ? '' : location.pathname) + pathEncode(this.n))
103 if (rest?.web) // this is actually a folder pointing to a default file, and it requires a final slash for correct handling
104 this.uri += '/'
105 this.isFolder = this.n.endsWith('/')
@@ -131,7 +132,7 @@ export class DirEntry {
132 }
133
134 getDefaultIcon() {
134 - return hIcon(this.icon ?? (this.isFolder ? 'folder' : this.web ? 'link' : ext2type(this.ext) || 'file'))
135 + return hIcon(this.icon ?? (this.isFolder ? 'folder' : this.web || this.url ? 'link' : ext2type(this.ext) || 'file'))
136 }
137 }
138 export type DirList = DirEntry[]
shared/index.ts
+8
@@ -87,3 +87,11 @@ export function makeSessionRefresher(state: any) {
87 setTimeout(() => apiCall('refresh_session').then(sessionRefresher), t)
88 }
89 }
90 +
91 +export function focusSelector(selector: string, root=document) {
92 + const res = root.querySelector(selector)
93 + if (res && res instanceof HTMLElement) {
94 + res.focus()
95 + return true
96 + }
97 +}
src/api.file_list.ts
+4 -2
@@ -11,7 +11,7 @@ import Koa from 'koa'
11 import { descriptIon, DESCRIPT_ION, getCommentFor, areCommentsEnabled } from './comments'
12 import { basename } from 'path'
13
14 - export interface DirEntry { n:string, s?:number, m?:Date, c?:Date, p?: string, comment?: string, web?: boolean }
14 +export interface DirEntry { n:string, s?:number, m?:Date, c?:Date, p?: string, comment?: string, web?: boolean, url?: string }
15
16 export const get_file_list: ApiHandler = async ({ uri, offset, limit, search, c }, ctx) => {
17 const node = await urlToNode(uri || '/', ctx)
@@ -94,8 +94,10 @@ export const get_file_list: ApiHandler = async ({ uri, offset, limit, search, c
94 }
95
96 async function nodeToDirEntry(ctx: Koa.Context, node: VfsNode): Promise<DirEntry | null> {
97 - let { source } = node
97 + let { source, url } = node
98 const name = getNodeName(node)
99 + if (url)
100 + return name ? { n: name, url } : null
101 if (!source)
102 return name ? { n: name + '/' } : null
103 if (node.isFolder && await hasDefaultFile(node))
src/api.vfs.ts
+11 -7
@@ -1,7 +1,9 @@
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 { getNodeName, isSameFilenameAs, nodeIsDirectory, saveVfs, urlToNode, vfs, VfsNode, applyParentToChild,
4 - permsFromParent } from './vfs'
3 +import {
4 + getNodeName, isSameFilenameAs, nodeIsDirectory, saveVfs, urlToNode, vfs, VfsNode, applyParentToChild,
5 + permsFromParent, nodeIsLink
6 +} from './vfs'
7 import _ from 'lodash'
8 import { mkdir, stat } from 'fs/promises'
9 import { ApiError, ApiHandlers, SendListReadable } from './apiMiddleware'
@@ -23,6 +25,8 @@ async function urlToNodeOriginal(uri: string) {
25 return n?.isTemp ? n.original : n
26 }
27
28 +const ALLOWED_KEYS = ['name','source','masks','default','accept','rename','mime','url', ...PERM_KEYS]
29 +
30 const apis: ApiHandlers = {
31
32 async get_vfs() {
@@ -31,7 +35,7 @@ const apis: ApiHandlers = {
35 async function recur(node=vfs): Promise<VfsNodeAdminSend> {
36 const { source } = node
37 const stats: false | Stats = Boolean(source) && await stat(source!).catch(() => false)
34 - const isDir = !source || stats && stats.isDirectory()
38 + const isDir = !nodeIsLink(node) && (!source || stats && stats.isDirectory())
39 const copyStats: Pick<VfsNodeAdminSend, 'size' | 'ctime' | 'mtime'> = stats ? _.pick(stats, ['size', 'ctime', 'mtime'])
40 : { size: source ? -1 : undefined }
41 if (copyStats.mtime && Number(copyStats.mtime) === Number(copyStats.ctime))
@@ -86,7 +90,7 @@ const apis: ApiHandlers = {
90 const n = await urlToNodeOriginal(uri)
91 if (!n)
92 return new ApiError(HTTP_NOT_FOUND, 'path not found')
89 - props = pickProps(props, ['name','source','masks','default','accept', ...PERM_KEYS]) // sanitize
93 + props = pickProps(props, ALLOWED_KEYS) // sanitize
94 if (props.name && props.name !== getNodeName(n)) {
95 const parent = await urlToNodeOriginal(dirname(uri))
96 if (parent?.children?.find(x => getNodeName(x) === props.name))
@@ -100,7 +104,7 @@ const apis: ApiHandlers = {
104 return n
105 },
106
103 - async add_vfs({ parent, source, name }) {
107 + async add_vfs({ parent, source, name, ...rest }) {
108 if (!source && !name)
109 return new ApiError(HTTP_BAD_REQUEST, 'name or source required')
110 const parentNode = parent ? await urlToNodeOriginal(parent) : vfs
@@ -113,7 +117,7 @@ const apis: ApiHandlers = {
117 const isDir = source && await isDirectory(source)
118 if (source && isDir === undefined)
119 return new ApiError(HTTP_NOT_FOUND, 'source not found')
116 - const child = { source, name }
120 + const child = { source, name, ...pickProps(rest, ALLOWED_KEYS) }
121 name = getNodeName(child) // could be not given as input
122 const ext = extname(name)
123 const noExt = ext ? name.slice(0, -ext.length) : name
@@ -124,7 +128,7 @@ const apis: ApiHandlers = {
128 simplifyName(child)
129 ;(parentNode.children ||= []).unshift(child)
130 saveVfs()
127 - const link = getBaseUrlOrDefault()
131 + const link = rest.url ? undefined : getBaseUrlOrDefault()
132 + (parent ? enforceFinal('/', parent) : '/')
133 + encodeURIComponent(getNodeName(child))
134 + (isDir ? '/' : '')
src/vfs.ts
+7 -5
@@ -18,6 +18,7 @@ type Masks = Record<string, VfsNode & { maskOnly?: 'files' | 'folders' }>
18 export interface VfsNodeStored extends VfsPerms {
19 name?: string
20 source?: string
21 + url?: string
22 children?: VfsNode[]
23 default?: string | false // we could have used empty string to override inherited default, but false is clearer, even reading the yaml, and works well with pickProps(), where empty strings are removed
24 mime?: string | Record<string, string>
@@ -166,14 +167,15 @@ export function getNodeName(node: VfsNode) {
167 export async function nodeIsDirectory(node: VfsNode) {
168 if (node.isFolder !== undefined)
169 return node.isFolder
169 - const isFolder = Boolean(node.children?.length || !node.source || await isDirectory(node.source))
170 - if (node.isTemp)
171 - node.isFolder = isFolder
172 - else
173 - setHidden(node, { isFolder }) // don't make it to the storage
170 + const isFolder = Boolean(node.children?.length || !nodeIsLink(node) && (!node.source || await isDirectory(node.source)))
171 + setHidden(node, { isFolder }) // don't make it to the storage (a node.isTemp doesn't need it to be hidden)
172 return isFolder
173 }
174
175 +export function nodeIsLink(node: VfsNode) {
176 + return node.url
177 +}
178 +
179 export function hasPermission(node: VfsNode, perm: keyof VfsPerms, ctx: Koa.Context): boolean {
180 return !statusCodeForMissingPerm(node, perm, ctx, false)
181 }
src/zip.ts
+2 -1
@@ -1,6 +1,6 @@
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 { getNodeName, hasPermission, nodeIsDirectory, urlToNode, VfsNode, walkNode } from './vfs'
3 +import { getNodeName, hasPermission, nodeIsDirectory, nodeIsLink, urlToNode, VfsNode, walkNode } from './vfs'
4 import Koa from 'koa'
5 import { filterMapGenerator, isWindowsDrive, pattern2filter, wantArray } from './misc'
6 import { QuickZipStream } from './QuickZipStream'
@@ -39,6 +39,7 @@ export async function zipStreamFromFolder(node: VfsNode, ctx: Koa.Context) {
39 }
40 })()
41 const mappedWalker = filterMapGenerator(walker, async (el:VfsNode) => {
42 + if (nodeIsLink(el)) return
43 if (!hasPermission(el, 'can_archive', ctx)) return // the fact you see it doesn't mean you can get it
44 const { source } = el
45 const name = getNodeName(el)