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> {