user.belongs (for grouping)

Massimo Melina committed Dec 25, 2021 at 12:19 UTC 6bf3ec80fd9619dd0f50fbefb7bbe32e87e3570a
8 files changed +77 -36
accounts.yaml
+4 -1
@@ -1,3 +1,6 @@
1 accounts:
2 rejetto:
3 - hashedPassword: p2:djAx2PvVT+ygZ5H3FB4U2hwzgQ9CQAqh5CNzyMEaADIJt2Y/e6Ee+LjKEzSnQoxeivjFiFe3
3 + belongs: admin
4 + password: password
5 + admin:
6 + belongs: []
src/apis.ts
+2 -2
@@ -2,7 +2,7 @@ import Koa from 'koa'
2 import { vfs, VfsNode, walkNode } from './vfs'
3 import { stat } from 'fs/promises'
4 import _ from 'lodash'
5 -import { getCurrentUser, verifyLogin } from './perm'
5 +import { getCurrentUserExpanded, verifyLogin } from './perm'
6 import { sessions } from './sessions'
7 import createSSE from './sse'
8 import { basename } from 'path'
@@ -51,7 +51,7 @@ export const frontEndApis: ApiHandlers = {
51 limit = Number(limit)
52 const re = new RegExp(_.escapeRegExp(search),'i')
53 const match = (s?:string) => !s || !search || re.test(s)
54 - const who = await getCurrentUser(ctx) // cache value
54 + const who = await getCurrentUserExpanded(ctx) // cache value
55 const walker = walkNode(node, who, search ? Infinity : 0)
56 const sseSrv = sse ? createSSE(ctx) : null
57 const res = produceEntries()
src/misc.ts
+4
@@ -39,3 +39,7 @@ export async function readFileBusy(path: string): Promise<string> {
39 return wait(100).then(()=> readFileBusy(path))
40 })
41 }
42 +
43 +export function wantArray(x:any) {
44 + return x == null ? [] : Array.isArray(x) ? x : [x]
45 +}
src/perm.ts
+28 -3
@@ -4,7 +4,7 @@ import _ from 'lodash'
4 import yaml from 'yaml'
5 import { hashPassword, verifyPassword } from './crypt'
6 import { argv } from './const'
7 -import { readFileBusy, setHidden } from './misc'
7 +import { readFileBusy, setHidden, wantArray } from './misc'
8 import { SESSION_COOKIE } from './apis'
9 import { sessions } from './sessions'
10 import Koa from 'koa'
@@ -14,7 +14,8 @@ const PATH = argv.accounts || 'accounts.yaml'
14 interface UserDetails {
15 user: string, // we'll have user in it, so we don't need to pass it separately
16 password?: string
17 - hashedPassword: string
17 + hashedPassword?: string
18 + belongs?: string[]
19 }
20 interface Accounts { [username:string]: UserDetails }
21
@@ -25,9 +26,28 @@ export async function getCurrentUser(ctx: Koa.Context) {
26 return id && sessions.get(id)?.user || ''
27 }
28
29 +export async function getCurrentUserExpanded(ctx: Koa.Context) {
30 + const who = await getCurrentUser(ctx)
31 + if (!who)
32 + return []
33 + const ret = [who]
34 + for (const u of ret) {
35 + const a = getAccount(u)
36 + if (a?.belongs)
37 + ret.push(...a.belongs)
38 + }
39 + return ret
40 +}
41 +
42 export async function verifyLogin(user:string, password: string) {
43 const acc = accounts[user]
30 - return acc && verifyPassword(acc.hashedPassword, password)
44 + if (!acc) return
45 + const { hashedPassword: h } = acc
46 + return h && verifyPassword(h, password)
47 +}
48 +
49 +export function getAccount(user:string) : UserDetails {
50 + return accounts[user]
51 }
52
53 let doing = false
@@ -53,7 +73,12 @@ async function load() {
73 accounts = res.accounts
74 let changed = false
75 await Promise.all(_.map(accounts, async (rec,k) => {
76 + if (!rec) // an empty object in yaml is stored as null
77 + rec = accounts[k] = { user: '' }
78 setHidden(rec, { user: k })
79 + rec.belongs = wantArray(rec.belongs).filter(b =>
80 + b in accounts // at this stage the group record may still be null if specified later in the file
81 + || console.error(`user ${k} belongs to non-existing ${b}`) )
82 if (rec.password) {
83 rec.hashedPassword = await hashPassword(rec.password)
84 delete rec.password
src/vfs.ts
+9 -15
@@ -5,7 +5,7 @@ import { FSWatcher, watch } from 'fs'
5 import { dirname, basename } from 'path'
6 import { isMatch } from 'micromatch'
7 import { complySlashes, enforceFinal, prefix, readFileBusy } from './misc'
8 -import { getCurrentUser } from './perm'
8 +import { getCurrentUserExpanded } from './perm'
9 import Koa from 'koa'
10 import glob from 'fast-glob'
11 import _ from 'lodash'
@@ -83,16 +83,16 @@ export class Vfs {
83 }
84
85 async urlToNode(url: string, ctx: Koa.Context) : Promise<VfsNode | undefined> {
86 - const who = await getCurrentUser(ctx)
86 + const users = await getCurrentUserExpanded(ctx)
87 let run = this.root
88 const rest = url.split('/').filter(Boolean).map(decodeURIComponent)
89 - if (forbidden()) return
89 + if (forbidden(run, users)) return
90 while (rest.length) {
91 let piece = rest.shift() as string
92 const child = findChildByName(piece, run)
93 if (child) {
94 run = child
95 - if (forbidden()) return
95 + if (forbidden(run, users)) return
96 continue
97 }
98 if (!run.source)
@@ -104,12 +104,6 @@ export class Vfs {
104 return removed || !await fs.stat(source) ? undefined : { source, mime: run.mime || (run.default && MIME_AUTO) }
105 }
106 return run
107 -
108 - function forbidden() {
109 - const { perm } = run
110 - return perm && (!perm[who] || perm['*'])
111 - }
112 -
107 }
108
109 }
@@ -123,21 +117,21 @@ function findChildByName(name:string, node:VfsNode) {
117 return node?.children?.find(x => x.name === name)
118 }
119
126 -export function directPermOnNode(node:VfsNode, username:string) {
120 +export function forbidden(node:VfsNode, users:string[]) {
121 const { perm } = node
128 - return !perm ? 'r' : (username && perm[username] || perm['*'])
122 + return perm && !users.some(u => perm[u])
123 }
124
125
132 -export async function* walkNode(parent:VfsNode, who:string, depth:number=0, prefixPath:string=''): AsyncIterableIterator<VfsNode> {
126 +export async function* walkNode(parent:VfsNode, who:string[], depth:number=0, prefixPath:string=''): AsyncIterableIterator<VfsNode> {
127 const { children, source } = parent
128 if (children)
129 for (const c of children) {
136 - if (c.hidden || !directPermOnNode(c,who))
130 + if (c.hidden || forbidden(c, who))
131 continue
132 yield prefixPath ? { ...c, name: prefixPath+c.name } : c
133 if (depth > 0 && c)
140 - yield* walkNode(c, '', depth - 1, prefixPath+c.name+'/')
134 + yield* walkNode(c, who, depth - 1, prefixPath+c.name+'/')
135 }
136 if (!source)
137 return
tests/test.ts
+28 -12
@@ -10,10 +10,10 @@ const appStarted = new Promise(resolve =>
10 describe('basics', () => {
11 //before(async () => appStarted)
12 it('frontend', req('/', s => s.includes('<body>')))
13 - it('api.list', req('/~/api/file_list', res => inList(res, 'f2/') && inList(res, 'page'), {
13 + it('api.list', req('/~/api/file_list', data => inList(data, 'f2/') && inList(data, 'page'), {
14 data: { path:'/f1/' }
15 }))
16 - it('api.search', req('/~/api/file_list', res => inList(res, 'f2/') && !inList(res, 'page'), {
16 + it('api.search', req('/~/api/file_list', data => inList(data, 'f2/') && !inList(data, 'page'), {
17 data: { path:'f1', search:'2' }
18 }))
19 it('download', req('/f1/f2/alfa.txt', s => s.includes('abcd')))
@@ -21,8 +21,23 @@ describe('basics', () => {
21 headers: { Range: 'bytes=0-2' }
22 }))
23 it('website', req('/f1/page/', s => s.includes('This is a test')))
24 - it('missing perm', req('/for-rejetto/', 404))
24 + it('missing perm', req('/for-admins/', 404))
25 it('proxy', req('/proxy', s => s.includes('github')))
26 + it('login', req('/~/api/login', 200, {
27 + data: { user:'rejetto', password:'password' }
28 + }))
29 +})
30 +
31 +let cookie:any
32 +describe('after-login', () => {
33 + before(req('/~/api/login', (data, res) => Boolean(cookie = res.headers['set-cookie']), {
34 + data: { user: 'rejetto', password: 'password' }
35 + }))
36 + it('list protected', done => // defer execution of req() to have cookie set
37 + req('/~/api/file_list', data => inList(data, 'alfa.txt'), {
38 + data: { path:'/for-admins/' },
39 + headers: { cookie },
40 + })(done))
41 })
42
43 type Tester = number | ((data:any, fullResponse:any) => boolean | Error)
@@ -32,24 +47,25 @@ function req(methodUrl: string, test:Tester, requestOptions?:any) {
47 const i = methodUrl.indexOf('/')
48 const method = methodUrl.slice(0,i) || requestOptions?.data && 'POST' || 'GET'
49 const url = 'http://localhost'+methodUrl.slice(i)
50 + axios.request({ method, url, ...requestOptions })
51 + .then(fun, fun)
52 + .catch(err => {
53 + done(err)
54 + })
55 +
56 function fun(res:any) {
57 + console.debug('sent', requestOptions, 'got', res instanceof Error ? String(res) : res)
58 if (typeof test === 'number') {
37 - const ok = (res.status || res.response.status) === test
59 + const got = res.status || res.response.status
60 + const ok = got === test
61 return done(!ok && 'expected code '+test)
62 }
63 const ok = test(res.data, res)
41 - if (!ok)
42 - console.debug('sent', requestOptions, 'got',res.data)
64 done(!ok && Error())
65 }
45 - axios.request({ method, url, ...requestOptions })
46 - .then(fun, fun)
47 - .catch(err => {
48 - done(err)
49 - })
66 }
67 }
68
69 function inList(res:any, name:string) {
54 - return Array.isArray(res.list) && Boolean(res.list.find((x:any) => x.n===name))
70 + return Array.isArray(res?.list) && Boolean((res.list as any[]).find(x => x.n===name))
71 }
todo.md
-1
@@ -1,5 +1,4 @@
1 # To do
2 -- user should be able to inherit from another a group (another user)
2 - file sorting
3 - let user change password (need dialogs)
4 - folders before?
vfs.yaml
+2 -2
@@ -12,8 +12,8 @@ children:
12 source: tests/page
13 - name: proxy
14 source: https://raw.githubusercontent.com/nodejs/node/master/README.md
15 - - name: for-rejetto
15 + - name: for-admins
16 perm:
17 - rejetto: r
17 + admin: r
18 children:
19 - source: tests/alfa.txt