admin/fs: use picker field for root's source
Massimo Melina committed
May 15, 2022 at 18:58 UTC
90a35de3426603e72323fec1a85b84e27f16bac3
9 files changed
+57
-43
admin/src/FileField.ts
+3
-2
@@ -7,7 +7,7 @@ import { newDialog } from '@hfs/shared/lib/dialogs'
7
import FilePicker from './FilePicker'
8
import { apiCall } from './api'
9
10
-export default function FileField({ value, onChange, ...props }: FieldProps<string>) {
10
+export default function FileField({ value, onChange, files, title, ...props }: FieldProps<string>) {
11
return h(StringField, {
12
...props,
13
value,
@@ -20,7 +20,7 @@ export default function FileField({ value, onChange, ...props }: FieldProps<stri
20
edge: 'end',
21
onClick() {
22
const close = newDialog({
23
- title: "Pick a file",
23
+ title: title ?? (files ? "Pick a file" : "Pick a folder"),
24
dialogProps: { sx:{ minWidth:'min(90vw, 40em)', minHeight: 'calc(100vh - 9em)' } },
25
Content,
26
})
@@ -29,6 +29,7 @@ export default function FileField({ value, onChange, ...props }: FieldProps<stri
29
return h(FilePicker, {
30
multiple: false,
31
folders: false,
32
+ files,
33
from: value,
34
async onSelect(sel) {
35
let one = sel?.[0]
admin/src/FileForm.ts
+2
-1
@@ -9,6 +9,7 @@ import { formatBytes, isEqualLax, modifiedSx, onlyTruthy } from './misc'
9
import { reloadVfs, VfsNode, Who } from './VfsPage'
10
import md from './md'
11
import _ from 'lodash'
12
+import FileField from './FileField'
13
14
interface Account { username: string }
15
@@ -80,7 +81,7 @@ export default function FileForm({ file }: { file: VfsNode }) {
81
k: 'name', validate: x => x>'' || `Required`,
82
helperText: source && "You can decide a name that's different from the one on your disk",
83
},
83
- isRoot ? { k: 'source', helperText: "If you specify a folder here, its files will be listed in the home" }
84
+ isRoot ? { k: 'source', comp: FileField, files: false, helperText: "If you specify a folder here, its files will be listed in the home" }
85
: (hasSource && { k: 'source', comp: DisplayField, multiline: true }),
86
{ k: 'can_read', label:"Who can download", xl: showCanSee && 6, comp: WhoField, parent, accounts, inherit: inheritedPerms.can_read,
87
helperText: "Note: who can't download won't see it in the list"
admin/src/FilePicker.ts
+12
-15
@@ -14,7 +14,7 @@ import {
14
TextField,
15
Typography
16
} from '@mui/material'
17
-import { enforceFinal, formatBytes, isWindowsDrive, spinner, pathJoin, dirname, isAbsolutePath } from './misc'
17
+import { enforceFinal, formatBytes, isWindowsDrive, spinner, Center } from './misc'
18
import { ArrowUpward, Home } from '@mui/icons-material'
19
import { StringField } from './Form'
20
import { FileIcon, FolderIcon } from './VfsTree'
@@ -28,21 +28,19 @@ interface FilePickerProps {
28
multiple?: boolean
29
from?: string
30
folders?: boolean
31
+ files?: boolean
32
}
32
-export default function FilePicker({ onSelect, multiple=true, folders=true, from }: FilePickerProps) {
33
- const passedDir = useMemo(() => from && dirname(from), [from])
34
- const [cwd, setCwd] = useState(from && passedDir || '')
33
+export default function FilePicker({ onSelect, multiple=true, files=true, folders=true, from='' }: FilePickerProps) {
34
+ const [cwd, setCwd] = useState(from)
35
const [ready, setReady] = useState(false)
36
useEffect(() => {
37
- if (passedDir && isAbsolutePath(passedDir))
38
- return setReady(true)
39
- apiCall('get_cwd').then(res => {
37
+ apiCall('resolve_path', { path: from, closestFolder: true }).then(res => {
38
if (typeof res.path === 'string')
41
- setCwd(pathJoin(res.path, passedDir))
39
+ setCwd(res.path)
40
setReady(true)
41
})
44
- }, [passedDir])
45
- const { list, error, loading } = useApiList<DirEntry>(ready && 'ls', { path: cwd })
42
+ }, [from])
43
+ const { list, error, loading } = useApiList<DirEntry>(ready && 'ls', { path: cwd, files })
44
useEffect(() => {
45
setSel([])
46
setFilter('')
@@ -81,10 +79,9 @@ export default function FilePicker({ onSelect, multiple=true, folders=true, from
79
}),
80
),
81
error ? h(Alert, { severity:'error' }, String(error))
84
- : !list.length ? h(Typography, { p:1 }, 'No elements in this folder')
82
: h(Fragment, {},
83
h(Box, { sx: { flex: 1 } },
87
- h(AutoSizer, {
84
+ !list.length ? h(Center, { flex: 1, mt: '4em' }, "No elements in this folder") : h(AutoSizer, {
85
children: size =>
86
h(FixedSizeList, {
87
...size, itemSize: 46, itemCount: filteredList.length, overscanCount: 5,
@@ -124,14 +121,14 @@ export default function FilePicker({ onSelect, multiple=true, folders=true, from
121
}),
122
),
123
h(Box, { display:'flex', gap: 1 },
127
- (multiple || folders) && h(Button, {
124
+ (multiple || folders || !files) && h(Button, {
125
variant: 'contained',
129
- disabled: !folders && !sel.length,
126
+ disabled: !folders && !sel.length && files,
127
sx: { minWidth: 'max-content' },
128
onClick() {
129
onSelect(sel.length ? sel.map(x => cwdPostfixed + x) : [cwd])
130
}
134
- }, sel.length || !folders ? `Select (${sel.length})` : `Select this folder`),
131
+ }, files && (sel.length || !folders) ? `Select (${sel.length})` : `Select this folder`),
132
h(TextField, {
133
value: filter,
134
label: `Filter results (${filteredList.length}${filteredList.length < list.length ? '/'+list.length : ''})`,
admin/src/api.ts
+10
-8
@@ -21,7 +21,9 @@ export function useApiComp<T=any>(...args: Parameters<typeof useApi>): [T | Reac
21
const PREFIX = '/~/api/'
22
23
export function apiCall(cmd: string, params?: Dict) : Promise<any> {
24
- params = addCsrf(params)
24
+ const csrf = getCsrf()
25
+ if (csrf)
26
+ params = { csrf, ...params }
27
28
const controller = new AbortController()
29
setTimeout(() => controller.abort(), 10_000)
@@ -75,14 +77,15 @@ export function useApi<T=any>(cmd: string | Falsy, params?: object) : [T | undef
77
type EventHandler = (type:string, data?:any) => void
78
79
export function apiEvents(cmd: string, params: Dict, cb:EventHandler) {
78
- const processed: Record<string,string> = {}
80
+ console.debug('API EVENTS', cmd, params)
81
+ const csrf = getCsrf()
82
+ const processed: Record<string,string> = { csrf: csrf && JSON.stringify(csrf) }
83
for (const k in params) {
84
const v = params[k]
85
if (v === undefined) continue
82
- processed[k] = v === true ? '1' : v
86
+ processed[k] = JSON.stringify(v)
87
}
84
- console.debug('API EVENTS', cmd, params)
85
- const source = new EventSource(PREFIX + cmd + '?' + new URLSearchParams(addCsrf(processed)))
88
+ const source = new EventSource(PREFIX + cmd + '?' + new URLSearchParams(processed))
89
source.onopen = () => cb('connected')
90
source.onerror = err => cb('error', err)
91
source.onmessage = ({ data }) => {
@@ -100,9 +103,8 @@ export function apiEvents(cmd: string, params: Dict, cb:EventHandler) {
103
return source
104
}
105
103
-function addCsrf(params?: Dict) {
104
- const csrf = getCookie('csrf')
105
- return csrf ? { csrf, ...params } : params
106
+function getCsrf() {
107
+ return getCookie('csrf')
108
}
109
110
export function useApiList<T=any>(cmd:string|Falsy, params: Dict={}, { addId=false, map=((x:any)=>x) }={}) {
frontend/src/api.ts
+9
-6
@@ -8,7 +8,9 @@ const PREFIX = '/~/api/'
8
interface ApiCallOptions { noModal?:true }
9
export function apiCall(cmd: string, params?: Dict, options: ApiCallOptions={}) : Promise<any> {
10
const stop = options.noModal ? undefined : working()
11
- params = addCsrf(params)
11
+ const csrf = getCsrf()
12
+ if (csrf)
13
+ params = { csrf, ...params }
14
return fetch(PREFIX+cmd, {
15
method: 'POST',
16
headers: { 'content-type': 'application/json' },
@@ -47,13 +49,14 @@ export function useApi(cmd: string | Falsy, params?: object) : any {
49
type EventHandler = (type:string, data?:any) => void
50
51
export function apiEvents(cmd: string, params: Dict, cb:EventHandler) {
50
- const processed: Record<string,string> = {}
52
+ const csrf = getCsrf()
53
+ const processed: Record<string,string> = { csrf: csrf && JSON.stringify(csrf) }
54
for (const k in params) {
55
const v = params[k]
56
if (v === undefined) continue
54
- processed[k] = v === true ? '1' : v
57
+ processed[k] = JSON.stringify(v)
58
}
56
- const source = new EventSource(PREFIX + cmd + '?' + new URLSearchParams(addCsrf(processed)))
59
+ const source = new EventSource(PREFIX + cmd + '?' + new URLSearchParams(processed))
60
source.onopen = () => cb('connected')
61
source.onerror = err => cb('error', err)
62
source.onmessage = ({ data }) => {
@@ -70,6 +73,6 @@ export function apiEvents(cmd: string, params: Dict, cb:EventHandler) {
73
return source
74
}
75
73
-function addCsrf(params?: Dict) {
74
- return { csrf: getCookie('csrf'), ...params }
76
+function getCsrf() {
77
+ return getCookie('csrf')
78
}
server/src/api.vfs.ts
+12
-3
@@ -4,7 +4,7 @@ import { getNodeName, nodeIsDirectory, saveVfs, urlToNode, vfs, VfsNode } from '
4
import _ from 'lodash'
5
import { stat } from 'fs/promises'
6
import { ApiError, ApiHandlers } from './apiMiddleware'
7
-import { dirname, join } from 'path'
7
+import { dirname, join, resolve } from 'path'
8
import { dirStream, enforceFinal, isWindowsDrive, objSameKeys } from './misc'
9
import { exec } from 'child_process'
10
import { promisify } from 'util'
@@ -109,11 +109,18 @@ const apis: ApiHandlers = {
109
}
110
},
111
112
- async get_cwd() {
112
+ get_cwd() {
113
return { path: process.cwd() }
114
},
115
116
- async *ls({ path }, ctx) {
116
+ async resolve_path({ path, closestFolder }) {
117
+ path = resolve(path)
118
+ if (closestFolder && !(await stat(path)).isDirectory())
119
+ path = dirname(path)
120
+ return { path }
121
+ },
122
+
123
+ async *ls({ path, files=true }, ctx) {
124
if (!path && IS_WINDOWS) {
125
try {
126
for (const n of await getDrives())
@@ -133,6 +140,8 @@ const apis: ApiHandlers = {
140
try {
141
const full = join(path, name)
142
const stats = await stat(full)
143
+ if (!files && stats.isFile())
144
+ continue
145
yield {
146
add: {
147
n: name,
server/src/apiMiddleware.ts
+3
-2
@@ -4,7 +4,7 @@ import { IncomingMessage } from 'http'
4
import Koa from 'koa'
5
import createSSE from './sse'
6
import { Readable } from 'stream'
7
-import { asyncGeneratorToReadable, onOff } from './misc'
7
+import { asyncGeneratorToReadable, objSameKeys, onOff, tryJson } from './misc'
8
import events from './events'
9
10
export class ApiError extends Error {
@@ -18,7 +18,8 @@ export type ApiHandlers = Record<string, ApiHandler>
18
19
export function apiMiddleware(apis: ApiHandlers) : Koa.Middleware {
20
return async (ctx) => {
21
- const params = ctx.method === 'POST' ? await getJsonFromReq(ctx.req) : ctx.request.query
21
+ const params = ctx.method === 'POST' ? await getJsonFromReq(ctx.req)
22
+ : objSameKeys(ctx.request.query, x => Array.isArray(x) ? x : tryJson(x))
23
console.debug('API', ctx.method, ctx.path, { ...params })
24
if (!apis.hasOwnProperty(ctx.path)) {
25
ctx.body = 'invalid api'
server/src/misc.ts
+5
@@ -323,3 +323,8 @@ export function httpsStream(url: string, options:RequestOptions={}): Promise<Inc
323
}).on('error', reject).end()
324
})
325
}
326
+
327
+export function tryJson(s?: string) {
328
+ try { return s && JSON.parse(s) }
329
+ catch {}
330
+}
server/src/plugins.ts
+1
-6
@@ -7,7 +7,7 @@ import pathLib from 'path'
7
import { API_VERSION, COMPATIBLE_API_VERSION, PLUGINS_PUB_URI } from './const'
8
import * as Const from './const'
9
import Koa from 'koa'
10
-import { debounceAsync, getOrSet, onProcessExit, same, wantArray, watchDir } from './misc'
10
+import { debounceAsync, getOrSet, onProcessExit, same, tryJson, wantArray, watchDir } from './misc'
11
import { defineConfig } from './config'
12
import { DirEntry } from './api.file_list'
13
import { VfsNode } from './vfs'
@@ -288,11 +288,6 @@ export function parsePluginSource(id: string, source: string) {
288
return pl
289
}
290
291
-function tryJson(s?: string) {
292
- try { return s && JSON.parse(s) }
293
- catch {}
294
-}
295
-
291
function calculateBadApi(data: AvailablePlugin) {
292
const r = data.apiRequired
293
const [min, max] = Array.isArray(r) ? r : [r, r] // normalize data type