Compare commits

..
10 Commits
18 changed files with 507 additions and 102 deletions
+33
View File
@@ -38,6 +38,39 @@ pip install cista --break-system-packages
The server remembers its settings in the config folder (default `~/.local/share/cista/`), including the listen port and directory, for future runs without arguments. The server remembers its settings in the config folder (default `~/.local/share/cista/`), including the listen port and directory, for future runs without arguments.
## Authentication
Cista supports three authentication modes:
### Built-in Authentication (default)
User accounts are managed directly by Cista. Create users with the `--user` flag:
```fish
uvx cista --user admin --privileged # Create admin user
uvx cista --user guest # Create regular user
```
Privileged users can manage other users and change settings via the Admin Settings menu.
### Public Mode
In public mode, anyone can read, send and even delete files without without logging in. Privileged users can still log in via the menu to access admin settings, from where the public mode can be toggled on or off.
### Paskia SSO Authentication
For centralized authentication, Cista can integrate with [Paskia](https://git.zi.fi/LeoVasanko/paskia) SSO server. Set the `PASKIA_BACKEND_URL` environment variable:
```fish
PASKIA_BACKEND_URL=http://localhost:4401 uvx cista
```
In Paskia mode:
- All `/auth/*` requests are proxied to the Paskia backend
- Users with `cista:login` permission can access files
- Users with `cista:admin` permission get privileged access (Admin Settings)
- Public mode works with Paskia: unauthenticated users can browse, while the menu has option to login
### Internet Access ### Internet Access
Most admins find the [Caddy](https://caddyserver.com/) web server convenient for its auto TLS certificates and all. A proxy also allows running multiple web services or Cista instances on the same IP address but different (sub)domains. Most admins find the [Caddy](https://caddyserver.com/) web server convenient for its auto TLS certificates and all. A proxy also allows running multiple web services or Cista instances on the same IP address but different (sub)domains.
+36 -9
View File
@@ -25,6 +25,28 @@ def create_banner():
""" """
def create_startup_box(*, folder, url, unix=None, dev=False, paskia_url=None):
"""Create a framed startup box with server information."""
title = f"Cista {cista.__version__}"
listen = unix if unix else url
location = f"{folder} @ {listen}"
lines = [title, location]
if paskia_url:
lines.append(f"Paskia: {paskia_url}")
if dev:
lines.append("dev mode")
# Calculate width based on content
inner_width = max(len(line) for line in lines) + 2
# Build the box
box = [f"{'' * inner_width}"]
for line in lines:
box.append(f"{line:<{inner_width - 1}}")
box.append(f"{'' * inner_width}")
return "\n".join(box) + "\n"
banner = create_banner() banner = create_banner()
doc = """\ doc = """\
@@ -83,8 +105,7 @@ def _main():
elif "--version" in sys.argv: elif "--version" in sys.argv:
sys.stdout.write(f"cista {cista.__version__}\n") sys.stdout.write(f"cista {cista.__version__}\n")
return 0 return 0
else: # Don't print banner yet for normal startup - we'll print the startup box later
sys.stderr.write(banner)
args = docopt(doc) args = docopt(doc)
if args["--user"]: if args["--user"]:
return _user(args) return _user(args)
@@ -121,17 +142,23 @@ def _main():
elif not exists: elif not exists:
settings["listen"] = ":8000" settings["listen"] = ":8000"
operation = config.update_config(settings) operation = config.update_config(settings)
sys.stderr.write(f"Config {operation}: {config.conffile}\n")
# Prepare to serve # Prepare to serve
unix = None url, opts = serve.parse_listen(config.config.listen)
url, _ = serve.parse_listen(config.config.listen)
if not config.config.path.is_dir(): if not config.config.path.is_dir():
raise ValueError(f"No such directory: {config.config.path}") raise ValueError(f"No such directory: {config.config.path}")
extra = f" ({unix})" if unix else ""
dev = args["--dev"] dev = args["--dev"]
if dev: # Check for Paskia SSO
extra += " (dev mode)" from cista.sso import PASKIA_BACKEND_URL
sys.stderr.write(f"Serving {config.config.path} at {url}{extra}\n")
# Print startup box
startup_box = create_startup_box(
folder=config.config.path,
url=url,
unix=opts.get("unix"),
dev=dev,
paskia_url=PASKIA_BACKEND_URL or None,
)
sys.stderr.write(startup_box)
# Run the server # Run the server
serve.run(dev=dev) serve.run(dev=dev)
return 0 return 0
+13 -8
View File
@@ -95,14 +95,19 @@ async def control(req, ws):
async def watch(req, ws): async def watch(req, ws):
# Build user info from either built-in auth or SSO # Build user info from either built-in auth or SSO
user_info = None user_info = None
if sso_user := getattr(req.ctx, "sso_user", None): if sso.paskia_enabled():
# SSO auth (paskia mode): extract from validation response # SSO auth: call validation to get user info (don't enforce auth in public mode)
ctx = sso_user.get("ctx", {}) try:
perms = ctx.get("permissions", []) await sso.validate_sso_request(req)
user_info = { except Exception:
"username": ctx.get("user", {}).get("display_name", ""), pass # Ignore auth errors, user_info stays None
"privileged": "cista:admin" in perms, if sso_user := getattr(req.ctx, "sso_user", None):
} ctx = sso_user.get("ctx", {})
perms = ctx.get("permissions", [])
user_info = {
"username": ctx.get("user", {}).get("display_name", ""),
"privileged": "cista:admin" in perms,
}
elif req.ctx.user: elif req.ctx.user:
# Built-in auth: use local user database # Built-in auth: use local user database
user_info = { user_info = {
+25 -11
View File
@@ -43,10 +43,13 @@ setproctitle("cista-main")
async def main_start(app): async def main_start(app):
config.load_config() config.load_config()
setproctitle(f"cista {config.config.path.name}") setproctitle(f"cista {config.config.path.name}")
workers = max(2, min(8, cpu_count())) # Small pool for memory-intensive preview generation
preview_workers = max(2, min(8, cpu_count()))
app.ctx.threadexec = ThreadPoolExecutor( app.ctx.threadexec = ThreadPoolExecutor(
max_workers=workers, thread_name_prefix="cista-ioworker" max_workers=preview_workers, thread_name_prefix="cista-preview"
) )
# Larger pool for long-running but low-memory zip operations
app.ctx.zipexec = ThreadPoolExecutor(max_workers=32, thread_name_prefix="cista-zip")
watching.start(app) watching.start(app)
@@ -56,6 +59,7 @@ async def main_stop(app):
quit.set() quit.set()
watching.stop(app) watching.stop(app)
app.ctx.threadexec.shutdown() app.ctx.threadexec.shutdown()
app.ctx.zipexec.shutdown(cancel_futures=True)
await sso.close_client() await sso.close_client()
logger.debug("Cista worker threads all finished") logger.debug("Cista worker threads all finished")
@@ -257,10 +261,7 @@ def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
@app.get("/zip/<keys>/<zipfile:ext=zip>") @app.get("/zip/<keys>/<zipfile:ext=zip>")
async def zip_download(req, keys, zipfile, ext): async def zip_download(req, keys, zipfile, ext):
"""Download a zip archive of the given keys""" """Download a zip archive of the given keys"""
if config.config.authentication == "paskia": await auth.verify(req)
await auth.verify_sso(req)
else:
auth.verify(req)
wanted = set(keys.split("+")) wanted = set(keys.split("+"))
files = get_files(wanted) files = get_files(wanted)
@@ -291,27 +292,40 @@ async def zip_download(req, keys, zipfile, ext):
yield chunk yield chunk
assert size == 0 assert size == 0
pending_put = None # Current queue.put future, can be cancelled
def worker(): def worker():
nonlocal pending_put
try: try:
for chunk in stream_zip(local_files(files)): for chunk in stream_zip(local_files(files)):
asyncio.run_coroutine_threadsafe(queue.put(chunk), loop).result() future = asyncio.run_coroutine_threadsafe(queue.put(chunk), loop)
pending_put = future
future.result() # Blocks until queue has space
except asyncio.CancelledError:
logger.info("ZIP download cancelled by client disconnect")
except Exception: except Exception:
logger.exception("Error streaming ZIP") logger.exception("Error streaming ZIP")
raise raise
finally: finally:
pending_put = None
asyncio.run_coroutine_threadsafe(queue.put(None), loop) asyncio.run_coroutine_threadsafe(queue.put(None), loop)
# Don't block the event loop: run in a thread # Don't block the event loop: run in a thread (use larger zip pool)
queue = asyncio.Queue(maxsize=1) queue = asyncio.Queue(maxsize=1)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
thread = loop.run_in_executor(app.ctx.threadexec, worker) thread = loop.run_in_executor(app.ctx.zipexec, worker)
# Stream the response # Stream the response
res = await req.respond( res = await req.respond(
content_type="application/zip", content_type="application/zip",
headers={"cache-control": "no-store"}, headers={"cache-control": "no-store"},
) )
while chunk := await queue.get(): try:
await res.send(chunk) while chunk := await queue.get():
await res.send(chunk)
finally:
# Cancel any pending put to unblock and stop the worker
if pending_put:
pending_put.cancel()
await thread # If it raises, the response will fail download await thread # If it raises, the response will fail download
+13 -16
View File
@@ -236,39 +236,36 @@ async def verify(request, *, privileged=False):
For paskia mode (PASKIA_BACKEND_URL set), validates against the SSO backend. For paskia mode (PASKIA_BACKEND_URL set), validates against the SSO backend.
For built-in mode, checks session-based authentication. For built-in mode, checks session-based authentication.
For public mode (config.public=True), allows all requests. For public mode (config.public=True), skips auth unless privileged is required.
All 401/403 responses include auth.iframe URL for consistent frontend handling
via the paskia library's showAuthIframe().
Args: Args:
request: The Sanic request object request: The Sanic request object
privileged: If True, requires admin privileges privileged: If True, requires admin privileges (always enforced even in public mode)
Raises: Raises:
Unauthorized: If authentication is required Unauthorized: If authentication is required
Forbidden: If access is denied Forbidden: If access is denied
""" """
# Public mode: skip auth unless privileged access is required
if config.config.public and not privileged:
return
sso = _get_sso() sso = _get_sso()
if sso.paskia_enabled(): if sso.paskia_enabled():
# SSO validation against auth backend
# Always check cista:login; privileged flag comes from response perm list
perm = "cista:admin" if privileged else "cista:login" perm = "cista:admin" if privileged else "cista:login"
await sso.validate_sso_request(request, perm=perm) await sso.validate_sso_request(request, perm=perm)
return return
user = getattr(request.ctx, "user", None) user = getattr(request.ctx, "user", None)
if privileged: if privileged:
if user: if user and user.privileged:
if user.privileged: return
return raise Forbidden(
raise Forbidden( "Access Forbidden: Only for privileged users",
"Access Forbidden: Only for privileged users", quiet=True,
quiet=True, )
) if user:
elif config.config.public or user:
return return
# Return iframe URL for paskia library to show login dialog
raise Unauthorized( raise Unauthorized(
f"Login required for {request.path}", f"Login required for {request.path}",
"cookie", "cookie",
+2 -2
View File
@@ -89,9 +89,9 @@ def dispatch(path, quality, maxsize, maxzoom):
return process_video(path, quality=quality, maxsize=maxsize) return process_video(path, quality=quality, maxsize=maxsize)
return process_image(path, quality=quality, maxsize=maxsize) return process_image(path, quality=quality, maxsize=maxsize)
except ValueError as e: except ValueError as e:
logger.warning(f"Cannot generate preview for {path.name}: {e}") logger.warning(f"Cannot generate preview for {path}: {e}")
except Exception as e: except Exception as e:
logger.exception(f"Error generating preview for {path.name}: {e}") logger.exception(f"Error generating preview for {path}: {e}")
def process_image(path, *, maxsize, quality): def process_image(path, *, maxsize, quality):
+9 -1
View File
@@ -1,5 +1,8 @@
<template> <template>
<div v-if="store.error && !store.authInProgress" class="toast-message" @click="store.error = ''"> <div v-if="store.toast" class="toast-message" @click="store.clearToast()">
{{ store.toast }}
</div>
<div v-else-if="store.error && !store.authInProgress" class="toast-message status" @click="store.error = ''">
{{ store.error }} {{ store.error }}
</div> </div>
<SettingsModal /> <SettingsModal />
@@ -100,6 +103,7 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
// Globally close search, clear errors on Escape // Globally close search, clear errors on Escape
else if (keyup && event.key === 'Escape') { else if (keyup && event.key === 'Escape') {
store.error = '' store.error = ''
store.clearToast()
headerMain.value!.closeSearch(event) headerMain.value!.closeSearch(event)
store.focusBreadcrumb() store.focusBreadcrumb()
} }
@@ -181,4 +185,8 @@ export type { Path }
max-width: 90vw; max-width: 90vw;
text-align: center; text-align: center;
} }
.toast-message.status {
background: #555;
color: #fff;
}
</style> </style>
+6 -5
View File
@@ -6,6 +6,7 @@
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { apiFetch } from '@/repositories/Client' import { apiFetch } from '@/repositories/Client'
import type { SelectedItems } from '@/repositories/Document' import type { SelectedItems } from '@/repositories/Document'
import { zipName } from '@/utils/fileutil'
import { reactive } from 'vue'; import { reactive } from 'vue';
const store = useMainStore() const store = useMainStore()
@@ -106,7 +107,6 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
++store.dprogress.fileidx ++store.dprogress.fileidx
const reader = res.body.getReader() const reader = res.body.getReader()
await writable.truncate(0) await writable.truncate(0)
store.error = "Direct download."
store.dprogress.tlast = Date.now() store.dprogress.tlast = Date.now()
while (true) { while (true) {
const { value, done } = await reader.read() const { value, done } = await reader.read()
@@ -136,7 +136,7 @@ const download = async () => {
console.log('Download', sel) console.log('Download', sel)
if (sel.keys.length === 0) { if (sel.keys.length === 0) {
console.warn('Attempted download but no files found. Missing selected keys:', sel.missing) console.warn('Attempted download but no files found. Missing selected keys:', sel.missing)
store.error = 'No existing files selected' store.showToast('No existing files selected')
store.selected.clear() store.selected.clear()
return return
} }
@@ -144,7 +144,7 @@ const download = async () => {
const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir) const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
if (files.length === 1) { if (files.length === 1) {
store.selected.clear() store.selected.clear()
store.error = "Single file via browser downloads" store.showToast(`Downloading ${files[0]![0].split('/').pop()}`)
return linkdl(`/files/${files[0]![1]}`) return linkdl(`/files/${files[0]![1]}`)
} }
// Use FileSystem API if multiple files and the browser supports it // Use FileSystem API if multiple files and the browser supports it
@@ -164,9 +164,10 @@ const download = async () => {
} }
// Otherwise, zip and download // Otherwise, zip and download
console.log("Falling back to zip download") console.log("Falling back to zip download")
const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]!]!.name : 'download' const items = sel.keys.map(k => sel.docs[k]!)
const name = zipName(items)
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`) linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
store.error = "Downloading as ZIP via browser downloads" store.showToast(`Downloading ${name}.zip`)
store.selected.clear() store.selected.clear()
} }
+13 -5
View File
@@ -51,14 +51,22 @@ const closeSearch = (ev: Event) => {
breadcrumb.focus() breadcrumb.focus()
updateSearch(ev) updateSearch(ev)
} }
const updateSearch = (ev: Event) => { const updateSearch = (ev: Event) => {
const q = (ev.target as HTMLInputElement).value const q = (ev.target as HTMLInputElement).value
let p = props.path.join('/') let p = props.path.join('/')
p = p ? `/${p}` : '' p = p ? `/${p}` : ''
const url = q ? `${p}//${q}` : (p || '/') const url = q ? `${p}//${q}` : (p || '/')
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23') const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
if (!props.query && q) router.push(u)
else router.replace(u) // Start search immediately via store (worker handles it async)
store.search(q, props.path.join('/'))
// Update route in next frame to keep typing responsive
requestAnimationFrame(() => {
if (!props.query && q) router.push(u)
else router.replace(u)
})
} }
const toggleSearchInput = (ev: Event) => { const toggleSearchInput = (ev: Event) => {
showSearchInput.value = !showSearchInput.value showSearchInput.value = !showSearchInput.value
@@ -94,11 +102,11 @@ const settingsMenu = (e: Event) => {
if (store.user.isLoggedIn) { if (store.user.isLoggedIn) {
items.push({ label: '🚪 Logout', onClick: () => store.logout() }) items.push({ label: '🚪 Logout', onClick: () => store.logout() })
} else if (!ssoStore.isExternalAuth) { } else if (store.server.public) {
// Show login in paskia iframe overlay // Show login option only in public mode (non-public modes trigger auth automatically)
items.push({ label: '🔐 Login', onClick: async () => { items.push({ label: '🔐 Login', onClick: async () => {
try { try {
await showAuthIframe('/auth/restricted') await showAuthIframe('/auth/restricted#theme=light')
resumeWatching() resumeWatching()
} catch (e) { } catch (e) {
console.log('Login cancelled') console.log('Login cancelled')
+2 -2
View File
@@ -70,7 +70,7 @@ const submit = async (ev: Event) => {
try { try {
if (form.passwordChange) { if (form.passwordChange) {
if (!form.password) { if (!form.password) {
store.error = '⚠️ Current password is required' store.showToast('⚠️ Current password is required')
password.value!.focus() password.value!.focus()
return return
} }
@@ -79,7 +79,7 @@ const submit = async (ev: Event) => {
close() close()
} catch (error) { } catch (error) {
const httpError = error as ISimpleError const httpError = error as ISimpleError
store.error = httpError.message || '🛑 Unknown error' store.showToast(httpError.message || '🛑 Unknown error')
} finally { } finally {
confirmLoading.value = false confirmLoading.value = false
} }
+1 -1
View File
@@ -84,7 +84,7 @@ const uploadFiles = (infiles: File[]) => {
const uploadCloudFiles = (files: CloudFile[]) => { const uploadCloudFiles = (files: CloudFile[]) => {
const dotfiles = files.filter(f => f.cloudName.includes('/.')) const dotfiles = files.filter(f => f.cloudName.includes('/.'))
if (dotfiles.length) { if (dotfiles.length) {
store.error = "Won't upload dotfiles" store.showToast("Won't upload dotfiles")
console.log("Dotfiles omitted", dotfiles) console.log("Dotfiles omitted", dotfiles)
files = files.filter(f => !f.cloudName.includes('/.')) files = files.filter(f => !f.cloudName.includes('/.'))
} }
@@ -93,7 +93,7 @@ const loadUsers = async () => {
users.value = data.users users.value = data.users
} catch (e) { } catch (e) {
const httpError = e as ISimpleError const httpError = e as ISimpleError
store.error = httpError.message || 'Failed to load users' store.showToast(httpError.message || 'Failed to load users')
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -111,7 +111,7 @@ const addUser = async () => {
} }
} catch (e) { } catch (e) {
const httpError = e as ISimpleError const httpError = e as ISimpleError
store.error = httpError.message || 'Failed to add user' store.showToast(httpError.message || 'Failed to add user')
} }
} }
@@ -122,7 +122,7 @@ const toggleAdmin = async (user: User, event: Event) => {
user.privileged = target.checked user.privileged = target.checked
} catch (e) { } catch (e) {
const httpError = e as ISimpleError const httpError = e as ISimpleError
store.error = httpError.message || 'Failed to update user' store.showToast(httpError.message || 'Failed to update user')
target.checked = user.privileged // revert target.checked = user.privileged // revert
} }
} }
@@ -142,7 +142,7 @@ const renameUser = async (user: User) => {
} }
} catch (e) { } catch (e) {
const httpError = e as ISimpleError const httpError = e as ISimpleError
store.error = httpError.message || 'Failed to rename user' store.showToast(httpError.message || 'Failed to rename user')
} }
} }
@@ -156,7 +156,7 @@ const resetPassword = async (user: User) => {
} }
} catch (e) { } catch (e) {
const httpError = e as ISimpleError const httpError = e as ISimpleError
store.error = httpError.message || 'Failed to reset password' store.showToast(httpError.message || 'Failed to reset password')
} }
} }
@@ -167,7 +167,7 @@ const deleteUserAction = async (username: string) => {
await loadUsers() await loadUsers()
} catch (e) { } catch (e) {
const httpError = e as ISimpleError const httpError = e as ISimpleError
store.error = httpError.message || 'Failed to delete user' store.showToast(httpError.message || 'Failed to delete user')
} }
} }
@@ -202,7 +202,7 @@ const updateServerSettings = async () => {
success.value = 'Server settings updated' success.value = 'Server settings updated'
} catch (e) { } catch (e) {
const httpError = e as ISimpleError const httpError = e as ISimpleError
store.error = httpError.message || 'Failed to update settings' store.showToast(httpError.message || 'Failed to update settings')
} }
} }
+2 -4
View File
@@ -1,4 +1,4 @@
import { formatSize, formatUnixDate, haystackFormat } from "@/utils" import { formatSize, formatUnixDate } from "@/utils"
export type FUID = string export type FUID = string
@@ -16,7 +16,6 @@ export class Doc {
public key: FUID = "" public key: FUID = ""
public size: number = 0 public size: number = 0
public mtime: number = 0 public mtime: number = 0
public haystack: string = ""
public dir: boolean = false public dir: boolean = false
/** @internal Use the name getter/setter instead */ /** @internal Use the name getter/setter instead */
public _name: string = "" public _name: string = ""
@@ -24,13 +23,12 @@ export class Doc {
constructor(props: Partial<DocProps> = {}) { constructor(props: Partial<DocProps> = {}) {
const { name, ...rest } = props const { name, ...rest } = props
Object.assign(this, rest) Object.assign(this, rest)
if (name) this.name = name // Use setter for validation if (name) this._name = name // Skip validation/haystack for bulk loading
} }
get name() { return this._name } get name() { return this._name }
set name(name: string) { set name(name: string) {
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`) if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
this._name = name this._name = name
this.haystack = haystackFormat(name)
} }
get sizedisp(): string { return formatSize(this.size) } get sizedisp(): string { return formatSize(this.size) }
get modified(): string { return formatUnixDate(this.mtime) } get modified(): string { return formatUnixDate(this.mtime) }
+83 -1
View File
@@ -4,14 +4,47 @@ import { defineStore, type StateTree } from 'pinia'
import { collator } from '@/utils' import { collator } from '@/utils'
import { watchConnect, resumeWatching } from '@/repositories/WS' import { watchConnect, resumeWatching } from '@/repositories/WS'
import { sorted, type SortOrder } from '@/utils/docsort' import { sorted, type SortOrder } from '@/utils/docsort'
import SearchWorker from '@/workers/searchWorker?worker'
// Singleton search worker instance
let searchWorker: Worker | null = null
let searchId = 0
let searchStore: ReturnType<typeof useMainStore> | null = null
function getSearchWorker(): Worker {
if (!searchWorker) {
searchWorker = new SearchWorker()
// Set up message handler once
searchWorker.onmessage = (e) => {
if (!searchStore || e.data.id !== searchId) return // Stale result
// Convert plain data back to Doc instances (constructor is now lightweight)
const docs = []
for (const d of e.data.docs) {
docs.push(new Doc(d))
}
searchStore.searchResults = docs
if (e.data.done) {
searchStore.searchLoading = false
}
}
}
return searchWorker
}
export const useMainStore = defineStore('main', { export const useMainStore = defineStore('main', {
state: () => ({ state: () => ({
document: [] as Doc[], document: [] as Doc[],
selected: new Set<FUID>([]), selected: new Set<FUID>([]),
query: '' as string, query: '' as string,
searchResults: [] as Doc[],
searchLoading: false,
_searchRouteTimer: null as ReturnType<typeof setTimeout> | null,
fileExplorer: null as any, fileExplorer: null as any,
error: '' as string, error: '' as string, // Permanent status message (e.g., "Reconnecting...")
toast: '' as string, // Temporary toast (auto-dismisses)
toastTimeout: null as ReturnType<typeof setTimeout> | null,
connected: false, connected: false,
authInProgress: false, authInProgress: false,
cursor: '' as string, cursor: '' as string,
@@ -61,6 +94,55 @@ export const useMainStore = defineStore('main', {
loc.push(name) loc.push(name)
} }
this.document = docs this.document = docs
// Sync documents to search worker
this.syncSearchWorker()
},
/** Show a temporary toast message that auto-dismisses */
showToast(message: string, duration = 3000) {
if (this.toastTimeout) {
clearTimeout(this.toastTimeout)
this.toastTimeout = null
}
this.toast = message
this.toastTimeout = setTimeout(() => {
this.toast = ''
this.toastTimeout = null
}, duration)
},
/** Clear the current toast immediately */
clearToast() {
if (this.toastTimeout) {
clearTimeout(this.toastTimeout)
this.toastTimeout = null
}
this.toast = ''
},
syncSearchWorker() {
const worker = getSearchWorker()
// Send plain data to worker (no class instances)
const docData = this.document.map(doc => ({
loc: doc.loc,
name: doc.name,
key: doc.key,
size: doc.size,
mtime: doc.mtime,
dir: doc.dir,
}))
worker.postMessage({ type: 'update', documents: docData })
},
search(query: string, loc: string) {
const worker = getSearchWorker()
const id = ++searchId
searchStore = this // Store reference for worker callback
if (!query) {
this.searchResults = []
this.searchLoading = false
return
}
this.searchLoading = true
worker.postMessage({ type: 'search', query, loc, id })
}, },
login(username: string, privileged: boolean) { login(username: string, privileged: boolean) {
this.user.username = username this.user.username = username
+41
View File
@@ -6,3 +6,44 @@ export const exists = (path: string[]) => {
const p = path.join('/') const p = path.join('/')
return store.document.some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p) return store.document.some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p)
} }
/** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
export const stripExt = (name: string): string => {
// Common compound extensions
const compoundExts = ['.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst']
const lower = name.toLowerCase()
for (const ext of compoundExts) {
if (lower.endsWith(ext)) return name.slice(0, -ext.length)
}
// Regular extension: only strip if the extension looks like one (2-5 chars, alphanumeric)
const lastDot = name.lastIndexOf('.')
if (lastDot > 0) {
const ext = name.slice(lastDot + 1)
if (ext.length >= 2 && ext.length <= 5 && /^[a-zA-Z0-9]+$/.test(ext)) {
return name.slice(0, lastDot)
}
}
return name
}
/** Generate a sensible zip filename for a selection of items */
export const zipName = (items: { name: string; loc: string }[]): string => {
const names = items.map(d => d.name)
if (names.length === 1) {
// Single item - use its name
return stripExt(names[0]!)
}
// Check if all items share the same direct parent folder
const locs = items.map(d => d.loc)
const sameLoc = locs.every(loc => loc === locs[0])
if (sameLoc && locs[0]) {
// All items in same folder - use folder name
return locs[0].split('/').pop()!
}
if (names.length <= 3) {
// Few items from different folders - join basenames with dot
return names.map(stripExt).join('.')
}
// Many items from different folders - first basename + indicator
return `${stripExt(names[0]!)}.etc`
}
+41 -29
View File
@@ -2,25 +2,25 @@
<Gallery <Gallery
v-if="store.prefs.gallery" v-if="store.prefs.gallery"
ref="fileExplorer" ref="fileExplorer"
:key="`gallery-${Router.currentRoute.value.path}`" :key="`gallery-${folderPath}`"
:path="props.path" :path="props.path"
:documents="documents" :documents="documents"
/> />
<FileExplorer <FileExplorer
v-else v-else
ref="fileExplorer" ref="fileExplorer"
:key="`explorer-${Router.currentRoute.value.path}`" :key="`explorer-${folderPath}`"
:path="props.path" :path="props.path"
:documents="documents" :documents="documents"
/> />
<div v-if="store.searchLoading" class="search-loading">Searching...</div>
<EmptyFolder :documents=documents :path=props.path /> <EmptyFolder :documents=documents :path=props.path />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { watchEffect, ref, computed, watch } from 'vue' import { watchEffect, ref, computed, watch } from 'vue'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import Router from '@/router/index' import { collator } from '@/utils'
import { needleFormat, localeIncludes, collator } from '@/utils'
import { sorted, sortedGrouped } from '@/utils/docsort' import { sorted, sortedGrouped } from '@/utils/docsort'
import FileExplorer from '@/components/FileExplorer.vue' import FileExplorer from '@/components/FileExplorer.vue'
@@ -30,41 +30,42 @@ const props = defineProps<{
path: Array<string> path: Array<string>
query: string query: string
}>() }>()
// Folder path for component keys - only recreate component when folder changes, not search
const folderPath = computed(() => props.path.join('/'))
// Trigger search when query changes (from route, e.g., page load or back button)
// Note: Direct typing triggers search immediately via HeaderMain, this is for route-based changes
watch(
() => [props.query, props.path.join('/')] as const,
([query, loc]) => {
// Only trigger if results don't match current query (avoid duplicate searches)
if (query && store.searchResults.length === 0) {
store.search(query, loc)
} else if (!query) {
store.search('', loc) // Clear search results
}
},
{ immediate: true }
)
const documents = computed(() => { const documents = computed(() => {
const loc = props.path.join('/') const loc = props.path.join('/')
const query = props.query const query = props.query
// List the current location
// List the current location (no search)
if (!query) return sorted( if (!query) return sorted(
store.document.filter(doc => doc.loc === loc), store.document.filter(doc => doc.loc === loc),
store.prefs.sortListing, store.prefs.sortListing,
) )
// Find up to 100 newest documents that match the search
const needle = needleFormat(query) // Search results from worker
let limit = 100 const docs = store.searchResults
let docs = []
for (const doc of store.recentDocuments) {
if (localeIncludes(doc.haystack, needle)) {
docs.push(doc)
if (--limit === 0) break
}
}
const locsub = loc + '/'
// Custom sort override in effect? Use grouped sorting to keep folders together // Custom sort override in effect? Use grouped sorting to keep folders together
const order = store.prefs.sortFiltered const order = store.prefs.sortFiltered
if (order) return sortedGrouped(docs, order) if (order) return sortedGrouped(docs, order)
// Sort by relevance - current folder, then subfolders, then others
docs.sort((a, b) => ( // Results are already sorted by relevance in the worker
// @ts-ignore
(b.loc === loc) - (a.loc === loc) ||
// @ts-ignore
(b.loc.slice(0, locsub.length) === locsub) - (a.loc.slice(0, locsub.length) === locsub) ||
collator.compare(a.loc, b.loc) ||
// @ts-ignore
(a.type === 'file') - (b.type === 'file') ||
// @ts-ignore
b.name.includes(query) - a.name.includes(query) ||
collator.compare(a.name, b.name)
))
return docs return docs
}) })
@@ -92,4 +93,15 @@ watch([() => props.path.join('/'), () => store.document.length], ([path, len], [
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008; text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
color: var(--accent-color); color: var(--accent-color);
} }
.search-loading {
position: fixed;
bottom: 1rem;
right: 1rem;
padding: 0.5rem 1rem;
background: var(--accent-color, #007bff);
color: white;
border-radius: 0.25rem;
font-size: 0.875rem;
opacity: 0.9;
}
</style> </style>
+179
View File
@@ -0,0 +1,179 @@
// Search worker - runs search in background thread
// Receives document updates and search queries, returns incremental results
interface DocData {
loc: string
name: string
key: string
size: number
mtime: number
dir: boolean
}
interface WorkerDoc extends DocData {
haystack: string
}
interface SearchMessage {
type: 'search'
query: string
loc: string
id: number
}
interface UpdateMessage {
type: 'update'
documents: DocData[]
}
type IncomingMessage = SearchMessage | UpdateMessage
interface ResultMessage {
type: 'results'
docs: DocData[]
id: number
done: boolean
}
// Worker state
let documents: WorkerDoc[] = []
let recentDocuments: WorkerDoc[] = [] // Sorted by mtime descending
let currentSearchId = 0
// Haystack formatting (same as main thread utils)
function haystackFormat(str: string): string {
const based = str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
return '^' + based + '$'
}
// Needle formatting
function needleFormat(query: string) {
const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
return { based, words: based.split(/\s+/) }
}
// Test if haystack includes needle
function localeIncludes(haystack: string, filter: { based: string; words: string[] }): boolean {
const { based, words } = filter
return haystack.includes(based) || (words && words.every(word => haystack.includes(word)))
}
// Collator for sorting
const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true, usage: 'search' })
// Sort by mtime descending
function sortByRecent(docs: WorkerDoc[]): WorkerDoc[] {
return [...docs].sort((a, b) => b.mtime - a.mtime)
}
// Yield control to check for new messages
function yieldControl(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 0))
}
// Perform search with incremental results
async function performSearch(query: string, loc: string, searchId: number) {
const needle = needleFormat(query)
const limit = 100
const batchSize = 500 // Smaller batches for faster incremental feedback
const results: WorkerDoc[] = []
let lastResultCount = 0
for (let i = 0; i < recentDocuments.length && results.length < limit; i += batchSize) {
// Check if search was superseded
if (currentSearchId !== searchId) return
// Process batch
const end = Math.min(i + batchSize, recentDocuments.length)
for (let j = i; j < end && results.length < limit; j++) {
const doc = recentDocuments[j]!
if (localeIncludes(doc.haystack, needle)) {
results.push(doc)
}
}
// Post incremental results if we found new matches
if (results.length > lastResultCount && currentSearchId === searchId) {
lastResultCount = results.length
const sortedResults = sortResults(results, query, loc)
postMessage({
type: 'results',
docs: sortedResults.map(stripHaystack),
id: searchId,
done: false
} as ResultMessage)
}
// Yield control between batches to allow new search requests to interrupt
if (i + batchSize < recentDocuments.length && results.length < limit) {
await yieldControl()
}
}
// Post final results
if (currentSearchId === searchId) {
const sortedResults = sortResults(results, query, loc)
postMessage({
type: 'results',
docs: sortedResults.map(stripHaystack),
id: searchId,
done: true
} as ResultMessage)
}
}
// Sort results by relevance
function sortResults(docs: WorkerDoc[], query: string, loc: string): WorkerDoc[] {
const locsub = loc + '/'
return [...docs].sort((a, b) => (
// Current folder first
// @ts-ignore
(b.loc === loc) - (a.loc === loc) ||
// Then subfolders
// @ts-ignore
(b.loc.slice(0, locsub.length) === locsub) - (a.loc.slice(0, locsub.length) === locsub) ||
// Then by location
collator.compare(a.loc, b.loc) ||
// Files after folders
// @ts-ignore
(a.dir === false) - (b.dir === false) ||
// Exact name match first
// @ts-ignore
b.name.includes(query) - a.name.includes(query) ||
// Finally by name
collator.compare(a.name, b.name)
))
}
// Strip haystack before sending back to main thread
function stripHaystack(doc: WorkerDoc): DocData {
const { haystack, ...rest } = doc
return rest
}
// Handle incoming messages
self.onmessage = async (e: MessageEvent<IncomingMessage>) => {
const msg = e.data
if (msg.type === 'update') {
// Update document list with haystacks
documents = msg.documents.map(doc => ({
...doc,
haystack: haystackFormat(doc.name)
}))
recentDocuments = sortByRecent(documents)
} else if (msg.type === 'search') {
currentSearchId = msg.id
if (msg.query) {
await performSearch(msg.query, msg.loc, msg.id)
} else {
// Empty query - no results needed (main thread handles folder listing)
postMessage({
type: 'results',
docs: [],
id: msg.id,
done: true
} as ResultMessage)
}
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ dependencies = [
"argon2-cffi>=25.1.0", "argon2-cffi>=25.1.0",
"av>=15.0.0", "av>=15.0.0",
"blake3>=1.0.5", "blake3>=1.0.5",
"docopt>=0.6.2", "docopt-ng>=0.9.0",
"fastapi-vue>=0.5.1", "fastapi-vue>=0.5.1",
"fastapi[standard]>=0.128.0", "fastapi[standard]>=0.128.0",
"html5tagger>=1.3.0", "html5tagger>=1.3.0",