Compare commits

..
12 Commits
Author SHA1 Message Date
LeoVasanko 760f7bc35d Fix vite argument passing on deno (fastapi-vue-setup upstream). 2026-02-05 20:13:03 +00:00
LeoVasanko 8480a73839 Fix --listen docopt parsing (only -l was working). 2026-02-05 20:01:26 +00:00
LeoVasanko 302ed684e7 Remove invalid Sanic kwarg when binding to all interfaces (not needed anyway). 2026-02-05 19:56:54 +00:00
LeoVasanko af35e0480a Better proxy header processing, pass original user-agent without httpx overriding it. 2026-02-05 17:53:52 +00:00
LeoVasanko 5717486197 Intl support for search hotkey (where US keyboard has it). 2026-02-05 00:57:42 +00:00
LeoVasanko 0061fc54ae Keyboard navigation fixes (still not perfect but better). 2026-02-05 00:54:15 +00:00
LeoVasanko 4eefe83072 Fix search bar losing focus under specific conditions, mainly that when the search takes more than 50ms, causing results to be emptied, with a file priorly focused, was causing breadcrumbs to get focused. (reactivity is hard) 2026-02-05 00:07:45 +00:00
LeoVasanko f578a50007 Pass the new allocated field through search filtering. Fixes issue where 0 allocated was reported in search results. 2026-02-04 23:55:05 +00:00
LeoVasanko f40d9c1abd Cache previews in server RAM for much faster access (they are about 70kB each). 2026-02-04 20:56:16 +00:00
LeoVasanko 3d8845cf99 Brighter low free space colors. 2026-02-04 20:40:59 +00:00
LeoVasanko 87e1443e7d Auth restricted endpoint is picky about the trailing slash, use it consistently. 2026-02-04 20:35:29 +00:00
LeoVasanko f45c57e901 Auth mode indication on startupbox 2026-02-04 20:24:21 +00:00
15 changed files with 238 additions and 57 deletions
+15 -4
View File
@@ -25,14 +25,22 @@ def create_banner():
"""
def create_startup_box(*, folder, url, unix=None, dev=False, paskia_url=None):
def create_startup_box(
*, folder, url, unix=None, dev=False, paskia_url=None, public=False
):
"""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]
# Auth line: Paskia <url> or Password, with optional Public suffix
if paskia_url:
lines.append(f"Paskia: {paskia_url}")
auth_line = f"Auth: Paskia {paskia_url}"
else:
auth_line = "Auth: Password"
if public:
auth_line += ", Public"
lines.append(auth_line)
if dev:
lines.append("dev mode")
@@ -53,10 +61,12 @@ doc = """\
Usage:
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
cista [-c <confdir>] --user <name> [--privileged] [--password]
cista --version
Options:
-c CONFDIR Custom config directory
-l LISTEN-ADDR Listen on
-l, --listen LISTEN-ADDR
Listen on
:8989 (localhost port, plain http)
<addr>:3000 (bind another address, port)
/path/to/unix.sock (unix socket)
@@ -109,7 +119,7 @@ def _main():
args = docopt(doc)
if args["--user"]:
return _user(args)
listen = args["-l"]
listen = args["--listen"]
# Validate arguments first
if args["<path>"]:
path = Path(args["<path>"]).resolve()
@@ -157,6 +167,7 @@ def _main():
unix=opts.get("unix"),
dev=dev,
paskia_url=PASKIA_BACKEND_URL or None,
public=config.config.public,
)
sys.stderr.write(startup_box)
# Run the server
+6 -4
View File
@@ -166,10 +166,12 @@ def subscribe(uuid, ws):
@bp.get("config")
async def get_config(request):
await auth.verify(request, privileged=True)
return json({
"name": config.config.name,
"public": config.config.public,
})
return json(
{
"name": config.config.name,
"public": config.config.public,
}
)
@bp.put("config/public")
+2 -2
View File
@@ -269,7 +269,7 @@ async def verify(request, *, privileged=False):
raise Unauthorized(
f"Login required for {request.path}",
"cookie",
context={"auth": {"iframe": "/auth/restricted"}},
context={"auth": {"iframe": "/auth/restricted/"}},
quiet=True,
)
@@ -278,7 +278,7 @@ async def verify(request, *, privileged=False):
bp = Blueprint("auth", url_prefix="/auth")
@bp.get("/restricted")
@bp.get("/restricted/")
async def login_page(request):
"""Login page that works both standalone and in paskia iframe."""
s = session.get(request)
+70 -12
View File
@@ -2,7 +2,10 @@ import asyncio
import gc
import io
import mimetypes
import threading
import urllib.parse
from collections import OrderedDict
from dataclasses import dataclass
from pathlib import PurePosixPath
from time import perf_counter
from urllib.parse import unquote
@@ -25,6 +28,49 @@ pillow_heif.register_heif_opener()
bp = Blueprint("preview", url_prefix="/preview")
@dataclass(slots=True)
class CachedPreview:
"""Cached preview with headers and body."""
headers: dict[str, str]
body: bytes
class PreviewCache:
"""Thread-safe LRU cache for preview responses."""
def __init__(self, capacity: int = 500):
self.capacity = capacity
self._cache: OrderedDict[str, CachedPreview] = OrderedDict()
self._lock = threading.Lock()
def get(self, key: str) -> CachedPreview | None:
"""Get cached preview, moving it to end (most recently used)."""
with self._lock:
if key in self._cache:
self._cache.move_to_end(key)
return self._cache[key]
return None
def set(self, key: str, value: CachedPreview) -> None:
"""Cache preview, evicting oldest if at capacity."""
with self._lock:
if key in self._cache:
self._cache.move_to_end(key)
else:
if len(self._cache) >= self.capacity:
self._cache.popitem(last=False)
self._cache[key] = value
def __len__(self) -> int:
with self._lock:
return len(self._cache)
# Global preview cache instance
_preview_cache = PreviewCache(capacity=500)
@bp.on_request
async def verify_preview(request):
"""Verify access to preview routes."""
@@ -55,6 +101,29 @@ async def preview(req, path):
etag = config.derived_secret(
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
).hex()
if req.headers.if_none_match == etag:
# The client has it cached, respond 304 Not Modified
return empty(304, headers={"etag": etag})
# Check in-memory cache first (includes headers)
cached = _preview_cache.get(etag)
if cached is not None:
logger.debug(f"Preview cache hit: {rel}")
return raw(cached.body, headers=cached.headers)
if not filepath.is_file():
raise NotFound("File not found")
# Generate preview
img = await asyncio.get_event_loop().run_in_executor(
req.app.ctx.threadexec, dispatch, filepath, quality, maxsize, maxzoom
)
if not img:
# Preview generation failed, redirect to the file itself
return redirect(f"/files/{path}", status=303)
# Build headers and cache the full response
savename = PurePosixPath(filepath.name).with_suffix(".avif")
headers = {
"etag": etag,
@@ -64,19 +133,8 @@ async def preview(req, path):
"content-type": "image/avif",
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
}
if req.headers.if_none_match == etag:
# The client has it cached, respond 304 Not Modified
return empty(304, headers=headers)
_preview_cache.set(etag, CachedPreview(headers=headers, body=img))
if not filepath.is_file():
raise NotFound("File not found")
img = await asyncio.get_event_loop().run_in_executor(
req.app.ctx.threadexec, dispatch, filepath, quality, maxsize, maxzoom
)
if not img:
# Preview generation failed, redirect to the file itself
return redirect(f"/files/{path}", status=303)
return raw(img, headers=headers)
+1 -6
View File
@@ -62,11 +62,6 @@ def parse_listen(listen):
return "http://localhost", {"unix": unix.as_posix()}
host, port = ep["host"], ep["port"]
# When binding all interfaces, use single_listener=False for Sanic
if len(endpoints) > 1:
return f"http://localhost:{port}", {
"host": host,
"port": port,
"single_listener": False,
}
return f"http://localhost:{port}", {"host": host, "port": port}
return f"http://{host}:{port}", {"host": host, "port": port}
+12 -2
View File
@@ -48,6 +48,8 @@ async def get_client() -> httpx.AsyncClient:
global _client
if _client is None or _client.is_closed:
_client = httpx.AsyncClient(timeout=1.0)
if "user-agent" in _client.headers:
del _client.headers["user-agent"] # No httpx UA
return _client
@@ -171,10 +173,10 @@ async def proxy_auth_request(request):
"upgrade",
"proxy-authorization",
"proxy-authenticate",
"forwarded",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-proto",
"forwarded",
}
headers = [
@@ -182,9 +184,17 @@ async def proxy_auth_request(request):
for key, value in request.headers.items()
if key.lower() not in skip_headers
]
headers.append(("x-forwarded-for", request.client_ip))
# Set Forwarded headers (strip IPv6 brackets for x-forwarded-for)
headers.append(("x-forwarded-for", request.client_ip.strip("[]")))
headers.append(("x-forwarded-host", request.host))
headers.append(("x-forwarded-proto", request.scheme))
headers.append(
(
"forwarded",
f"by=cista;for={request.client_ip};host={request.host};proto={request.scheme}",
)
)
try:
async with client.stream(
+62 -13
View File
@@ -63,6 +63,7 @@ onUnmounted(watchDisconnect)
const headerMain = ref<typeof HeaderMain | null>(null)
let vert = 0
let timer: any = null
const globalShortcutHandler = (event: KeyboardEvent) => {
if (store.dialog) {
if (timer) {
@@ -76,6 +77,13 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
const c = fileExplorer.isCursor()
const input = (event.target as HTMLElement).tagName === 'INPUT'
const keyup = event.type === 'keyup'
// Always clear repeat timer on arrow keyup, even if focus moved to input
if (keyup && event.key.startsWith('Arrow') && timer) {
clearTimeout(timer)
timer = null
}
if (event.repeat) {
if (
event.key === 'ArrowUp' ||
@@ -91,13 +99,32 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
//console.log("key pressed", event)
/// Long if-else machina for all keys we handle here
let arrow = ''
if (!input && event.key.startsWith("Arrow")) arrow = event.key.slice(5).toLowerCase()
const inHeader = !!(event.target as HTMLElement).closest('.headermain')
const inBreadcrumb = !!(event.target as HTMLElement).closest('.breadcrumb')
// Handle arrows: in search input with text, only up/down; otherwise all arrows
const searchInput = inHeader && input
const searchHasText = searchInput && (event.target as HTMLInputElement).value
if (event.key.startsWith("Arrow")) {
const dir = event.key.slice(5).toLowerCase()
// In search with text: left/right move cursor, up/down navigate
if (searchHasText && (dir === 'left' || dir === 'right')) {
return // Let browser handle cursor movement
}
arrow = dir
}
if (arrow) {
// Arrow key handling - fall through to bottom
}
// Find: process on keydown so that we can bypass the built-in search hotkey
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
headerMain.value!.toggleSearchInput()
}
// Search also on / (UNIX style)
else if (!input && keyup && event.key === '/') {
// Search also on / (UNIX style) - use code to support any keyboard layout
else if (!input && keyup && event.code === 'Slash') {
// Record the actual character for display (varies by keyboard layout)
if (event.key.length === 1 && event.key !== store.prefs.searchHotkey) {
store.prefs.searchHotkey = event.key
}
headerMain.value!.toggleSearchInput()
}
// Globally close search, clear errors on Escape
@@ -143,13 +170,34 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
timer = null
}
let f: any
switch (arrow) {
case 'up': f = () => fileExplorer.up(event); break
case 'down': f = () => fileExplorer.down(event); break
case 'left': f = () => fileExplorer.left(event); break
case 'right': f = () => fileExplorer.right(event); break
// Arrow navigation - always use fileExplorer for repeatable movement
if (arrow && !keyup) {
const focusSearch = () => (document.querySelector('.headermain input[type="search"]') as HTMLElement)?.focus()
const focusBreadcrumb = () => (document.querySelector('.breadcrumb') as HTMLElement)?.focus()
if (inBreadcrumb) {
// Breadcrumb: up→header (no repeat), down→files (with repeat)
if (arrow === 'up') { focusSearch(); f = null }
else if (arrow === 'down') { fileExplorer.focusFirst?.(); f = null }
} else if (inHeader) {
// Header: left/right navigate focusable items (buttons without tabindex=-1, search input, disk space)
const items = Array.from(document.querySelectorAll('.headermain button:not([tabindex=\"-1\"]), .headermain input[type=\"search\"], .headermain [tabindex=\"0\"]')) as HTMLElement[]
const idx = items.indexOf(document.activeElement as HTMLElement)
if (arrow === 'left' && idx > 0) { items[idx - 1]?.focus(); f = null }
else if (arrow === 'right' && idx < items.length - 1) { items[idx + 1]?.focus(); f = null }
else if (arrow === 'up') f = () => fileExplorer.up({ shiftKey: false })
else if (arrow === 'down') { focusBreadcrumb(); f = null }
} else {
// File explorer: normal navigation with repeat
switch (arrow) {
case 'up': f = () => fileExplorer.up(event); break
case 'down': f = () => fileExplorer.down(event); break
case 'left': f = () => fileExplorer.left(event); break
case 'right': f = () => fileExplorer.right(event); break
}
}
}
if (f && !keyup) {
if (f) {
// Initial move, then t0 delay until repeats at tr intervals
const t0 = 200, tr = event.altKey ? 20 : 100
f()
@@ -157,12 +205,13 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
}
}
onMounted(() => {
window.addEventListener('keydown', globalShortcutHandler)
window.addEventListener('keyup', globalShortcutHandler)
// Use capture phase to handle events before they reach target elements
window.addEventListener('keydown', globalShortcutHandler, true)
window.addEventListener('keyup', globalShortcutHandler, true)
})
onUnmounted(() => {
window.removeEventListener('keydown', globalShortcutHandler)
window.removeEventListener('keyup', globalShortcutHandler)
window.removeEventListener('keydown', globalShortcutHandler, true)
window.removeEventListener('keyup', globalShortcutHandler, true)
})
export type { Path }
</script>
+8 -3
View File
@@ -1,5 +1,5 @@
<template>
<div class="disk-space-container" ref="containerRef">
<div class="disk-space-container" ref="containerRef" tabindex="0" @keydown.enter="handleClick" @keydown.space.prevent="handleClick">
<div
ref="widgetRef"
class="disk-space-widget"
@@ -157,8 +157,8 @@ const freeColor = computed(() => {
if (!s.disk) return '#6c6'
const freePct = s.free / s.disk
if (freePct > 0.25) return '#5b5'
if (freePct > 0.10) return '#db3'
return '#d44'
if (freePct > 0.10) return '#ff0'
return '#f00'
})
const PIE_RADIUS = 55
@@ -352,6 +352,11 @@ onUnmounted(() => {
position: relative;
width: 3em;
height: 3em;
outline: none;
}
.disk-space-container:focus .disk-space-widget:not(.expanded) {
filter: brightness(1);
}
.disk-space-widget {
+28 -4
View File
@@ -72,7 +72,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document'
import FileRenameInput from './FileRenameInput.vue'
@@ -135,6 +135,17 @@ defineExpose({
isCursor() {
return store.cursor && editing.value === null
},
focusFirst() {
const docs = props.documents
if (docs.length > 0) {
store.cursor = docs[0]!.key
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
nextTick(() => {
const a = document.querySelector(`#file-${store.cursor} .name a`) as HTMLAnchorElement | null
if (a) a.focus()
})
}
},
cursorRename() {
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
},
@@ -150,7 +161,12 @@ defineExpose({
},
up(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
down(ev: KeyboardEvent) { this.cursorMove(1, ev) },
left(ev: KeyboardEvent) { router.back() },
left(ev: KeyboardEvent) {
// Only go back if we're in a subfolder (not at root)
if (props.path.length > 0) {
router.back()
}
},
right(ev: KeyboardEvent) {
const a = document.querySelector(`#file-${store.cursor} a`) as HTMLAnchorElement | null
if (a) a.click()
@@ -190,9 +206,17 @@ defineExpose({
scrolltimer = null
}, 300)
}
if (moveto === N) focusBreadcrumb()
// When leaving the file list: up goes to breadcrumbs, down goes to header
if (moveto === N) {
if (d < 0) focusBreadcrumb()
else focusHeader()
}
}
})
const focusHeader = () => {
const el = document.querySelector('.headermain input[type="search"]') as HTMLElement | null
if (el) el.focus()
}
const focusBreadcrumb = () => {
const el = document.querySelector('.breadcrumb') as HTMLElement | null
if (el) el.focus()
@@ -210,7 +234,7 @@ watchEffect(() => {
}
})
watchEffect(() => {
if (!props.documents.length && store.cursor) {
if (!props.documents.length && store.cursor && !store.query) {
store.cursor = ''
focusBreadcrumb()
}
+22 -3
View File
@@ -9,7 +9,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document'
import { connect, controlUrl } from '@/repositories/WS'
@@ -82,6 +82,17 @@ defineExpose({
isCursor() {
return store.cursor && editing.value === null
},
focusFirst() {
const docs = props.documents
if (docs.length > 0) {
store.cursor = docs[0]!.key
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
nextTick(() => {
const a = document.querySelector(`#file-${store.cursor}`) as HTMLAnchorElement | null
if (a) a.focus()
})
}
},
cursorRename() {
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
},
@@ -144,9 +155,17 @@ defineExpose({
scrolltimer = null
}, 300)
}
if (moveto === N) focusBreadcrumb()
// When leaving the file list: up goes to breadcrumbs, down goes to header
if (moveto === N) {
if (d < 0) focusBreadcrumb()
else focusHeader()
}
}
})
const focusHeader = () => {
const el = document.querySelector('.headermain input[type="search"]') as HTMLElement | null
if (el) el.focus()
}
const focusBreadcrumb = () => {
const el = document.querySelector('.breadcrumb') as HTMLElement | null
if (el) el.focus()
@@ -162,7 +181,7 @@ watchEffect(() => {
}
})
watchEffect(() => {
if (!props.documents.length && store.cursor) {
if (!props.documents.length && store.cursor && !store.query) {
store.cursor = ''
focusBreadcrumb()
}
+6 -3
View File
@@ -9,7 +9,7 @@
<div class="smallgap"></div>
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
<div class="search-group">
<SvgButton name="find" @click="focusSearch" tooltip="Search" />
<SvgButton name="find" tabindex="-1" @click="focusSearch" tooltip="Search" />
<input
ref="search"
type="search"
@@ -17,7 +17,7 @@
@input="updateSearch"
@keydown.escape="clearSearch"
/>
<span v-if="!query" class="search-hint" @click="focusSearch">/</span>
<span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span>
</div>
<div class="spacer smallgap"></div>
<DiskSpace v-if="store.space.disk" />
@@ -115,7 +115,7 @@ const settingsMenu = (e: Event) => {
// Show login option only in public mode (non-public modes trigger auth automatically)
items.push({ label: '🔐 Login', onClick: async () => {
try {
await showAuthIframe('/auth/restricted#theme=light')
await showAuthIframe('/auth/restricted/#theme=light')
resumeWatching()
} catch (e) {
console.log('Login cancelled')
@@ -159,6 +159,9 @@ defineExpose({
.search-group:focus-within {
background: rgba(255, 255, 255, 0.2);
}
.search-group:focus-within {
box-shadow: 0 0 0 2px var(--accent-color, #f80);
}
.search-group:hover :deep(button.action-button),
.search-group:focus-within :deep(button.action-button) {
transform: scale(1.1);
+2
View File
@@ -1,6 +1,7 @@
<template>
<button
class="action-button"
:tabindex="tabindex"
@mouseenter="tooltip?.startHover"
@mousemove="tooltip?.updatePosition"
@mouseleave="tooltip?.endHover"
@@ -19,6 +20,7 @@ import CursorTooltip from './CursorTooltip.vue'
const props = defineProps<{
name: IconName
tooltip?: string
tabindex?: string | number
}>()
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
+2
View File
@@ -87,6 +87,7 @@ export const useMainStore = defineStore('main', {
gallery: false,
sortListing: '' as SortOrder,
sortFiltered: '' as SortOrder,
searchHotkey: '/', // Character shown for search hotkey (Slash key)
},
user: {
username: '' as string,
@@ -221,6 +222,7 @@ export const useMainStore = defineStore('main', {
name: doc.name,
key: doc.key,
size: doc.size,
allocated: doc.allocated,
mtime: doc.mtime,
dir: doc.dir,
}))
+1
View File
@@ -6,6 +6,7 @@ interface DocData {
name: string
key: string
size: number
allocated: number
mtime: number
dir: boolean
}
+1 -1
View File
@@ -134,7 +134,7 @@ def find_dev_tool() -> list[str]:
Raises RuntimeError if no runtime is found.
"""
dev_args = {
"deno": ("run", "dev", "--"),
"deno": ("run", "-A", "npm:vite"),
"npm": ("--silent", "run", "dev", "--"),
"bun": ("run", "dev", "--"),
}