Enhance file browser API and dialog components

This commit is contained in:
arabcoders 2025-09-01 19:35:25 +03:00
parent 2695bb40fc
commit 28e3062bef
4 changed files with 250 additions and 65 deletions

View file

@ -135,7 +135,24 @@ async def file_browser(request: Request, config: Config, encoder: Encoder) -> Re
req_path: str = request.match_info.get("path")
req_path: str = "/" if not req_path else unquote_plus(req_path)
test: Path = Path(config.download_path).joinpath(req_path)
# Normalize requested path to always be inside download root.
raw_req: str = (req_path or "").strip()
root_dir: Path = Path(config.download_path).resolve()
if raw_req in ("", "/"):
test: Path = root_dir
rel_for_listing = "/"
else:
# Strip leading slash so joinpath doesn't ignore the base path.
test = root_dir.joinpath(raw_req.lstrip("/")).resolve(strict=False)
rel_for_listing = raw_req.lstrip("/")
try:
test.relative_to(root_dir)
except Exception:
return web.json_response(
data={"error": f"path '{req_path}' does not exist."}, status=web.HTTPNotFound.status_code
)
if not test.exists():
return web.json_response(
data={"error": f"path '{req_path}' does not exist."}, status=web.HTTPNotFound.status_code
@ -149,8 +166,8 @@ async def file_browser(request: Request, config: Config, encoder: Encoder) -> Re
try:
return web.json_response(
data={
"path": req_path,
"contents": get_files(base_path=Path(config.download_path), dir=req_path),
"path": rel_for_listing,
"contents": get_files(base_path=root_dir, dir=rel_for_listing),
},
status=web.HTTPOk.status_code,
dumps=encoder.encode,
@ -235,13 +252,14 @@ async def path_actions(request: Request, config: Config) -> Response:
rel: str = str(op_path).strip("/")
except Exception:
rel = str(op_path)
entry: dict = {
"path": rel if rel else "/",
"status": ok,
"error": error,
}
if not rel or rel == ".":
rel = "/"
entry: dict = {"path": rel, "status": ok, "error": error}
if action:
entry["action"] = action
if extra:
norm_extra: dict = {}
for k, v in extra.items():
@ -253,6 +271,7 @@ async def path_actions(request: Request, config: Config) -> Response:
else:
norm_extra[k] = v
entry.update(norm_extra)
operations_status.append(entry)
# perform each action
@ -289,7 +308,7 @@ async def path_actions(request: Request, config: Config) -> Response:
continue
except Exception as e:
LOG.exception(e)
record(req_path, ok=False, error=str(e), action=action)
record(req_path, ok=False, error=str(e), action=action, extra={"item": params})
continue
if "directory" != action:
@ -301,6 +320,38 @@ async def path_actions(request: Request, config: Config) -> Response:
record(path, ok=False, error="Path outside download root.", action=action)
continue
if "directory" == action:
new_dir = params.get("new_dir").lstrip("/").strip()
if not new_dir:
record(path, ok=False, error="New directory name is required.", action=action)
continue
new_path = path.joinpath(*new_dir.split("/"))
try:
new_path = new_path.resolve(strict=False)
except Exception:
record(path, ok=False, error="Invalid directory path.", action=action, extra={"new_dir": new_dir})
continue
root_real = Path(config.download_path).resolve()
if not new_path.is_relative_to(root_real):
record(path, ok=False, error="Destination outside root.", action=action, extra={"new_dir": new_path})
continue
if new_path.exists():
dst = new_path.relative_to(config.download_path)
record(path, ok=False, error="Directory already exists.", action=action, extra={"new_dir": dst})
continue
try:
new_path.mkdir(parents=True, exist_ok=True)
record(path, ok=True, action=action, extra={"new_dir": new_path.relative_to(config.download_path)})
LOG.info(f"Created directory '{new_path.relative_to(config.download_path)}'")
except OSError as e:
LOG.exception(e)
record(path, ok=False, error=str(e), action=action, extra={"item": params})
continue
if "rename" == action:
new_name: str = params.get("new_name")
if not new_name:
@ -315,16 +366,16 @@ async def path_actions(request: Request, config: Config) -> Response:
continue
try:
path.rename(new_path)
renamed = path.rename(new_path)
LOG.info(
f"Renamed '{path.relative_to(config.download_path)}' to '{new_path.relative_to(config.download_path)}'"
f"Renamed '{path.relative_to(config.download_path)}' to '{renamed.relative_to(config.download_path)}'"
)
except OSError as e:
LOG.exception(e)
record(path, ok=False, error=str(e), action=action, extra={"new_path": new_path})
record(path, ok=False, error=str(e), action=action, extra={"item": params})
continue
else:
record(path, ok=True, action=action, extra={"new_path": new_path})
record(path, ok=True, action=action, extra={"new_path": renamed})
if "delete" == action:
try:
@ -340,7 +391,7 @@ async def path_actions(request: Request, config: Config) -> Response:
LOG.info(f"Deleted '{path.relative_to(config.download_path)}'")
except OSError as e:
LOG.exception(e)
record(path, ok=False, error=str(e), action=action)
record(path, ok=False, error=str(e), action=action, extra={"item": params})
continue
else:
record(path, ok=True, action=action, extra={"deleted": True})
@ -348,46 +399,54 @@ async def path_actions(request: Request, config: Config) -> Response:
if "move" == action:
new_path = params.get("new_path")
if not new_path:
record(path, ok=False, error="New path is required for move action.", action=action)
record(path, ok=False, error="New path is required for move.", action=action, extra={"item": params})
continue
new_path = Path(config.download_path).joinpath(unquote_plus(new_path))
if not new_path.exists() or not new_path.is_dir():
record(path, ok=False, error="Destination path is invalid.", action=action, extra={"move_to": new_path})
continue
raw_new: str = unquote_plus(str(new_path)).strip()
target_dir = (
Path(config.download_path)
if not raw_new or raw_new in ("/", ".")
else Path(config.download_path).joinpath(raw_new.lstrip("/"))
)
try:
dest = new_path.joinpath(path.name)
path.rename(dest)
except OSError as e:
LOG.exception(e)
record(path, ok=False, error=str(e), action=action, extra={"moved_to": dest})
continue
else:
record(path, ok=True, action=action, extra={"moved_to": dest})
if "directory" == action:
new_dir = params.get("new_dir").lstrip("/").strip()
if not new_dir:
record(path, ok=False, error="New directory name is required for directory action.", action=action)
continue
new_path = path.joinpath(*new_dir.split("/"))
if new_path.exists():
target_dir = target_dir.resolve()
except Exception:
record(
new_path, ok=False, error="Directory already exists.", action=action, extra={"created": new_path}
path, ok=False, error="Destination path is invalid.", action=action, extra={"new_path": target_dir}
)
continue
root_real = Path(config.download_path).resolve()
if not target_dir.exists() or not target_dir.is_dir():
record(
path, ok=False, error="Destination path is invalid.", action=action, extra={"new_path": target_dir}
)
continue
if not target_dir.is_relative_to(root_real):
record(
path,
ok=False,
error="Destination outside download root.",
action=action,
extra={"new_path": target_dir},
)
continue
if path.parent == target_dir:
record(path, ok=False, error="Source and destination are the same.", action=action)
continue
try:
new_path.mkdir(parents=True, exist_ok=True)
LOG.info(f"Created directory '{new_path.relative_to(config.download_path)}'")
dest = target_dir.joinpath(path.name)
path.rename(dest)
except OSError as e:
LOG.exception(e)
record(new_path, ok=False, error=str(e), action=action, extra={"created": new_path})
record(path, ok=False, error=str(e), action=action, extra={"item": params})
continue
else:
record(new_path, ok=True, action=action, extra={"created": new_path})
record(path, ok=True, action=action, extra={"new_path": dest})
return web.json_response(data=operations_status, status=web.HTTPOk.status_code)

View file

@ -56,6 +56,8 @@
</span>
</p>
</div>
<div v-else-if="'confirm' === state.current?.type && (state.current?.opts as ConfirmOptions)?.rawHTML"
class="content" v-html="(state.current?.opts as ConfirmOptions)?.rawHTML" />
</section>
<footer class="modal-card-foot p-4 is-justify-content-flex-end">
@ -71,9 +73,8 @@
<template v-else-if="state.current?.type === 'confirm' || state.current?.type === 'prompt'">
<div class="field is-grouped">
<div class="control">
<button class="button" @click="onEnter"
:class="state.current?.opts.confirmColor ?? 'is-primary'"
:disabled="localInput === (state.current?.opts as PromptOptions)?.initial">
<button class="button" @click="onEnter" :class="state.current?.opts.confirmColor ?? 'is-primary'"
:disabled="localInput === (state.current?.opts as PromptOptions)?.initial">
<span class="icon-text">
<span class="icon"><i class="fas fa-check" /></span>
<span>{{ (state.current?.opts as any)?.confirmText ?? 'OK' }}</span>
@ -99,7 +100,7 @@
<script setup lang="ts">
import { ref, watch, nextTick, computed } from 'vue'
import { useDialog, type PromptOptions } from '~/composables/useDialog'
import { useDialog, type ConfirmOptions, type PromptOptions } from '~/composables/useDialog'
const { state, confirm, cancel } = useDialog()

View file

@ -46,6 +46,10 @@ export type ConfirmOptions = BaseOptions & {
* Text for the confirm button
*/
cancelText?: string
/**
* Raw HTML content to include in the dialog message.
*/
rawHTML?: string
}
export type AlertOptions = BaseOptions & {}

View file

@ -27,7 +27,7 @@
</div>
<div class="control">
<button class="button is-danger is-light" @click="toggleFilter" v-tooltip.bottom="'Filter content'">
<button class="button is-danger is-light" @click="toggleFilter">
<span class="icon"><i class="fas fa-filter" /></span>
<span v-if="!isMobile">Filter</span>
</button>
@ -35,8 +35,7 @@
<p class="control">
<button class="button is-info is-light" @click="createDirectory(path)"
:class="{ 'is-loading': isLoading }" :disabled="!socket.isConnected || isLoading"
v-tooltip.bottom="'Create new directory'" v-if="config.app.browser_control_enabled">
v-if="config.app.browser_control_enabled">
<span class="icon"><i class="fas fa-folder-plus" /></span>
<span v-if="!isMobile">New Folder</span>
</button>
@ -56,21 +55,22 @@
</div>
</div>
<div class="columns is-multiline"
v-if="items && items.length > 0 && selectedElms.length > 0 && config.app.browser_control_enabled">
<div class="columns is-multiline" v-if="config.app.browser_control_enabled">
<div class="column is-half-mobile">
<button type="button" class="button is-fullwidth is-danger" @click="confirm_delete">
<button type="button" class="button is-fullwidth is-danger" @click="deleteSelected"
:disabled="selectedElms.length < 1 || isLoading || items.length < 1">
<span class="icon-text is-block">
<span class="icon"><i class="fa-solid fa-trash-can" /></span>
<span>Delete selected</span>
<span>Delete {{ selectedElms.length > 0 ? selectedElms.length : '' }} items</span>
</span>
</button>
</div>
<div class="column is-half-mobile">
<button type="button" class="button is-fullwidth is-link">
<button type="button" class="button is-fullwidth is-link" @click="moveSelected"
:disabled="selectedElms.length < 1 || isLoading || items.length < 1">
<span class="icon-text is-block">
<span class="icon"><i class="fa-solid fa-arrows-alt" /></span>
<span>Move selected</span>
<span>Move {{ selectedElms.length > 0 ? selectedElms.length : '' }} items</span>
</span>
</button>
</div>
@ -417,6 +417,10 @@ const reloadContent = async (dir: string = '/', fromMounted: boolean = false): P
}
items.value = []
search.value = ''
selectedElms.value = []
show_filter.value = false
masterSelectAll.value = false
const data = await response.json() as FileBrowserResponse
@ -554,8 +558,15 @@ const createDirectory = async (dir: string): Promise<void> => {
return
}
const newDir = prompt('Enter new directory name:', '')
if (!newDir) {
const { status, value: newDir } = await dialog.promptDialog({
title: 'Create New Directory',
message: `Enter name for new directory in '${dir || '/'}':`,
initial: '',
confirmText: 'Create',
cancelText: 'Cancel',
})
if (true !== status || !newDir) {
return
}
@ -564,7 +575,7 @@ const createDirectory = async (dir: string): Promise<void> => {
return
}
await actionRequest({ path: dir } as FileItem, 'directory', { new_dir: new_dir }, (item, action, data) => {
await actionRequest({ path: dir || '/' } as FileItem, 'directory', { new_dir: new_dir }, (item, action, data) => {
reloadContent(path.value, true)
toast.success(`Successfully created '${new_dir}'.`)
})
@ -626,7 +637,7 @@ const handleAction = async (action: string, item: FileItem): Promise<void> => {
const { status, value: newPath } = await dialog.promptDialog({
title: 'Move Item',
message: `Enter new path for '${item.name}':`,
initial: item.path.replace(/[^/]+$/, ''),
initial: item.path.replace(/[^/]+$/, '') || '/',
confirmText: 'Move',
cancelText: 'Cancel',
})
@ -635,7 +646,7 @@ const handleAction = async (action: string, item: FileItem): Promise<void> => {
return
}
let new_path = sTrim(newPath, '/')
let new_path = sTrim(newPath, '/') || '/'
if (!new_path || new_path === item.path) {
return
}
@ -702,25 +713,135 @@ const actionRequest = async (
}
}
const confirm_delete = () => {
const massAction = async (items: Array<{ path: string, action: string }>, cb: (item: any) => void): Promise<any> => {
if (!config.app.browser_control_enabled) {
return
}
if (!items || items.length < 1) {
return
}
try {
const response = await request(`/api/file/actions`, {
method: 'POST',
body: JSON.stringify(items),
})
if (!response.ok) {
const error = await response.json()
toast.error(`Failed to perform action: ${error.error || 'Unknown error'}`)
return
}
const json = await response.json() as Array<{ path: string, status: boolean, error?: string }>
json.forEach(i => {
if (true !== i.status) {
toast.error(`Failed to perform action on '${i.path}': ${i.error || 'Unknown error'}`)
return
}
if (cb && typeof cb === 'function') {
cb(i)
}
});
return response
} catch (error: any) {
console.error(error)
toast.error(`Failed to perform action: ${error.message}`)
}
}
const deleteSelected = async () => {
if (selectedElms.value.length < 1) {
toast.error('No items selected.')
return
}
dialog_confirm.value.visible = true
dialog_confirm.value.title = 'Delete Confirmation'
dialog_confirm.value.html_message = `Delete the following items?<ul>` + selectedElms.value.map(p => {
const rawHTML = `<ul>` + selectedElms.value.map(p => {
const item = items.value.find(i => i.path === p)
if (!item) {
return ''
}
const color = item.type === 'dir' ? 'has-text-primary is-bold' : ''
const color = 'dir' === item.type ? 'has-text-danger is-bold' : ''
return `<li><span class="icon"><i class="fa-solid ${setIcon(item)}"></i></span> <span class="${color}">${item.name}</span></li>`
}).join('') + `</ul>`
// dialog_confirm.value.confirm = async () => await deleteSelected()
const { status } = await dialog.confirmDialog({
title: 'Delete Confirmation',
message: `Delete the following items?`,
rawHTML: rawHTML,
confirmText: 'Delete',
cancelText: 'Cancel',
confirmColor: 'is-danger',
})
if (true !== status) {
selectedElms.value = []
return
}
const actions = [] as Array<{ action: string, path: string }>
selectedElms.value.forEach(p => {
const item = items.value.find(i => i.path === p)
if (!item) {
return;
}
actions.push({ path: item.path, action: 'delete' })
})
await massAction(actions, i => {
const item = items.value.find(it => it.path === i.path)
if (!item) {
return
}
items.value = items.value.filter(it => it.path !== i.path)
toast.warning(`Deleted '${item.name}'.`)
})
}
const moveSelected = async () => {
if (selectedElms.value.length < 1) {
toast.error('No items selected.')
return
}
const { status, value: newPath } = await dialog.promptDialog({
title: 'Move Items',
message: `Enter new path for selected items:`,
initial: path.value || '/',
confirmText: 'Move',
confirmColor: 'is-danger',
cancelText: 'Cancel',
})
if (true !== status || !newPath || newPath === path.value) {
selectedElms.value = []
return
}
const actions = [] as Array<{ action: string, path: string, new_path: string }>
selectedElms.value.forEach(p => {
const item = items.value.find(i => i.path === p)
if (!item) {
return;
}
actions.push({
path: item.path,
action: 'move',
new_path: sTrim(newPath, '/') || '/',
})
})
await massAction(actions, i => {
items.value = items.value.filter(it => it.path !== i.path)
toast.success(`Moved '${i.path}' to '${sTrim(newPath, '/')}'.`)
})
}
</script>