new permission system (masks)
Massimo Melina committed
Feb 14, 2022 at 11:09 UTC
4084bc832f3f9ffdd24c32d81c66fd9a1fb239e2
21 files changed
+571
-249
README.md
+16
-10
@@ -214,24 +214,30 @@ Supported entries are:
214
The virtual file system is a tree of files and folders, collectively called *nodes*.
215
By default, a node is a folder, unless you provide for it a source that's a file.
216
Valid keys in a node are:
217
-- `name`: how to display it. If not provided HFS will infer it from the source.
217
+- `name`: this is the name we'll use to display this file/folder. If not provided, HFS will infer it from the source. At least `name` or `source` must be provided.
218
- `source`: absolute or relative path of where to get the content
219
- `children`: just for folders, specify its virtual children.
220
Value is a list and its entries are nodes.
221
-- `hidden`: this must not be listed, but it's still downloadable.
222
-- `forbid`: set `true` to forbid listing for this folder
223
-- `hide`: similar to hidden, but it's from the parent node point of view.
224
- Use this to hide children read from the source, not listed in the VFS.
225
- Value is a file mask.
226
-- `remove`: use this to not only hide files but also prevent downloads in a folder with a source. Value is a file mask.
221
- `rename`: similar to name, but it's from the parent node point.
222
Use this to change the name of entries that are read from the source, not listed in the VFS.
223
Value is a dictionary, where the key is the original name.
230
-- `perm`: specify who can see this.
231
- Use this to limit access to this node.
232
- Value is a dictionary, where the key is the username, and the value is `r`.
224
- `mime`: specify what mime to use for this resource. Use "auto" for automatic detection.
225
- `default`: to be used with a folder where you want to serve a default html. E.g.: "index.html". Using this will make `mime` default to "auto".
226
+- `can_read`: specify who can download this entry. Value is a `WhoCan` descriptor, which is one of these values
227
+ - `true`: anyone can, even people who didn't log in. This is normally the default value.
228
+ - `false`: no one can.
229
+ - `"*"`: any account can, i.e. anyone who logged in.
230
+ - `[ frank, peter ]`: the list of accounts who can.
231
+- `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.
232
+ Remember that to see in the list you must also be able to download, or else you won't see it anyway. Value is a `WhoCan` descriptor, refer above.
233
+- `masks`: maps a file mask to a set of properties as the one documented in this section. E.g.
234
+ ```
235
+ masks:
236
+ "**/*.mp3":
237
+ can_read: false
238
+ "*.jpg|*.png":
239
+ mime: auto
240
+ ```
241
242
# Accounts
243
admin/src/FileCard.ts
+98
-31
@@ -1,15 +1,15 @@
1
import { state, useSnapState } from './state'
2
-import { createElement as h, useEffect, useState } from 'react'
3
-import { Box, Card, CardContent, List, ListItem, ListItemText } from '@mui/material'
4
-import { BoolField, DisplayField, Form } from './Form'
5
-import _ from 'lodash'
6
-import { apiCall } from './api'
7
-import { formatBytes, isEqualLax, objSameKeys } from './misc'
8
-import { reloadVfs } from './VfsPage'
2
+import { createElement as h, useEffect, useMemo, useState } from 'react'
3
+import { Card, CardContent, List, ListItem, ListItemText } from '@mui/material'
4
+import { BoolField, DisplayField, FieldComponent, FieldProps, Form, MultiSelectField, SelectField } from './Form'
5
+import { apiCall, useApi } from './api'
6
+import { formatBytes, isEqualLax, onlyTruthy } from './misc'
7
+import { reloadVfs, Who } from './VfsPage'
8
import { alertDialog } from './dialog'
10
-import PermField from './PermField'
11
-import { Lock, LockOpen } from '@mui/icons-material'
9
import md from './md'
10
+import _ from 'lodash'
11
+
12
+interface Account { username: string }
13
14
export default function FileCard() {
15
const { selectedFiles: files } = useSnapState()
@@ -26,13 +26,35 @@ export default function FileCard() {
26
))
27
}
28
29
-function FileForm({ file }:any) {
30
- file = _.omit(file, ['parent', 'children'])
29
+function FileForm({ file }: { file: ReturnType<typeof useSnapState>['selectedFiles']['0'] }) {
30
+ const { parent, children, ...rest } = file
31
+ const [values, setValues] = useState(rest)
32
+ useEffect(() => {
33
+ setValues(Object.assign({ can_see: null, can_read: null }, rest))
34
+ }, [file]) //eslint-disable-line
35
+
36
+ const accounts = useApi('get_accounts')[0]?.list
37
+
38
const { source } = file
32
- useEffect(() => setValues(file), [JSON.stringify(file)]) //eslint-disable-line
33
- const [values, setValues] = useState(file)
39
const isDir = file.type === 'folder'
35
- const realFolder = source && isDir
40
+ const hasSource = source !== undefined // we need a boolean
41
+ const realFolder = hasSource && isDir
42
+ const inheritedPerms = useMemo(() => {
43
+ const ret = { can_read: true, can_see: true }
44
+ // reconstruct parents backward
45
+ const parents = []
46
+ let run = parent
47
+ while (run) {
48
+ parents.unshift(run)
49
+ run = run.parent
50
+ }
51
+ for (const node of parents)
52
+ Object.assign(ret, node)
53
+ return ret
54
+ }, [parent])
55
+ const showCanSee = (values.can_read ?? inheritedPerms.can_read) === true
56
+ const showTimestamps = hasSource && Boolean(values.ctime)
57
+
58
return h(Form, {
59
values,
60
set(v, { k }) {
@@ -43,8 +65,10 @@ function FileForm({ file }:any) {
65
async onClick() {
66
if (!values.name)
67
return alertDialog(`Name cannot be empty`, 'warning')
46
- const props = objSameKeys(values, (v,k) =>
47
- v === file[k] ? undefined : v)
68
+ const props = _.pickBy(values, (v,k) =>
69
+ v !== file[k as keyof typeof values])
70
+ if (!props.masks)
71
+ props.masks = null // undefined cannot be serialized
72
delete props.source
73
await apiCall('set_vfs', {
74
uri: values.id,
@@ -57,22 +81,65 @@ function FileForm({ file }:any) {
81
},
82
fields: [
83
{ k: 'name', helperText: source && "You can decide a name that's different from the one on your disk" },
60
- source && { k: 'source', comp: DisplayField },
61
- realFolder && { k: 'hide', xl: values.hide ? 12: 6, label: "Hide elements read from the source",
62
- helperText: "Entering a file mask you can decide that people won't see some elements in this list, but still can download if they have a direct link to them" },
63
- realFolder && { k: 'remove', xl: values.hide ? 12: 6, label: "Remove/skip elements read from the source",
64
- helperText: "Elements matching the specified file mask won't be neither listed nor downloadable, like they don't exist" },
65
- source && !realFolder && { k: 'size', comp: DisplayField, map: formatBytes },
66
- source && { k: 'ctime', comp: DisplayField, md: 6, label: 'Created', map: (x:string) => x && new Date(x).toLocaleString() },
67
- source && { k: 'mtime', comp: DisplayField, md: 6, label: 'Modified', map: (x:string) => x && new Date(x).toLocaleString() },
68
- { k: 'hidden', comp: BoolField, md: 6, helperText: "If you hide this element will not be listed, but will still be accessible if you have a direct link" },
69
- isDir && { k: 'forbid', comp: BoolField, md: 6, helperText: "Forbid listing the content of this folder, but elements inside will still be accessible if you have a direct link" },
70
- { k: 'perm', comp: PermField,
71
- label: h(Box, { display:'flex', gap:1 }, ...values.perm ? [h(Lock), 'Access restricted'] : [h(LockOpen), 'Access not restricted'])
84
+ hasSource && { k: 'source', comp: DisplayField },
85
+ { k: 'can_read', label:"Who can download", md: showCanSee && 6, comp: WhoField, parent, accounts, inherit: inheritedPerms.can_read,
86
+ helperText: "Who cannot download also cannot see in list"
87
},
73
- { k: 'mime', lg: 6, label:"MIME type", helperText: isDir && "Will be applied for all files in this folder" },
74
- realFolder && { k: 'default', lg: 6, label:"Serve file instead of list",
75
- helperText: md("If you have a website that you want to serve in this folder, specify `index.html`") },
88
+ showCanSee && { k: 'can_see', label:"Who can see", md: 6, comp: WhoField, parent, accounts, inherit: inheritedPerms.can_see,
89
+ helperText: "If you hide this element it will not be listed, but will still be accessible if you have a direct link"
90
+ },
91
+ hasSource && !realFolder && { k: 'size', comp: DisplayField, toField: formatBytes },
92
+ showTimestamps && { k: 'ctime', comp: DisplayField, md: 6, label: 'Created', toField: formatTimestamp },
93
+ showTimestamps && { k: 'mtime', comp: DisplayField, md: 6, label: 'Modified', toField: formatTimestamp },
94
+ realFolder && { k: 'default', md: 6, comp: BoolField, label:"Act as website",
95
+ toField: Boolean, fromField: (v:boolean) => v ? 'index.html' : null,
96
+ helperText: md("If you want this folder to work like a website and load `index.html`") },
97
+ isDir && { k: 'masks', multiline: true, md: 6, toField: JSON.stringify, fromField: JSON.parse,
98
+ helperText: "This is a special field. Leave it empty unless you know what you are doing." }
99
]
100
})
101
}
102
+
103
+function formatTimestamp(x: string) {
104
+ return x ? new Date(x).toLocaleString() : '-'
105
+}
106
+
107
+interface WhoFieldProps extends FieldProps<Who> { accounts: Account[] }
108
+function WhoField({ value, onChange, parent, inherit, accounts, ...rest }: WhoFieldProps) {
109
+ const options = useMemo(() =>
110
+ onlyTruthy([
111
+ { value: null, label: (parent ? "Same as parent: " : "Default: " ) + who2desc(inherit === 0 ? true : inherit) },
112
+ { value: true },
113
+ { value: false },
114
+ { value: '*' },
115
+ { value: [], label: "Select accounts" },
116
+ ].map(x => x && x.value !== inherit // don't offer inherited value twice
117
+ && { label: _.capitalize(who2desc(x.value)), ...x })), // default label
118
+ [inherit, parent])
119
+
120
+ const arrayMode = Array.isArray(value)
121
+ return h('div', {},
122
+ h(SelectField as FieldComponent<Who>, {
123
+ ...rest,
124
+ value: arrayMode ? [] : value,
125
+ onChange(v, { was, event }) {
126
+ onChange(v, { was , event })
127
+ },
128
+ options
129
+ }),
130
+ arrayMode && h(MultiSelectField as FieldComponent<string[]>, {
131
+ label: "Choose accounts for " + rest.label,
132
+ value,
133
+ onChange,
134
+ options: accounts?.map(a => ({ value: a.username, label: a.username })) || [],
135
+ })
136
+ )
137
+}
138
+
139
+function who2desc(who: any) {
140
+ return who === false ? "no one"
141
+ : who === true ? "anyone"
142
+ : who === '*' ? "any account (login required)"
143
+ : Array.isArray(who) ? who.join(', ')
144
+ : "*UNKNOWN*" + JSON.stringify(who)
145
+}
admin/src/VfsPage.ts
+17
-7
@@ -10,7 +10,7 @@ import { onlyTruthy } from './misc'
10
let selectOnReload: string[] | undefined
11
12
export default function VfsPage() {
13
- const [id2node] = useState(() => new Map<string, Node>())
13
+ const [id2node] = useState(() => new Map<string, VfsNode>())
14
const snap = useSnapState()
15
const [res, reload] = useApiComp('get_vfs')
16
useMemo(() => snap.vfs || reload(), [snap.vfs, reload])
@@ -30,7 +30,7 @@ export default function VfsPage() {
30
id2node.get(id)))
31
32
// calculate id and parent fields, and builds the map id2node
33
- function recur(node: Node, pre='', parent: Node|undefined=undefined) {
33
+ function recur(node: VfsNode, pre='', parent: VfsNode|undefined=undefined) {
34
node.parent = parent
35
node.id = (pre + node.name) || '/' // root
36
id2node.set(node.id, node)
@@ -57,7 +57,7 @@ export function reloadVfs(pleaseSelect?: string[]) {
57
state.vfs = undefined
58
}
59
60
-export type Node = {
60
+export type VfsNode = {
61
id: string
62
name: string
63
type?: 'folder'
@@ -65,8 +65,18 @@ export type Node = {
65
size?: number
66
ctime?: string
67
mtime?: string
68
- children?: Node[]
69
- parent?: Node
70
- hidden?: boolean
71
- perm?: any
68
+ children?: VfsNode[]
69
+ parent?: VfsNode
70
+ can_see: Who
71
+ can_read: Who
72
+ masks?: any
73
}
74
+
75
+const WHO_ANYONE = true
76
+const WHO_NO_ONE = false
77
+const WHO_ANY_ACCOUNT = '*'
78
+type AccountList = string[]
79
+export type Who = typeof WHO_ANYONE
80
+ | typeof WHO_NO_ONE
81
+ | typeof WHO_ANY_ACCOUNT
82
+ | AccountList
admin/src/VfsTree.ts
+10
-6
@@ -11,7 +11,7 @@ import {
11
Lock,
12
RemoveRedEye
13
} from '@mui/icons-material'
14
-import { Node } from './VfsPage'
14
+import { VfsNode, Who } from './VfsPage'
15
import { isWindowsDrive, onlyTruthy } from './misc'
16
17
export const FolderIcon = Folder
@@ -29,7 +29,7 @@ const useStyles = makeStyles({
29
}
30
})
31
32
-export default function VfsTree({ id2node }:{ id2node: Map<string, Node> }) {
32
+export default function VfsTree({ id2node }:{ id2node: Map<string, VfsNode> }) {
33
const { vfs, selectedFiles } = useSnapState()
34
const [selected, setSelected] = useState<string[]>(selectedFiles.map(x => x.id)) // try to restore selection after reload
35
const [expanded, setExpanded] = useState(Array.from(id2node.keys()))
@@ -44,9 +44,13 @@ export default function VfsTree({ id2node }:{ id2node: Map<string, Node> }) {
44
setSelected(ids)
45
state.selectedFiles = onlyTruthy(ids.map(id => id2node.get(id)))
46
}
47
- }, recur(vfs as Readonly<Node>))
47
+ }, recur(vfs as Readonly<VfsNode>))
48
49
- function recur(node: Readonly<Node>): ReactElement {
49
+ function isRestricted(who: Who) {
50
+ return who === false || Array.isArray(who)
51
+ }
52
+
53
+ function recur(node: Readonly<VfsNode>): ReactElement {
54
let { id, name, source } = node
55
if (!id)
56
debugger
@@ -57,8 +61,8 @@ export default function VfsTree({ id2node }:{ id2node: Map<string, Node> }) {
61
label: !name ? h(Home)
62
: h('div', { className: styles.label },
63
h(folder ? FolderIcon : FileIcon),
60
- node.hidden && h(RemoveRedEye),
61
- node.perm && h(Lock),
64
+ isRestricted(node.can_see) && h(RemoveRedEye),
65
+ isRestricted(node.can_read) && h(Lock),
66
!source?.endsWith(name) ? name
67
: h('span', {},
68
h('span', { className:styles.path }, source.slice(0,-name.length)),
admin/src/addFiles.ts
+2
-2
@@ -1,7 +1,7 @@
1
import { alertDialog, newDialog } from './dialog'
2
import { createElement as h } from 'react'
3
import { Box } from '@mui/material'
4
-import { Node, reloadVfs } from './VfsPage'
4
+import { VfsNode, reloadVfs } from './VfsPage'
5
import { state } from './state'
6
import { apiCall } from './api'
7
import FilePicker from './FilePicker'
@@ -48,7 +48,7 @@ export async function addVirtual() {
48
}
49
50
function getUnder() {
51
- let f: Node | undefined = state.selectedFiles[0]
51
+ let f: VfsNode | undefined = state.selectedFiles[0]
52
if (f && f.type !== 'folder')
53
f = f.parent
54
return f?.id
admin/src/state.ts
+3
-3
@@ -1,13 +1,13 @@
1
import { proxy, useSnapshot } from 'valtio'
2
import { Dict } from './misc'
3
-import { Node } from './VfsPage'
3
+import { VfsNode } from './VfsPage'
4
5
export const state = proxy<{
6
title: string
7
config: Dict
8
changes: Dict
9
- vfs: Node | undefined
10
- selectedFiles: Node[]
9
+ vfs: VfsNode | undefined
10
+ selectedFiles: VfsNode[]
11
}>({
12
title: '',
13
config: {},
src/adminApis.ts
+2
-2
@@ -1,13 +1,13 @@
1
import { ApiHandlers } from './apis'
2
import { getConfig, getWholeConfig, setConfig } from './config'
3
import { getStatus } from './listen'
4
-import { app } from './index'
4
import { BUILD_TIMESTAMP, HFS_STARTED, VERSION } from './const'
5
import vfsApis from './api.vfs'
6
import accountsApis from './api.accounts'
7
import { Connection, getConnections } from './connections'
8
import { generatorAsCallback, onOffMap, pendingPromise } from './misc'
9
import _ from 'lodash'
10
+import events from './events'
11
12
export const adminApis: ApiHandlers = {
13
@@ -57,7 +57,7 @@ export const adminApis: ApiHandlers = {
57
yield { add: serializeConnection(conn) }
58
yield* generatorAsCallback(wrapper =>
59
ctx.res.once('close', // as connection is closed, call the callback returned by onOffMap that uninstalls the listener
60
- onOffMap(app, {
60
+ onOffMap(events, {
61
connection: conn => wrapper.callback({ add: serializeConnection(conn) }),
62
connectionClosed(conn: Connection) {
63
wrapper.callback({ remove: [ serializeConnection(conn, true) ] })
src/api.file_list.ts
+5
-6
@@ -1,16 +1,15 @@
1
-import { getNodeName, vfs, VfsNode, walkNode } from './vfs'
1
+import { cantReadStatusCode, getNodeName, hasPermission, urlToNode, VfsNode, walkNode } from './vfs'
2
import { ApiError, ApiHandler } from './apis'
3
import { stat } from 'fs/promises'
4
import { mapPlugins } from './plugins'
5
import { asyncGeneratorToArray, dirTraversal, filterMapGenerator, pattern2filter } from './misc'
6
-import { FORBIDDEN } from './const'
6
7
export const file_list:ApiHandler = async ({ path, offset, limit, search, omit, sse }, ctx) => {
9
- let node = await vfs.urlToNode(path || '/', ctx)
8
+ let node = await urlToNode(path || '/', ctx)
9
if (!node)
11
- return
12
- if (node.forbid)
13
- return new ApiError(FORBIDDEN)
10
+ return new ApiError(404)
11
+ if (!hasPermission(node,'can_read',ctx))
12
+ return new ApiError(cantReadStatusCode(node))
13
if (dirTraversal(search))
14
return new ApiError(418)
15
if (node.default)
src/api.vfs.ts
+29
-18
@@ -1,12 +1,13 @@
1
-import { getNodeName, nodeIsDirectory, saveVfs, vfs, VfsNode } from './vfs'
1
+import { getNodeName, nodeIsDirectory, saveVfs, urlToNode, vfs, VfsNode } from './vfs'
2
import _ from 'lodash'
3
import { stat } from 'fs/promises'
4
import { ApiError, ApiHandlers } from './apis'
5
import { dirname, join } from 'path'
6
import glob from 'fast-glob'
7
-import { enforceFinal, isWindows, isWindowsDrive } from './misc'
7
+import { enforceFinal, isWindowsDrive, objSameKeys } from './misc'
8
import { exec } from 'child_process'
9
import { promisify } from 'util'
10
+import { IS_WINDOWS } from './const'
11
12
type VfsAdmin = {
13
type?: string,
@@ -16,17 +17,23 @@ type VfsAdmin = {
17
children?: VfsAdmin[]
18
} & Omit<VfsNode, 'type' | 'children'>
19
20
+// to manipulate the tree we need the original node
21
+async function urlToNodeOriginal(uri: string) {
22
+ const n = await urlToNode(uri)
23
+ return n?.isTemp ? n.original : n
24
+}
25
+
26
const apis: ApiHandlers = {
27
28
async get_vfs() {
22
- return { root: vfs.root && await recur(vfs.root) }
29
+ return { root: vfs && await recur(vfs) }
30
24
- async function recur(n: typeof vfs.root): Promise<VfsAdmin> {
25
- const dir = await nodeIsDirectory(n)
31
+ async function recur(node: VfsNode): Promise<VfsAdmin> {
32
+ const dir = await nodeIsDirectory(node)
33
const stats: Pick<VfsAdmin, 'size' | 'ctime' | 'mtime'> = {}
34
try {
28
- if (n.source && !dir)
29
- Object.assign(stats, _.pick(await stat(n.source), ['size', 'ctime', 'mtime']))
35
+ if (node.source && !dir)
36
+ Object.assign(stats, _.pick(await stat(node.source), ['size', 'ctime', 'mtime']))
37
}
38
catch {
39
stats.size = -1
@@ -35,27 +42,31 @@ const apis: ApiHandlers = {
42
delete stats.mtime
43
return {
44
...stats,
38
- ...n,
39
- name: getNodeName(n),
45
+ ...node,
46
+ name: getNodeName(node),
47
type: dir ? 'folder' : undefined,
41
- children: n.children && await Promise.all(n.children.map(recur)),
48
+ children: node.children && await Promise.all(node.children.map(recur)),
49
}
50
}
51
},
52
53
async set_vfs({ uri, props }) {
47
- const n = await vfs.urlToNode(uri)
54
+ const n = await urlToNodeOriginal(uri)
55
if (!n)
56
return new ApiError(404, 'path not found')
50
- Object.assign(n, pickProps(props, ['name','source','hidden','forbid','perm','hide','remove']))
57
+ props = pickProps(props, ['name','source','can_see','can_read','masks','default'])
58
+ props = objSameKeys(props, v => v === null ? undefined : v) // null is a way to serialize undefined, that will restore default values
59
+ if (props.masks && typeof props.masks !== 'object')
60
+ delete props.masks
61
+ Object.assign(n, props)
62
if (getNodeName(_.omit(n, ['name'])) === n.name) // name only if necessary
52
- delete n.name
63
+ n.name = undefined
64
await saveVfs()
65
return n
66
},
67
68
async add_vfs({ under, source, name }) {
58
- const n = under ? await vfs.urlToNode(under) : vfs.root
69
+ const n = under ? await urlToNodeOriginal(under) : vfs
70
if (!n)
71
return new ApiError(404, 'invalid under')
72
if (n.isTemp || !await nodeIsDirectory(n))
@@ -75,11 +86,11 @@ const apis: ApiHandlers = {
86
errors: await Promise.all(uris.map(async uri => {
87
if (typeof uri !== 'string')
88
return 400
78
- const node = await vfs.urlToNode(uri)
79
- if (!node || node.isTemp)
89
+ const node = await urlToNodeOriginal(uri)
90
+ if (!node)
91
return 404
92
const parent = dirname(uri)
82
- const parentNode = await vfs.urlToNode(parent)
93
+ const parentNode = await urlToNodeOriginal(parent)
94
if (!parentNode)
95
return 403
96
const { children } = parentNode
@@ -98,7 +109,7 @@ const apis: ApiHandlers = {
109
},
110
111
async *ls({ path }, ctx) {
101
- if (!path && isWindows()) {
112
+ if (!path && IS_WINDOWS) {
113
try {
114
for (const n of await getDrives())
115
yield { add: { n, k: 'd' } }
src/connections.ts
+4
-4
@@ -1,5 +1,5 @@
1
import { Socket } from 'net'
2
-import { app } from './index'
2
+import events from './events'
3
4
export interface Connection {
5
socket: Socket
@@ -15,12 +15,12 @@ const all: Connection[] = []
15
export function newConnection(socket: Socket, secure:boolean=false) {
16
const conn: Connection = { socket, secure, got: 0, sent: 0, started: new Date() }
17
all.push(conn)
18
- app.emit('connection', conn) // we'll use these events for SSE
18
+ events.emit('connection', conn) // we'll use these events for SSE
19
socket.on('data', data =>
20
conn.got += data.length )
21
socket.on('close', () => {
22
all.splice(all.indexOf(conn), 1)
23
- app.emit('connectionClosed', conn)
23
+ events.emit('connectionClosed', conn)
24
})
25
}
26
@@ -36,5 +36,5 @@ export function socket2connection(socket: Socket) {
36
37
export function updateConnection(conn: Connection, change: Partial<Connection>) {
38
Object.assign(conn, change)
39
- app.emit('connectionUpdated', conn, change)
39
+ events.emit('connectionUpdated', conn, change)
40
}
src/const.ts
+3
@@ -20,3 +20,6 @@ export const argv = minimist(process.argv.slice(2))
20
export const METHOD_NOT_ALLOWED = 405
21
export const NO_CONTENT = 204
22
export const FORBIDDEN = 403
23
+
24
+export const IS_WINDOWS = process.platform === 'win32'
25
+
src/events.ts
new
+4
@@ -0,0 +1,4 @@
1
+import EventEmitter from 'events'
2
+
3
+// app-wide events
4
+export default new EventEmitter()
src/middlewares.ts
+9
-8
@@ -3,8 +3,8 @@ import Koa from 'koa'
3
import session from 'koa-session'
4
import { BUILD_TIMESTAMP, SESSION_DURATION } from './const'
5
import Application from 'koa'
6
-import { FORBIDDEN, FRONTEND_URI } from './const'
7
-import { vfs } from './vfs'
6
+import { FRONTEND_URI } from './const'
7
+import { cantReadStatusCode, hasPermission, urlToNode } from './vfs'
8
import { dirTraversal, isDirectory } from './misc'
9
import { zipStreamFromFolder } from './zip'
10
import { serveFileNode } from './serveFile'
@@ -52,12 +52,12 @@ export const frontendAndSharedFiles: Koa.Middleware = async (ctx, next) => {
52
return next()
53
if (path.startsWith(FRONTEND_URI))
54
return serveFrontendPrefixed(ctx,next)
55
- const node = await vfs.urlToNode(path, ctx)
55
+ const node = await urlToNode(path, ctx)
56
if (!node)
57
return next()
58
+ if (!hasPermission(node, 'can_read', ctx))
59
+ return ctx.status = cantReadStatusCode(node)
60
const { source } = node
59
- if (node.forbid)
60
- return ctx.status = FORBIDDEN
61
if (!source || await isDirectory(source)) {
62
const { get } = ctx.query
63
if (get === 'zip')
@@ -65,9 +65,10 @@ export const frontendAndSharedFiles: Koa.Middleware = async (ctx, next) => {
65
if (!path.endsWith('/')) // this folder was requested without the trailing /
66
return ctx.redirect(path + '/')
67
if (node.default) {
68
- const def = await vfs.urlToNode(path + node.default, ctx)
69
- if (def)
70
- return serveFileNode(def)(ctx, next)
68
+ const def = await urlToNode(path + node.default, ctx)
69
+ return !def ? next()
70
+ : hasPermission(def, 'can_read', ctx) ? serveFileNode(def)(ctx, next)
71
+ : ctx.status = cantReadStatusCode(def)
72
}
73
ctx.set({ server:'HFS '+BUILD_TIMESTAMP })
74
return serveFrontend(ctx, next)
src/misc.ts
+4
-4
@@ -133,10 +133,6 @@ export function pattern2filter(pattern: string){
133
!s || !pattern || re.test(basename(s))
134
}
135
136
-export function isWindows() {
137
- return process.platform === 'win32'
138
-}
139
-
136
type Truthy<T> = T extends false | '' | 0 | null | undefined ? never : T
137
138
export function truthy<T>(value: T): value is Truthy<T> {
@@ -194,3 +190,7 @@ export function objRenameKey(o: Dict | undefined, from: string, to: string) {
190
delete o[from]
191
return true
192
}
193
+
194
+export function typedKeys<T>(o: T) {
195
+ return Object.keys(o) as (keyof T)[]
196
+}
src/perm.ts
+2
-9
@@ -6,7 +6,7 @@ import { watchLoad } from './watchLoad'
6
import Koa from 'koa'
7
import { CFG_ALLOW_CLEAR_TEXT_LOGIN, getConfig, subscribeConfig } from './config'
8
import { createVerifierAndSalt, SRPParameters, SRPRoutines } from 'tssrp6a'
9
-import { vfs, VfsNode } from './vfs'
9
+import events from './events'
10
11
let path = ''
12
@@ -134,19 +134,12 @@ export function renameAccount(from: string, to: string) {
134
135
function updateReferences() {
136
setHidden(accounts[to], { username: to })
137
- recur(vfs.root)
137
for (const a of Object.values(accounts)) {
138
const idx = a.belongs?.indexOf(from)
139
if (idx !== undefined && idx >= 0)
140
a.belongs![idx] = to
141
}
143
- }
144
-
145
- function recur(n: VfsNode) {
146
- objRenameKey(n.perm, from, to)
147
- if (n.children)
148
- for (const c of n.children)
149
- recur(c)
142
+ events.emit('accountRenamed', from, to) // everybody, take care of your stuff
143
}
144
}
145
src/serveFile.ts
+7
-4
@@ -2,19 +2,22 @@ import Koa from 'koa'
2
import { createReadStream, stat } from 'fs'
3
import fs from 'fs/promises'
4
import { METHOD_NOT_ALLOWED, NO_CONTENT } from './const'
5
-import { MIME_AUTO, VfsNode } from './vfs'
5
+import { getNodeName, MIME_AUTO, VfsNode } from './vfs'
6
import mimetypes from 'mime-types'
7
import { defineConfig, getConfig } from './config'
8
-import mm from 'micromatch'
8
+import mm, { isMatch } from 'micromatch'
9
import _ from 'lodash'
10
import path from 'path'
11
import { promisify } from 'util'
12
13
export function serveFileNode(node: VfsNode) : Koa.Middleware {
14
const { source, mime } = node
15
+ const name = getNodeName(node)
16
+ const mimeString = typeof mime === 'string' ? mime
17
+ : _.find(mime, (val,mask) => isMatch(name, mask))
18
return (ctx, next) => {
19
ctx.vfsNode = node // useful to tell service files from files shared by the user
17
- return serveFile(source||'', mime)(ctx, next)
20
+ return serveFile(source||'', mimeString)(ctx, next)
21
}
22
}
23
@@ -28,7 +31,7 @@ export function serveFile(source:string, mime?:string, modifier?:(s:string)=>str
31
ctx.set('Accept-Ranges', 'bytes')
32
const mimeCfg = getConfig('mime')
33
const fn = path.basename(source)
31
- mime = mime ?? _.find(mimeCfg, (v,k) => mm.isMatch(fn, k))
34
+ mime = mime ?? _.find(mimeCfg, (v,k) => k && mm.isMatch(fn, k))
35
if (mime === MIME_AUTO)
36
mime = mimetypes.lookup(source) || ''
37
if (mime)
src/vfs.ts
+201
-85
@@ -1,89 +1,118 @@
1
import fs from 'fs/promises'
2
import { basename } from 'path'
3
import { isMatch } from 'micromatch'
4
-import { dirTraversal, enforceFinal, isDirectory, isWindows, onlyTruthy } from './misc'
4
+import { dirTraversal, enforceFinal, isDirectory, typedKeys } from './misc'
5
import Koa from 'koa'
6
import glob from 'fast-glob'
7
import _ from 'lodash'
8
import { setConfig, subscribeConfig } from './config'
9
+import { FORBIDDEN, IS_WINDOWS } from './const'
10
+import events from './events'
11
10
-export interface VfsNode {
11
- isTemp?: true, // this node was spawned by a source-d node and is not part of the vfs tree
12
- name?: string,
13
- source?: string,
14
- children?: VfsNode[],
15
- hide?: string,
16
- remove?: string,
17
- hidden?: boolean,
18
- forbid?: boolean,
19
- rename?: Record<string,string>,
20
- perm?: Record<string, SinglePerm>,
21
- default?: string,
22
- mime?: string,
12
+const WHO_ANYONE = true
13
+const WHO_NO_ONE = false
14
+const WHO_ANY_ACCOUNT = '*'
15
+type AccountList = string[]
16
+type Who = typeof WHO_ANYONE
17
+ | typeof WHO_NO_ONE
18
+ | typeof WHO_ANY_ACCOUNT
19
+ | AccountList
20
+
21
+interface VfsPerm {
22
+ can_see: Who
23
+ can_read: Who
24
}
25
25
-type SinglePerm = 'r' | 'w'
26
+type Masks = Record<string, VfsNode>
27
27
-export const MIME_AUTO = 'auto'
28
+export interface VfsNode extends Partial<VfsPerm> {
29
+ name?: string
30
+ source?: string
31
+ children?: VfsNode[]
32
+ default?: string
33
+ mime?: string | Record<string,string>
34
+ rename?: Record<string, string>
35
+ masks?: Masks // express fields for descendants that are not in the tree
36
+ // fields that are only filled at run-time
37
+ isTemp?: true // this node doesn't belong to the tree and was created by necessity
38
+ url?: string // what url brought to this node
39
+ parents?: VfsNode[]
40
+ original?: VfsNode // if this is a temp node but reflecting an existing node
41
+}
42
29
-export class Vfs {
30
- root: VfsNode = {}
43
+export const defaultPerms: VfsPerm = {
44
+ can_see: WHO_ANYONE,
45
+ can_read: WHO_ANYONE,
46
+}
47
+
48
+export const MIME_AUTO = 'auto'
49
32
- reset(){
33
- this.root = {}
50
+function inheritFromParent(parent: VfsNode, child: VfsNode) {
51
+ for (const k of typedKeys(defaultPerms)) {
52
+ const v = parent[k]
53
+ if (v !== undefined)
54
+ child[k] = v
55
}
56
+ if (typeof parent.mime === 'object' && typeof child.mime === 'object')
57
+ Object.assign(child.mime, parent.mime)
58
+ else
59
+ child.mime = parent.mime
60
+ return child
61
+}
62
36
- async urlToNode(url: string, ctx?: Koa.Context, root?: VfsNode) : Promise<VfsNode | undefined> {
37
- let run = root || this.root
38
- const decoded = decodeURI(url)
39
- if (dirTraversal(decoded)) {
40
- if (ctx)
41
- ctx.status = 418
42
- return
43
- }
44
- const rest = decoded.split('/').filter(Boolean)
45
- if (ctx && !hasPermission(run, ctx)) return
46
- while (rest.length) {
47
- const child = findChildByName(rest[0], run) // does the tree node have a child that goes by this name?
48
- if (child) { // yes
49
- rest.shift() // consume
50
- run = child // move cursor
51
- if (ctx && !hasPermission(run, ctx)) return
52
- continue // go on
53
- }
54
- // not in the tree, we can see consider continuing on the disk
55
- if (!run.source) return // but then we need the current node to be linked to the disk, otherwise, we give up
56
- const relativeSource = rest.join('/')
57
- const baseSource = run.source+ '/'
58
- const source = baseSource + relativeSource
59
- if (run.remove && isMatch(source, run.remove.split('|').map(x => baseSource + x)))
60
- return
61
- try { await fs.stat(source) } // check existence
62
- catch { return }
63
- return {
64
- isTemp: true,
65
- source,
66
- mime: run.mime || (run.default && MIME_AUTO)
63
+export async function urlToNode(url: string, ctx?: Koa.Context, parent: VfsNode=vfs) : Promise<VfsNode | undefined> {
64
+ let i = url.indexOf('/', 1)
65
+ const name = decodeURI(url.slice(url[0]==='/' ? 1 : 0, i < 0 ? undefined : i))
66
+ if (!name)
67
+ return parent
68
+ const rest = i < 0 ? '' : url.slice(i+1, url.endsWith('/') ? -1 : undefined)
69
+ if (dirTraversal(name) || /[\/]/.test(name)) {
70
+ if (ctx)
71
+ ctx.status = 418
72
+ return
73
+ }
74
+ const parents = parent.parents || [] // don't waste time cloning the array, as we won't keep intermediate nodes
75
+ const ret: VfsNode = {
76
+ isTemp: true,
77
+ url: enforceFinal('/', parent.url || '') + name,
78
+ parents,
79
+ }
80
+ parents.push(parent)
81
+ inheritFromParent(parent, ret)
82
+ inheritMasks(ret, parent, name)
83
+ applyMasks(ret, parent, name)
84
+ // does the tree node have a child that goes by this name?
85
+ const child = parent.children?.find(x => getNodeName(x) === name)
86
+ if (child) // yes
87
+ return urlToNode(rest, ctx, Object.assign(ret, child, { original: child }))
88
+ // not in the tree, we can see consider continuing on the disk
89
+ if (!parent.source) return // but then we need the current node to be linked to the disk, otherwise, we give up
90
+ let onDisk = name
91
+ if (parent.rename) { // reverse the mapping
92
+ for (const [from, to] of Object.entries(parent.rename))
93
+ if (name === to) {
94
+ onDisk = from
95
+ break // found, search no more
96
}
68
- }
69
- return run
97
+ ret.rename = renameUnderPath(parent.rename, name)
98
}
71
-
99
+ ret.source = enforceFinal('/', parent.source) + onDisk
100
+ if (parent.default)
101
+ inheritFromParent({ mime: { '*': MIME_AUTO } }, ret)
102
+ if (rest)
103
+ return urlToNode(rest, ctx, ret)
104
+ if (ret.source)
105
+ try { await fs.stat(ret.source) } // check existence
106
+ catch { return }
107
+ return ret
108
}
109
74
-export const vfs = new Vfs()
75
-subscribeConfig<VfsNode>({ k: 'vfs', defaultValue: vfs.root }, data =>
76
- vfs.root = data)
110
+export let vfs: VfsNode = {}
111
+subscribeConfig<VfsNode>({ k: 'vfs', defaultValue: {} }, data =>
112
+ vfs = data)
113
114
export function saveVfs() {
79
- return setConfig({ vfs: _.cloneDeep(vfs.root) }, true)
80
-}
81
-
82
-function findChildByName(name:string, node:VfsNode) {
83
- const { rename } = node
84
- if (rename) // @ts-ignore
85
- name = Object.entries(rename).find(([,v]) => name === v)[0] || name
86
- return node?.children?.find(x => getNodeName(x) === name)
115
+ return setConfig({ vfs: _.cloneDeep(vfs) }, true)
116
}
117
118
export function getNodeName(node: VfsNode) {
@@ -100,50 +129,137 @@ export async function nodeIsDirectory(node: VfsNode) {
129
return Boolean(!node.source || await isDirectory(node.source))
130
}
131
103
-export function hasPermission(node:VfsNode, ctx: Koa.Context) {
104
- const { perm } = node
105
- return !perm || ctx.state.usernames.some((u:string) => perm[u])
132
+export function hasPermission(node: VfsNode, perm: keyof VfsPerm, ctx: Koa.Context): boolean {
133
+ return matchWho(node[perm] ?? defaultPerms[perm], ctx)
134
+ && (perm !== 'can_see' || hasPermission(node, 'can_read', ctx)) // if you can't read, then you can't see
135
}
136
137
export async function* walkNode(parent:VfsNode, ctx: Koa.Context, depth:number=0, prefixPath:string=''): AsyncIterableIterator<VfsNode> {
138
const { children, source } = parent
139
if (children)
111
- for (const node of children) {
112
- if (node.hidden || !hasPermission(node, ctx))
113
- continue
114
- yield prefixPath ? { ...node, name: prefixPath+getNodeName(node) } : node
115
- try {
116
- if (depth > 0 && node && await nodeIsDirectory(node))
117
- yield* walkNode(node, ctx, depth - 1, prefixPath+getNodeName(node)+'/')
118
- }
119
- catch{} // stat failed in nodeIsDirectory, ignore
140
+ for (let idx = 0; idx < children.length; idx++) {
141
+ const child = children[idx]
142
+ yield* workItem({
143
+ ...child,
144
+ name: prefixPath ? (prefixPath + getNodeName(child)) : child.name
145
+ })
146
}
147
if (!source)
148
return
123
- const depthPath = depth === Infinity ? '**/' : _.repeat('*/',depth)
149
try {
150
const base = enforceFinal('/', source)
126
- const dirStream = glob.stream(depthPath + '*', {
151
+ const dirStream = glob.stream('*', {
152
dot: true,
153
onlyFiles: false,
154
cwd: base,
155
suppressErrors: true,
131
- caseSensitiveMatch: !isWindows(),
132
- ignore: onlyTruthy([parent.hide, parent.remove]),
156
})
157
for await (let path of dirStream) {
158
if (ctx.req.aborted)
159
return
160
if (path instanceof Buffer)
161
path = path.toString('utf8')
139
- yield {
140
- isTemp: true,
162
+ if (shouldSkipFile(path))
163
+ continue
164
+ let { rename } = parent
165
+ const renamed = rename?.[path]
166
+ yield* workItem({
167
+ name: (prefixPath || renamed) && prefixPath + (renamed || path),
168
source: base + path,
142
- name: prefixPath + (parent!.rename?.[path] || path)
143
- }
169
+ rename: renameUnderPath(rename, path),
170
+ })
171
}
172
}
173
catch(e) {
174
console.debug('glob', source, e) // ENOTDIR, or lacking permissions
175
}
176
+
177
+ async function* workItem(item: VfsNode) {
178
+ // we basename for depth>0 where we already have the rest of the path in the parent's url, and would be duplicated
179
+ const name = basename(getNodeName(item))
180
+ const url = enforceFinal('/', parent.url || '') + name
181
+ const temp = inheritFromParent(parent, {
182
+ ...item,
183
+ isTemp: true,
184
+ url,
185
+ parents: [ ...parent.parents||[], parent],
186
+ })
187
+ applyMasks(temp, parent, name)
188
+ if (!hasPermission(temp, 'can_see', ctx))
189
+ return
190
+ yield temp
191
+ try {
192
+ if (!depth || !await nodeIsDirectory(temp)) return
193
+ inheritMasks(temp, parent, name)
194
+ yield* walkNode(temp, ctx, depth - 1, getNodeName(temp) + '/')
195
+ }
196
+ catch{} // stat failed in nodeIsDirectory, ignore
197
+ }
198
+}
199
+
200
+function applyMasks(item: VfsNode, parent: VfsNode, name: string) {
201
+ const { masks } = parent
202
+ if (!masks) return
203
+ for (const k in masks)
204
+ if (k.startsWith('**/') && isMatch(name, k.slice(3))
205
+ || !k.includes('/') && isMatch(name, k))
206
+ Object.assign(item, masks[k])
207
+}
208
+
209
+function inheritMasks(item: VfsNode, parent: VfsNode, name:string) {
210
+ const { masks } = parent
211
+ if (!masks) return
212
+ const o: Masks = {}
213
+ for (const k in masks)
214
+ if (k.startsWith('**/'))
215
+ o[k.slice(3)] = masks[k]
216
+ else if (k.startsWith(name+'/'))
217
+ o[k.slice(name.length+1)] = masks[k]
218
+ if (Object.keys(o).length)
219
+ item.masks = o
220
+}
221
+
222
+function renameUnderPath(rename:undefined | Record<string,string>, path: string) {
223
+ if (!rename) return rename
224
+ const match = path+'/'
225
+ rename = Object.fromEntries(Object.entries(rename).map(([k, v]) =>
226
+ [k.startsWith(match) ? k.slice(match.length) : '', v]))
227
+ delete rename['']
228
+ return _.isEmpty(rename) ? undefined : rename
229
+}
230
+
231
+function matchWho(who: Who, ctx: Koa.Context) {
232
+ return who === WHO_ANYONE
233
+ || who === WHO_ANY_ACCOUNT && Boolean(ctx.state.account)
234
+ || Array.isArray(who) && who.some(u => ctx.state.usernames.includes(u) )
235
+}
236
+
237
+export function cantReadStatusCode(node: VfsNode) {
238
+ return node.can_read === false ? FORBIDDEN : 401
239
+}
240
+
241
+events.on('accountRenamed', (from, to) => {
242
+ recur(vfs)
243
+ saveVfs()
244
+
245
+ function recur(n: VfsNode) {
246
+ replace(n.can_see)
247
+ replace(n.can_read)
248
+
249
+ if (n.masks)
250
+ Object.values(n.masks).forEach(recur)
251
+ n.children?.forEach(recur)
252
+ }
253
+
254
+ function replace(a?: Who) {
255
+ if (!Array.isArray(a)) return
256
+ for (let i=0; i < a.length; i++)
257
+ if (a[i] === from)
258
+ a[i] = to
259
+ }
260
+
261
+})
262
+
263
+function shouldSkipFile(name: string) {
264
+ return IS_WINDOWS && (name === '$RECYCLE.BIN' || name === 'System Volume Information')
265
}
src/zip.ts
+4
-4
@@ -1,4 +1,4 @@
1
-import { getNodeName, nodeIsDirectory, vfs, VfsNode, walkNode } from './vfs'
1
+import { getNodeName, hasPermission, nodeIsDirectory, urlToNode, VfsNode, walkNode } from './vfs'
2
import Koa from 'koa'
3
import { filterMapGenerator, pattern2filter, prefix } from './misc'
4
import { QuickZipStream } from './QuickZipStream'
@@ -16,9 +16,9 @@ export async function zipStreamFromFolder(node: VfsNode, ctx: Koa.Context) {
16
const { list } = ctx.query
17
const walker = !list ? walkNode(node, ctx, Infinity)
18
: (async function*(): AsyncIterableIterator<VfsNode> {
19
- for (const el of String(list).split('*')) { // we are using * as separator because it cannot be used in a file name and doesn't need url encoding
20
- const subNode = await vfs.urlToNode(el, ctx, node)
21
- if (!subNode)
19
+ for await (const el of String(list).split('*')) { // we are using * as separator because it cannot be used in a file name and doesn't need url encoding
20
+ const subNode = await urlToNode(el, ctx, node)
21
+ if (!subNode || !hasPermission(subNode,'can_read',ctx))
22
continue
23
if (await nodeIsDirectory(subNode)) // a directory needs to walked
24
yield* walkNode(subNode, ctx, Infinity, el+'/')
tests/config.yaml
+57
-19
@@ -1,22 +1,19 @@
1
-# BEWARE all lines starting with # are ignored, just placeholders. Default values are used for such lines. If you need to set the option then remove the initial #
2
-port: 80 # 0 is a special value that will let the system pick a random available port. Use -1 to disable http, if you want https-only for example.
3
-#max_kbps: 1000
4
-#max_kbps_per_ip: 500
5
-#log:
6
-#error_log:
7
-#zip_calculate_size_for_seconds: 1
8
-#open_browser_at_start: false
9
-#https_port: 443 # https will work only if you give a valid certificate and private key
10
-#cert: filepath
11
-#private_key: filepath
12
-mime:
13
- "*.jpg|*.png|*.mp3|*.txt": auto
14
-disable_plugins: [ 'theme-example', 'download-counter' ]
15
-plugins_config:
16
- middleware-example:
17
- message: ciao
1
vfs:
2
+ mime:
3
+ "*.jpg|*.png|*.mp3|*.txt": auto
4
+ masks:
5
+ tests/page/*.html:
6
+ mime: text/plain
7
+ protectFromAbove/child/*.txt:
8
+ can_read: false
9
children:
10
+ - name: protectFromAbove
11
+ children:
12
+ - name: child
13
+ children:
14
+ - source: tests/alfa.txt
15
+ - name: renamed
16
+ source: tests/alfa.txt
17
- name: f1
18
children:
19
- name: f2
@@ -29,7 +26,48 @@ vfs:
26
default: index.html
27
source: tests/page
28
- name: for-admins
32
- perm:
33
- admins: r
29
+ can_read: [ admins ]
30
children:
31
+ - name: asd
32
- source: tests/alfa.txt
33
+ - source: tests
34
+ - name: renameChild
35
+ children:
36
+ - source: tests
37
+ rename:
38
+ alfa.txt: renamed1
39
+ page/gpl.png: renamed2
40
+ - name: cantReadPage
41
+ source: tests
42
+ masks:
43
+ page:
44
+ can_read: false
45
+ page/*:
46
+ can_read: true
47
+ - name: cantReadPageAlt
48
+ source: tests
49
+ masks:
50
+ page:
51
+ can_read: false
52
+ masks:
53
+ "**/*":
54
+ can_read: true
55
+ - name: cantReadPageRecursive
56
+ source: tests
57
+ masks:
58
+ page:
59
+ can_read: false
60
+ - name: cantReadRealFolder
61
+ source: tests
62
+ can_read: false
63
+ - name: cantSeeThis
64
+ can_see: false
65
+ children:
66
+ - name: hi
67
+ - name: cantSeeThisButChildren
68
+ can_see: false
69
+ masks:
70
+ "*":
71
+ can_see: true
72
+ children:
73
+ - name: hi
tests/test.ts
+83
-25
@@ -10,47 +10,83 @@ const appStarted = new Promise(resolve =>
10
11
const username = 'rejetto'
12
const password = 'password'
13
+const API = '/~/api/'
14
15
describe('basics', () => {
16
//before(async () => appStarted)
16
- it('frontend', req('/', s => s?.includes('<body>')))
17
- it('api.list', req('/~/api/file_list', data => inList(data, 'f2/') && inList(data, 'page'), {
18
- data: { path:'/f1/' }
19
- }))
20
- it('api.search', req('/~/api/file_list', data => inList(data, 'f2/') && !inList(data, 'page'), {
21
- data: { path:'f1', search:'2' }
22
- }))
23
- it('download', req('/f1/f2/alfa.txt', s => s?.includes('abcd')))
24
- it('partial download', req('/f1/f2/alfa.txt', s => s?.includes('a') && !s?.includes('d'), {
17
+ it('frontend', req('/', /<body>/))
18
+ it('list', reqList('/f1/', { inList:['f2/', 'page'] }))
19
+ it('search', reqList('f1', { inList:['f2/'], outList:['page'] }, { search:'2' }))
20
+ it('search root', reqList('/', { inList:['cantReadPage/'], outList:['cantReadPage/page/'] }, { search:'page' }))
21
+ it('download', req('/f1/f2/alfa.txt', { re:/abcd/, mime:'text/plain' }))
22
+ it('partial download', req('/f1/f2/alfa.txt', /a[^d]+$/, { // only "abc" is expected
23
headers: { Range: 'bytes=0-2' }
24
}))
25
it('bad range', req('/f1/f2/alfa.txt', 416, {
26
headers: { Range: 'bytes=7-' }
27
}))
30
- it('website', req('/f1/page/', s => s?.includes('This is a test')))
28
+ it('website', req('/f1/page/', { re:/This is a test/, mime:'text/html' }))
29
it('traversal', req('/f1/page/.%2e/.%2e/README.md', 418))
32
- it('missing perm', req('/for-admins/', 404))
33
- it('zip+head', req('/f1/?get=zip',
34
- (data, res) => !data && res.headers['content-length'] === '13074',
35
- { method:'HEAD' }) )
36
- it('login', req('/~/api/login', 406, { // by default we don't support clear-text login
37
- data: { username, password }
38
- }))
30
+ it('custom mime from above', req('/tests/page/index.html', { status: 200, mime:'text/plain' }))
31
+
32
+ it('missing perm', req('/for-admins/', 401))
33
+ it('missing perm.file', req('/for-admins/alfa.txt', 401))
34
+
35
+ it('forbidden list', req('/cantReadPage/page/', 403))
36
+ it('forbidden list.api', reqList('/cantReadPage/page/', 403))
37
+ it('forbidden list.cant see', reqList('/cantReadPage/', { outList:['page/'] }))
38
+ it('forbidden list.but readable file', req('/cantReadPage/page/gpl.png', 200))
39
+ it('forbidden list.alternative method', reqList('/cantReadPageAlt/page/', 403))
40
+ it('forbidden list.alternative method readable file', req('/cantReadPageAlt/page/gpl.png', 200))
41
+
42
+ it('cantReadPageRecursive', reqList('/cantReadPageRecursive/page', 403))
43
+ it('cantReadPageRecursive.file', req('/cantReadPageRecursive/page/gpl.png', 403))
44
+ it('cantReadPageRecursive.parent', reqList('/cantReadPageRecursive', 200))
45
+ it('cantReadRealFolder', reqList('/cantReadRealFolder', 403))
46
+ it('cantReadRealFolder.file', req('/cantReadRealFolder/page/gpl.png', 403))
47
+
48
+ it('renameChild', reqList('/renameChild/tests', { inList:['renamed1'] }))
49
+ it('renameChild.get', req('/renameChild/tests/renamed1', /abc/))
50
+ it('renameChild.deeper', reqList('/renameChild/tests/page', { inList:['renamed2'] }))
51
+ it('renameChild.get deeper', req('/renameChild/tests/page/renamed2', /PNG/))
52
+
53
+ it('cantSeeThis', reqList('/', { outList:['cantSeeThis/'] }))
54
+ it('cantSeeThis.children', reqList('/cantSeeThis', { outList:['hi/'] }))
55
+ it('cantSeeThisButChildren', reqList('/', { outList:['cantSeeThisButChildren/'] }))
56
+ it('cantSeeThisButChildren.children', reqList('/cantSeeThisButChildren', { inList:['hi/'] }))
57
+
58
+ it('protectFromAbove', req('/protectFromAbove/child/alfa.txt', 403))
59
+ it('protectFromAbove.list', reqList('/protectFromAbove/child/', { outList:['alfa.txt'] }))
60
+
61
+ it('zip.head', req('/f1/?get=zip', { empty:true, length:13074 }, { method:'HEAD' }) )
62
+ it('zip.alfa is forbidden', req('/protectFromAbove/child/?get=zip&list=alfa.txt*renamed', { empty: true, length:138 }, { method:'HEAD' }))
63
+ it('login', reqApi('login', { username, password }, 406)) // by default, we don't support clear-text login
64
})
65
66
let cookie:any
67
describe('after-login', () => {
43
- before(req('/~/api/login', (data, res) => Boolean(cookie = res.headers['set-cookie']), {
68
+ before(req(API+'login', (data, res) => Boolean(cookie = res.headers['set-cookie']), {
69
data: { username, password }
70
}))
71
it('list protected', done => // defer execution of req() to have cookie set
47
- req('/~/api/file_list', data => inList(data, 'alfa.txt'), {
72
+ req(API+'file_list', data => isInList(data, 'alfa.txt'), {
73
data: { path:'/for-admins/' },
74
headers: { cookie },
75
})(done))
76
})
77
53
-type Tester = number | ((data:any, fullResponse:any) => boolean | Error)
78
+type Tester = number
79
+ | ((data: any, fullResponse: any) => boolean | Error)
80
+ | RegExp
81
+ | {
82
+ mime?: string
83
+ status?: number
84
+ re?: RegExp
85
+ inList?: string[]
86
+ outList?: string[]
87
+ empty?: true
88
+ length?: number
89
+ }
90
91
function req(methodUrl: string, test:Tester, requestOptions?:any) {
92
return (done:Done) => {
@@ -65,10 +101,24 @@ function req(methodUrl: string, test:Tester, requestOptions?:any) {
101
102
function fun(res:any) {
103
console.debug('sent', requestOptions, 'got', res instanceof Error ? String(res) : [res.status, res.data])
68
- if (typeof test === 'number') {
69
- const got = res.status || res.response.status
70
- const ok = got === test
71
- return done(!ok && 'expected code '+test)
104
+ if (test && test instanceof RegExp)
105
+ test = { re:test }
106
+ if (typeof test === 'number')
107
+ test = { status: test }
108
+ if (typeof test === 'object') {
109
+ const { status, mime, re, inList, outList, length } = test
110
+ const gotMime = res.headers?.['content-type']
111
+ const gotStatus = (res.status|| res.response.status)
112
+ const gotLength = res.headers?.['content-length']
113
+ const err = mime && !gotMime?.startsWith(mime) ? 'expected mime ' + mime + ' got ' + gotMime
114
+ : status && gotStatus !== status ? 'expected status ' + status + ' got ' + gotStatus
115
+ : re && !(typeof res.data === 'string' && re.test(res.data)) ? 'expected content '+String(re)+' got '+res.data
116
+ : inList && !inList.every(x => isInList(res.data, x)) ? 'expected in list '+inList
117
+ : outList && !outList.every(x => !isInList(res.data, x)) ? 'expected not in list '+outList
118
+ : test.empty && res.data ? 'expected empty body'
119
+ : length !== undefined && gotLength !== String(length) ? "expected content-length " + length + " got " + gotLength
120
+ : ''
121
+ return done(err && Error(err))
122
}
123
const ok = test(res.data, res)
124
done(!ok && Error())
@@ -76,6 +126,14 @@ function req(methodUrl: string, test:Tester, requestOptions?:any) {
126
}
127
}
128
79
-function inList(res:any, name:string) {
129
+function reqApi(api: string, params: object, test:Tester) {
130
+ return req(API+api, test, { data: params })
131
+}
132
+
133
+function reqList(path:string, tester:Tester, params?: object) {
134
+ return reqApi('file_list', { path, ...params }, tester)
135
+}
136
+
137
+function isInList(res:any, name:string) {
138
return Array.isArray(res?.list) && Boolean((res.list as any[]).find(x => x.n===name))
139
}
todo.md
+11
-2
@@ -1,4 +1,15 @@
1
+NO: provare ad usare il name invece del rename: è complesso invertire, e bisogna comunque impedire di usarlo con le mask. Non vale la pena
2
+OK verificare che walknode/file_list non produca entries con search
3
+OK migliorare codice
4
+aggiornare admin gui
5
+counters: non contare richieste fallite
6
+consider having mime as ext,ext instead of *.ext|*.ext
7
# To do
8
+- admin: improve masks editor
9
+- if specified config is a folder, check for file config.yaml inside
10
+- merge accounts in config
11
+- frontend: ok button to inputDialogs
12
+- admin: in a group, show linked accounts
13
- admin/config: use filepicker for https files
14
- admin: warn in case of items with same name
15
- password protect admin
@@ -23,7 +34,5 @@
34
- config: min disk space
35
- thumbnails support
36
- webdav?
26
-- vfs: ability to remove/hide/rename files deep in a source
27
-- administration interface
37
- log: ip2name
38
- apis in separated log file with parameters?