prompt for login when directly linking a protected resource
Massimo Melina committed
Jun 13, 2022 at 16:52 UTC
1bbbbf4669327f0aeafee19e26532509618f2db9
16 files changed
+146
-77
frontend/src/App.ts
+5
-1
@@ -5,13 +5,17 @@ import { createElement as h, Fragment } from 'react'
5
import { BrowseFiles } from "./BrowseFiles"
6
import { Dialogs } from './dialog'
7
import useTheme from "./useTheme"
8
+import { useSnapState } from './state'
9
10
function App() {
11
useTheme()
12
+ const { messageOnly } = useSnapState()
13
+ if (messageOnly)
14
+ return h('h1', { style: { textAlign: 'center'} }, messageOnly)
15
return h(Fragment, {},
16
h(BrowserRouter, {},
17
h(Routes, {},
14
- h(Route, { path:'*', element:h(BrowseFiles) })
18
+ h(Route, { path:'*', element: h(BrowseFiles) })
19
)
20
),
21
h(Dialogs)
frontend/src/BrowseFiles.ts
+7
-6
@@ -8,6 +8,7 @@ import { Head } from './Head'
8
import { state, useSnapState } from './state'
9
import { alertDialog } from './dialog'
10
import useFetchList from './useFetchList'
11
+import useAuthorized from './useAuthorized'
12
13
export function usePath() {
14
return decodeURI(useLocation().pathname)
@@ -20,10 +21,10 @@ export type DirList = DirEntry[]
21
export function BrowseFiles() {
22
useFetchList()
23
const { error, list, serverConfig } = useSnapState()
23
- return h(Fragment, {},
24
+ return useAuthorized() && h(Fragment, {},
25
h(Html, { code: serverConfig?.custom_header }),
26
h(Head),
26
- hError(error && 'Failed to retrieve list')
27
+ hError(error)
28
|| h(list ? FilesList : Spinner))
29
}
30
@@ -41,8 +42,8 @@ function FilesList() {
42
43
return h(Fragment, {},
44
h('ul', { className: 'dir' },
44
- !list.length ? (!loading && (stoppedSearch ? 'Stopped before finding anything' : 'Nothing here'))
45
- : filteredList && !filteredList.length ? 'No match for this filter'
45
+ !list.length ? (!loading && (stoppedSearch ? "Stopped before finding anything" : "Nothing here"))
46
+ : filteredList && !filteredList.length ? "No match for this filter"
47
: theList.slice(offset, offset + pageSize).map((entry: DirEntry) =>
48
h(Entry, { key: entry.n, midnight, ...entry })),
49
loading && h(Spinner),
@@ -126,14 +127,14 @@ const EntryProps = memo(function(entry: DirEntry & { midnight: Date }) {
127
h(Html, { code, className:'add-props' }),
128
s !== undefined && h(Fragment, {},
129
h('span', { className:'entry-size' }, formatBytes(s)),
129
- ' — ',
130
+ " — ",
131
),
132
t && h('span', {
133
className: 'entry-ts',
134
title: today || !shortTs ? null : t.toLocaleString(),
135
onClick() { // mobile has no hover
136
if (shortTs)
136
- alertDialog('Full timestamp:\n' + t.toLocaleString()).then()
137
+ alertDialog("Full timestamp:\n" + t.toLocaleString()).then()
138
}
139
}, !shortTs ? t.toLocaleString() : today ? t.toLocaleTimeString() : t.toLocaleDateString()),
140
)
frontend/src/dialog.ts
+4
-2
@@ -70,7 +70,9 @@ export async function alertDialog(msg: ReactElement | string | Error, type:Alert
70
}
71
72
interface ConfirmOptions { href?: string }
73
-export async function confirmDialog(msg: string, { href }: ConfirmOptions={}) : Promise<boolean> {
73
+export async function confirmDialog(msg: ReactElement | string, { href }: ConfirmOptions={}) : Promise<boolean> {
74
+ if (typeof msg === 'string')
75
+ msg = h('p', {}, msg)
76
return new Promise(resolve => newDialog({
77
className: 'dialog-confirm',
78
icon: '?',
@@ -80,7 +82,7 @@ export async function confirmDialog(msg: string, { href }: ConfirmOptions={}) :
82
83
function Content() {
84
return h('div', {},
83
- h('p', {}, msg),
85
+ msg,
86
h('a', {
87
href,
88
onClick: () => closeDialog(true),
frontend/src/login.ts
+1
@@ -10,6 +10,7 @@ export async function login(username:string, password:string) {
10
return srpSequence(username, password, apiCall).then(res => {
11
stopWorking()
12
sessionRefresher(res)
13
+ state.loginRequired = false
14
return res
15
}, (err: Error) => {
16
stopWorking()
frontend/src/menu.ts
+13
-13
@@ -4,7 +4,7 @@ import { state, useSnapState } from './state'
4
import { createElement as h, useEffect, useState } from 'react'
5
import { useDebounce } from 'use-debounce'
6
import { confirmDialog, promptDialog } from './dialog'
7
-import { hIcon, isMobile, prefix } from './misc'
7
+import { hIcon, isMobile, prefix, useStateMounted } from './misc'
8
import { login } from './login'
9
import { showOptions } from './options'
10
import showUserPanel from './UserPanel'
@@ -20,12 +20,12 @@ export function MenuPanel() {
20
state.selected = {}
21
}, [showFilter])
22
23
- const [started1secAgo, setStarted1secAgo] = useState(false)
23
+ const [started1secAgo, setStarted1secAgo] = useStateMounted(false)
24
useEffect(() => {
25
if (!stopSearch) return
26
setStarted1secAgo(false)
27
setTimeout(() => setStarted1secAgo(true), 1000)
28
- }, [stopSearch])
28
+ }, [stopSearch, setStarted1secAgo])
29
30
//TODO do something for list > 63KB as it hit the url limit (1kb reserved for the rest for the url)
31
const list = Object.keys(selected).map(s => s.endsWith('/') ? s.slice(0,-1) : s).join('*')
@@ -160,16 +160,16 @@ function LoginButton() {
160
} : {
161
icon: 'login',
162
label: 'Login',
163
- async onClick() {
164
- const user = await promptDialog('Username')
165
- if (!user) return
166
- const password = await promptDialog('Password', { type: 'password' })
167
- if (!password) return
168
- const res = await login(user, password)
169
- if (res?.redirect)
170
- navigate(res.redirect)
171
- }
163
+ onClick: () => loginDialog(navigate),
164
})
165
}
166
175
-
167
+export async function loginDialog(navigate: ReturnType<typeof useNavigate>) {
168
+ const user = await promptDialog('Username')
169
+ if (!user) return
170
+ const password = await promptDialog('Password', { type: 'password' })
171
+ if (!password) return
172
+ const res = await login(user, password)
173
+ if (res?.redirect)
174
+ navigate(res.redirect)
175
+}
frontend/src/misc.ts
+1
-1
@@ -11,7 +11,7 @@ export function hIcon(name: string, props?:any) {
11
return h(Icon, { name, ...props })
12
}
13
14
-export function hError(err: Error | string | null) {
14
+export function hError(err: Error | string | undefined) {
15
return err && h('div', { className:'error-msg' }, typeof err === 'string' ? err : err.message)
16
}
17
frontend/src/state.ts
+3
-2
@@ -14,7 +14,7 @@ export const state = proxy<{
14
list: DirList,
15
filteredList?: DirList,
16
loading: boolean,
17
- error: Error | null,
17
+ error?: string,
18
listReloader: number,
19
patternFilter: string,
20
showFilter: boolean,
@@ -26,13 +26,14 @@ export const state = proxy<{
26
theme: string,
27
adminUrl?: string,
28
serverConfig?: any,
29
+ loginRequired?: boolean, // force user to login before proceeding
30
+ messageOnly?: string, // no gui, just show this message
31
}>({
32
iconsClass: '',
33
username: '',
34
list: [],
35
filteredList: undefined,
36
loading: false,
35
- error: null,
37
listReloader: 0,
38
patternFilter: '',
39
showFilter: false,
frontend/src/useAuthorized.ts
new
+17
@@ -0,0 +1,17 @@
1
+import { state, useSnapState } from './state'
2
+import { useNavigate } from 'react-router-dom'
3
+import { useEffect } from 'react'
4
+import { loginDialog } from './menu'
5
+
6
+export default function useAuthorized() {
7
+ const { loginRequired } = useSnapState()
8
+ const navigate = useNavigate()
9
+ useEffect(() => {
10
+ (async () => {
11
+ while (state.loginRequired)
12
+ await loginDialog(navigate).then()
13
+ })()
14
+ }, [loginRequired, navigate])
15
+ return loginRequired ? null : true
16
+}
17
+
frontend/src/useFetchList.ts
+21
-7
@@ -14,10 +14,6 @@ export default function useFetchList() {
14
const lastPath = useRef('')
15
16
useEffect(()=>{
17
- if (!desiredPath.endsWith('/')) { // useful only in dev, while accessing the frontend directly without passing by the main server
18
- window.location.href = window.location.href + '/'
19
- return
20
- }
17
const previous = lastPath.current
18
lastPath.current = desiredPath
19
if (previous !== desiredPath) {
@@ -36,7 +32,7 @@ export default function useFetchList() {
32
state.filteredList = undefined
33
state.selected = {}
34
state.loading = true
39
- state.error = null
35
+ state.error = undefined
36
// buffering entries is necessary against burst of events that will hang the browser
37
const buffer: DirList = []
38
const flush = () => {
@@ -49,7 +45,7 @@ export default function useFetchList() {
45
switch (type) {
46
case 'error':
47
state.stopSearch?.()
52
- return state.error = Error(JSON.stringify(data))
48
+ return state.error = JSON.stringify(data)
49
case 'closed':
50
flush()
51
state.stopSearch?.()
@@ -57,7 +53,21 @@ export default function useFetchList() {
53
case 'msg':
54
if (src?.readyState === src?.CLOSED)
55
return state.stopSearch?.()
60
- buffer.push(data.entry)
56
+ if (!data) return
57
+ if (data.add)
58
+ return buffer.push(data.add)
59
+ const { error } = data
60
+ if (error === 405) { // "method not allowed" happens when we try to directly access an unauthorized file, and we get a login prompt, and then file_list the file (because we didn't know it was file or folder)
61
+ state.messageOnly = "Your download should now start"
62
+ window.location.reload() // reload will start the download, because now we got authenticated
63
+ return
64
+ }
65
+ if (error) {
66
+ state.stopSearch?.()
67
+ state.error = (ERRORS as any)[error] || String(error)
68
+ state.loginRequired = error === 401
69
+ return
70
+ }
71
}
72
})
73
state.stopSearch = ()=>{
@@ -70,6 +80,10 @@ export default function useFetchList() {
80
}, [desiredPath, search, snap.username, snap.listReloader])
81
}
82
83
+const ERRORS = {
84
+ 404: "Not found"
85
+}
86
+
87
export function reloadList() {
88
state.listReloader = Date.now()
89
}
server/src/api.file_list.ts
+28
-11
@@ -1,28 +1,45 @@
1
// This file is part of HFS - Copyright 2021-2022, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
-import { cantReadStatusCode, getNodeName, hasPermission, urlToNode, VfsNode, walkNode } from './vfs'
4
-import { ApiError, ApiHandler } from './apiMiddleware'
3
+import { cantReadStatusCode, getNodeName, hasPermission, nodeIsDirectory, urlToNode, VfsNode, walkNode } from './vfs'
4
+import { ApiError, ApiHandler, sendList } from './apiMiddleware'
5
import { stat } from 'fs/promises'
6
import { mapPlugins } from './plugins'
7
-import { asyncGeneratorToArray, asyncGeneratorToReadable, dirTraversal, filterMapGenerator, pattern2filter } from './misc'
7
+import { asyncGeneratorToArray, dirTraversal, pattern2filter } from './misc'
8
+import _ from 'lodash'
9
9
-export const file_list:ApiHandler = async ({ path, offset, limit, search, omit, sse }, ctx) => {
10
+export const file_list: ApiHandler = async ({ path, offset, limit, search, omit, sse }, ctx) => {
11
let node = await urlToNode(path || '/', ctx)
12
+ const list = sendList()
13
if (!node)
12
- return new ApiError(404)
14
+ return fail(404)
15
if (!hasPermission(node,'can_read',ctx))
14
- return new ApiError(cantReadStatusCode(node))
16
+ return fail(cantReadStatusCode(node))
17
if (dirTraversal(search))
16
- return new ApiError(418)
18
+ return fail(418)
19
if (node.default)
18
- return { redirect: path }
20
+ return (sse ? list.custom : _.identity)({ redirect: path })
21
+ if (!await nodeIsDirectory(node))
22
+ return fail(405) // method not allowed on target
23
offset = Number(offset)
24
limit = Number(limit)
25
const filter = pattern2filter(search)
26
const walker = walkNode(node, ctx, search ? Infinity : 0)
27
const onDirEntryHandlers = mapPlugins(plug => plug.onDirEntry)
24
- return sse ? filterMapGenerator(produceEntries(), async entry => ({ entry })) // wrap entry in an object
25
- : { list: await asyncGeneratorToArray(produceEntries()) }
28
+ if (!sse)
29
+ return { list: await asyncGeneratorToArray(produceEntries()) }
30
+ setTimeout(async () => {
31
+ for await (const entry of produceEntries())
32
+ list.add(entry)
33
+ list.end()
34
+ })
35
+ return list.return
36
+
37
+ function fail(code: any) {
38
+ if (!sse)
39
+ return new ApiError(code)
40
+ list.error(code)
41
+ return list.return
42
+ }
43
44
async function* produceEntries() {
45
for await (const sub of walker) {
@@ -38,7 +55,7 @@ export const file_list:ApiHandler = async ({ path, offset, limit, search, omit,
55
continue
56
}
57
catch(e) {
41
- console.log('a plugin with onDirEntry is causing problems:', e)
58
+ console.log("a plugin with onDirEntry is causing problems:", e)
59
}
60
if (offset) {
61
--offset
server/src/api.vfs.ts
+2
-2
@@ -35,8 +35,8 @@ const apis: ApiHandlers = {
35
const dir = await nodeIsDirectory(node)
36
const stats: Pick<VfsAdmin, 'size' | 'ctime' | 'mtime'> = {}
37
try {
38
- if (node.source && !dir)
39
- Object.assign(stats, _.pick(await stat(node.source), ['size', 'ctime', 'mtime']))
38
+ if (!dir)
39
+ Object.assign(stats, _.pick(await stat(node.source!), ['size', 'ctime', 'mtime']))
40
}
41
catch {
42
stats.size = -1
server/src/apiMiddleware.ts
+11
-3
@@ -71,17 +71,25 @@ export function sendList<T>(addAtStart?: T[]) {
71
const stream = new Readable({ objectMode: true, read(){} })
72
const ret = {
73
return: stream,
74
- add(rec: T) { stream.push({ add: rec }) },
75
- remove(key: Partial<T>) { stream.push({ remove: [ key ] }) },
74
+ add(rec: T) {
75
+ stream.push({ add: rec })
76
+ },
77
+ remove(key: Partial<T>) {
78
+ stream.push({ remove: [key] })
79
+ },
80
update(search: Partial<T>, change: Partial<T>) {
81
stream.push({ update:[{ search, change }] })
82
},
83
end() { // notify end of additions
84
stream.push('end')
85
+ stream.push(null)
86
},
82
- error(msg: string) {
87
+ error(msg: string | number) {
88
stream.push({ error: msg })
89
},
90
+ custom(data: any) {
91
+ stream.push(data)
92
+ },
93
events(ctx: Koa.Context, eventMap: Parameters<typeof onOff>[1]) {
94
const off = onOff(events, eventMap)
95
ctx.res.once('close', off)
server/src/const.ts
+1
@@ -26,6 +26,7 @@ export const argv = minimist(process.argv.slice(2))
26
export const METHOD_NOT_ALLOWED = 405
27
export const NO_CONTENT = 204
28
export const FORBIDDEN = 403
29
+export const UNAUTHORIZED = 401
30
31
export const IS_WINDOWS = process.platform === 'win32'
32
server/src/middlewares.ts
+26
-24
@@ -3,11 +3,11 @@
3
import compress from 'koa-compress'
4
import Koa from 'koa'
5
import session from 'koa-session'
6
-import { ADMIN_URI, BUILD_TIMESTAMP, DEV, SESSION_DURATION } from './const'
6
+import { ADMIN_URI, BUILD_TIMESTAMP, DEV, FORBIDDEN, SESSION_DURATION } from './const'
7
import Application from 'koa'
8
import { FRONTEND_URI } from './const'
9
-import { cantReadStatusCode, hasPermission, urlToNode } from './vfs'
10
-import { dirTraversal, isDirectory } from './misc'
9
+import { cantReadStatusCode, hasPermission, nodeIsDirectory, urlToNode } from './vfs'
10
+import { dirTraversal } from './misc'
11
import { zipStreamFromFolder } from './zip'
12
import { serveFileNode } from './serveFile'
13
import { serveGuiFiles } from './serveGuiFiles'
@@ -63,31 +63,33 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
63
if (path.startsWith(ADMIN_URI))
64
return serveAdminPrefixed(ctx,next)
65
const node = await urlToNode(path, ctx)
66
- if (!node) {
67
- ctx.body = "Not found. Sometimes you need to login first."
66
+ if (!node)
67
return ctx.status = 404
69
- }
70
- if (!hasPermission(node, 'can_read', ctx))
71
- return ctx.status = cantReadStatusCode(node)
72
- const { source } = node
73
- if (!source || await isDirectory(source)) {
74
- const { get } = ctx.query
75
- if (get === 'zip')
76
- return await zipStreamFromFolder(node, ctx)
77
- if (!path.endsWith('/')) // this folder was requested without the trailing /
68
+ const cantRead = !hasPermission(node, 'can_read', ctx)
69
+ const isFolder = await nodeIsDirectory(node)
70
+ if (!cantRead && !isFolder)
71
+ return node.source ? serveFileNode(node)(ctx,next)
72
+ : next()
73
+ ctx.set({ server:'HFS '+BUILD_TIMESTAMP })
74
+ if (cantRead) {
75
+ ctx.status = cantReadStatusCode(node)
76
+ if (ctx.status === FORBIDDEN)
77
+ return
78
+ // this folder was requested without the trailing / and we may still log in
79
+ if (isFolder && !path.endsWith('/') && !ctx.state.account)
80
return ctx.redirect(path + '/')
79
- if (node.default) {
80
- const def = await urlToNode(path + node.default, ctx)
81
- return !def ? next()
82
- : hasPermission(def, 'can_read', ctx) ? serveFileNode(def)(ctx, next)
83
- : ctx.status = cantReadStatusCode(def)
84
- }
85
- ctx.set({ server:'HFS '+BUILD_TIMESTAMP })
81
return serveFrontendFiles(ctx, next)
82
}
88
- if (source)
89
- return serveFileNode(node)(ctx,next)
90
- return next()
83
+ const { get } = ctx.query
84
+ if (get === 'zip')
85
+ return await zipStreamFromFolder(node, ctx)
86
+ if (node.default) {
87
+ const def = await urlToNode(path + node.default, ctx)
88
+ return !def ? next()
89
+ : hasPermission(def, 'can_read', ctx) ? serveFileNode(def)(ctx, next)
90
+ : ctx.status = cantReadStatusCode(def)
91
+ }
92
+ return serveFrontendFiles(ctx, next)
93
}
94
95
let proxyDetected = false
server/src/serveGuiFiles.ts
+5
-4
@@ -2,7 +2,7 @@
2
3
import Koa from 'koa'
4
import fs from 'fs/promises'
5
-import { METHOD_NOT_ALLOWED, NO_CONTENT, PLUGINS_PUB_URI } from './const'
5
+import { METHOD_NOT_ALLOWED, NO_CONTENT, PLUGINS_PUB_URI, UNAUTHORIZED } from './const'
6
import { serveFile } from './serveFile'
7
import { mapPlugins } from './plugins'
8
import { refresh_session } from './api.auth'
@@ -15,8 +15,9 @@ const DEV_STATIC = process.env.DEV ? '../dist/' : ''
15
function serveStatic(uri: string): Koa.Middleware {
16
const folder = uri.slice(2,-1) // we know folder is very similar to uri
17
return async (ctx, next) => {
18
- const isDir = ctx.path.endsWith('/')
19
- const fullPath = path.join(__dirname, '..', DEV_STATIC, folder, isDir? '/index.html': ctx.path)
18
+ const loginRequired = ctx.status === UNAUTHORIZED
19
+ const serveApp = ctx.path.endsWith('/') || loginRequired
20
+ const fullPath = path.join(__dirname, '..', DEV_STATIC, folder, serveApp? '/index.html': ctx.path)
21
if(ctx.method === 'OPTIONS') {
22
ctx.status = NO_CONTENT
23
ctx.set({ Allow: 'OPTIONS, GET' })
@@ -24,7 +25,7 @@ function serveStatic(uri: string): Koa.Middleware {
25
}
26
if (ctx.method !== 'GET')
27
return ctx.status = METHOD_NOT_ALLOWED
27
- if (!isDir)
28
+ if (!serveApp)
29
return serveFile(fullPath, 'auto', getModifier(ctx.path, uri))(ctx, next)
30
// we don't cache the index as it's small and may prevent plugins change to apply
31
ctx.body = await treatIndex(ctx, String(await fs.readFile(fullPath)), uri)
server/src/vfs.ts
+1
-1
@@ -138,7 +138,7 @@ export async function nodeIsDirectory(node: VfsNode) {
138
139
export function hasPermission(node: VfsNode, perm: keyof VfsPerm, ctx: Koa.Context): boolean {
140
return matchWho(node[perm] ?? defaultPerms[perm], ctx)
141
- && (perm !== 'can_see' || hasPermission(node, 'can_read', ctx)) // if you can't read, then you can't see
141
+ && (perm !== 'can_see' || hasPermission(node, 'can_read', ctx)) // for can_see you must also can_read
142
}
143
144
export async function* walkNode(parent:VfsNode, ctx: Koa.Context, depth:number=0, prefixPath:string=''): AsyncIterableIterator<VfsNode> {