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)