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