use vfs.forbid to forbid listing

Massimo Melina committed Jan 22, 2022 at 18:46 UTC c9db79d6d6f12d1785fb466c70aadf396b48bbe0
9 files changed +32 -28
README.md
+2 -1
@@ -91,8 +91,9 @@ Valid keys in a node are:
91 - `children`: just for folders, specify its virtual children.
92 Value is a list and its entries are nodes.
93 - `hidden`: this must not be listed, but it's still downloadable.
94 +- `forbid`: set `true` to forbid listing for this folder
95 - `hide`: similar to hidden, but it's from the parent node point of view.
95 - Use this to hide entries that are read from the source, not listed in the VFS.
96 + Use this to hide children read from the source, not listed in the VFS.
97 Value can be just a file name, a mask, or a list of names/masks.
98 - `rename`: similar to name, but it's from the parent node point.
99 Use this to change the name of entries that are read from the source, not listed in the VFS.
src/api.auth.ts
+4 -4
@@ -106,9 +106,9 @@ export const refresh_session: ApiHandler = async ({}, ctx) => {
106 export const change_password: ApiHandler = async ({ newPassword }, ctx) => {
107 if (!newPassword) // clear text version
108 return Error('missing parameters')
109 - if (!ctx.account)
109 + if (!ctx.state.account)
110 return new ApiError(401)
111 - await updateAccount(ctx.account, account => {
111 + await updateAccount(ctx.state.account, account => {
112 account.password = newPassword
113 })
114 return {}
@@ -119,9 +119,9 @@ export const change_srp: ApiHandler = async ({ salt, verifier }, ctx) => {
119 return new ApiError(406)
120 if (!salt || !verifier)
121 return Error('missing parameters')
122 - if (!ctx.account)
122 + if (!ctx.state.account)
123 return new ApiError(401)
124 - await updateAccount(ctx.account, account => {
124 + await updateAccount(ctx.state.account, account => {
125 saveSrpInfo(account, salt, verifier)
126 delete account.hashed_password // remove leftovers
127 })
src/api.file_list.ts
+3
@@ -5,11 +5,14 @@ import { ApiError, ApiHandler } from './apis'
5 import { stat } from 'fs/promises'
6 import { mapPlugins } from './plugins'
7 import { pattern2filter } from './misc'
8 +import { FORBIDDEN } from './const'
9
10 export const file_list:ApiHandler = async ({ path, offset, limit, search, omit, sse }, ctx) => {
11 let node = await vfs.urlToNode(path || '/', ctx)
12 if (!node)
13 return
14 + if (node.forbid)
15 + return new ApiError(FORBIDDEN)
16 if (search?.includes('..'))
17 return new ApiError(400)
18 if (node.default)
src/const.ts
+1
@@ -14,3 +14,4 @@ export const argv = minimist(process.argv.slice(2))
14
15 export const METHOD_NOT_ALLOWED = 405
16 export const NO_CONTENT = 204
17 +export const FORBIDDEN = 403
src/index.ts
+6 -6
@@ -7,21 +7,21 @@ import { frontEndApis } from './frontEndApis'
7 import { log } from './log'
8 import { pluginsMiddleware } from './plugins'
9 import { throttler } from './throttler'
10 -import { getAccount, getCurrentUsername } from './perm'
10 +import { getAccount, getCurrentUsername, getCurrentUsernameExpanded } from './perm'
11 import { headRequests, gzipper, sessions, frontendAndSharedFiles } from './middlewares'
12 import './listen'
13
14 export const BUILD_TIMESTAMP = "-"
15 export const SESSION_DURATION = 30*60_000
16
17 -console.log('started', new Date().toLocaleString())
18 -console.log('build', BUILD_TIMESTAMP, DEV)
17 +console.log('started', new Date().toLocaleString(), 'build', BUILD_TIMESTAMP, DEV)
18 console.debug('cwd', process.cwd())
20 -export const app = new Koa()
21 -app.keys = ['hfs-keys-test']
19 +export const app = new Koa({ keys: ['hfs-keys-test'] })
20 +
21 app.use(sessions(app))
22 app.use(async (ctx, next) => {
24 - ctx.account = getAccount(getCurrentUsername(ctx))
23 + ctx.state.usernames = getCurrentUsernameExpanded(ctx) // accounts chained via .belongs for permissions check
24 + ctx.state.account = getAccount(getCurrentUsername(ctx))
25 await next()
26 })
27 app.use(headRequests)
src/middlewares.ts
+3 -1
@@ -3,7 +3,7 @@ import Koa from 'koa'
3 import session from 'koa-session'
4 import { BUILD_TIMESTAMP, SESSION_DURATION } from './index'
5 import Application from 'koa'
6 -import { FRONTEND_URI } from './const'
6 +import { FORBIDDEN, FRONTEND_URI } from './const'
7 import { vfs } from './vfs'
8 import { isDirectory } from './misc'
9 import { zipStreamFromFolder } from './zip'
@@ -59,6 +59,8 @@ export const frontendAndSharedFiles: Koa.Middleware = async (ctx, next) => {
59 if (!node)
60 return next()
61 const { source } = node
62 + if (node.forbid)
63 + return ctx.status = FORBIDDEN
64 if (!source || await isDirectory(source)) {
65 const { get } = ctx.query
66 if (get === 'zip')
src/perm.ts
+2 -2
@@ -10,7 +10,7 @@ import { createVerifierAndSalt, SRPParameters, SRPRoutines } from 'tssrp6a'
10
11 let path = ''
12
13 -interface Account {
13 +export interface Account {
14 username: string, // we'll have username in it, so we don't need to pass it separately
15 password?: string
16 hashed_password?: string
@@ -28,7 +28,7 @@ export function getCurrentUsername(ctx: Koa.Context): string {
28 }
29
30 // provides the username and all other usernames it inherits based on the 'belongs' attribute. Useful to check permissions
31 -export async function getCurrentUsernameExpanded(ctx: Koa.Context) {
31 +export function getCurrentUsernameExpanded(ctx: Koa.Context) {
32 const who = getCurrentUsername(ctx)
33 if (!who)
34 return []
src/throttler.ts
+1 -1
@@ -16,7 +16,7 @@ export function throttler(): Koa.Middleware {
16 return async (ctx, next) => {
17 await next()
18 const { body } = ctx
19 - if (!body || !(body instanceof Readable) || ctx.account?.ignore_limits)
19 + if (!body || !(body instanceof Readable) || ctx.state.account?.ignore_limits)
20 return
21 const ipGroup = getOrSet(ip2group, ctx.ip, ()=> {
22 const tg = new ThrottleGroup(Infinity, mainThrottleGroup)
src/vfs.ts
+10 -13
@@ -2,7 +2,6 @@ import fs from 'fs/promises'
2 import { basename } from 'path'
3 import { isMatch } from 'micromatch'
4 import { complySlashes, enforceFinal, isDirectory, prefix, wantArray } from './misc'
5 -import { getCurrentUsernameExpanded } from './perm'
5 import Koa from 'koa'
6 import glob from 'fast-glob'
7 import _ from 'lodash'
@@ -21,6 +20,7 @@ export interface VfsNode {
20 hide?: string | string[],
21 remove?: string | string[],
22 hidden?: boolean,
23 + forbid?: boolean,
24 rename?: Record<string,string>,
25 perm?: Record<string, SinglePerm>,
26 default?: string,
@@ -41,16 +41,15 @@ export class Vfs {
41 }
42
43 async urlToNode(url: string, ctx: Koa.Context, root?: VfsNode) : Promise<VfsNode | undefined> {
44 - const users = await getCurrentUsernameExpanded(ctx)
44 let run = root || this.root
45 const rest = url.split('/').filter(Boolean).map(decodeURIComponent)
47 - if (forbidden(run, users)) return
46 + if (!hasPermission(run, ctx)) return
47 while (rest.length) {
48 let piece = rest.shift() as string
49 const child = findChildByName(piece, run)
50 if (child) {
51 run = child
53 - if (forbidden(run, users)) return
52 + if (!hasPermission(run, ctx)) return
53 continue
54 }
55 if (!run.source)
@@ -93,22 +92,20 @@ function findChildByName(name:string, node:VfsNode) {
92 return node?.children?.find(x => x.name === name)
93 }
94
96 -export function forbidden(node:VfsNode, users:string[]) {
95 +export function hasPermission(node:VfsNode, ctx: Koa.Context) {
96 const { perm } = node
98 - return perm && !users.some(u => perm[u])
97 + return !perm || ctx.state.usernames.some((u:string) => perm[u])
98 }
99
101 -
100 export async function* walkNode(parent:VfsNode, ctx: Koa.Context, depth:number=0, prefixPath:string=''): AsyncIterableIterator<VfsNode> {
101 const { children, source } = parent
104 - ctx._who = ctx._who || await getCurrentUsernameExpanded(ctx) // cache value
102 if (children)
106 - for (const c of children) {
107 - if (c.hidden || forbidden(c, ctx._who))
103 + for (const node of children) {
104 + if (node.hidden || !hasPermission(node, ctx))
105 continue
109 - yield prefixPath ? { ...c, name: prefixPath+c.name } : c
110 - if (depth > 0 && c && (c.children || c.source && await isDirectory(c.source)))
111 - yield* walkNode(c, ctx, depth - 1, prefixPath+c.name+'/')
106 + yield prefixPath ? { ...node, name: prefixPath+node.name } : node
107 + if (depth > 0 && node && (node.children || node.source && await isDirectory(node.source)))
108 + yield* walkNode(node, ctx, depth - 1, prefixPath+node.name+'/')
109 }
110 if (!source)
111 return