Compare commits

...
8 Commits
Author SHA1 Message Date
LeoVasanko 3bad311e35 Tell Paskia SSO not to renew session on WebSocket connections where we cannot pass back the refreshed cookie. 2026-08-11 02:08:01 +00:00
LeoVasanko fdc4fe0a3e Forward client user-agent to SSO backend on validation refreshes. Matches function of existing proxy_auth_request (copies all headers) and proxy_auth_websocket (copies user-agent when present). 2026-08-10 14:08:17 +00:00
LeoVasanko 5df2308bdb Silence CPY copyright rule; make format_access_log tail args keyword-only
Newer ruff flagged CPY001 across the codebase (copyright notices are not
wanted here, rule disabled) and PLR0917 on format_access_log. duration_ms
and extra are now keyword-only at the single call site.
2026-07-28 02:37:12 +00:00
LeoVasanko f4c44ce1aa Remove unused frontend test framework
vitest, @vue/test-utils, jsdom and @types/jsdom were installed but no
frontend tests exist or are planned. Removing them also drops the
deprecated glob@10 dependency chain (js-beautify). type-check now uses
tsconfig.app.json.
2026-07-28 02:32:26 +00:00
LeoVasanko 49232f11cc Fix rename flow: KeepAlive-cached view watchers cleared cursor on stale props
Deactivated FileExplorer/Gallery instances stay alive in KeepAlive with
frozen, potentially empty document props. Their empty-folder watcher
cleared store.cursor and yanked focus to the breadcrumb on every cursor
change, breaking rename via gallery pen and keyboard entry into the
file list, and hiding the explorer rename button.

- Guard cursor watchers in FileExplorer/Gallery with an isActive flag
  (set on activated, cleared on deactivated)
- Declare emits in GalleryFigure (rename/menu fell through to the root
  anchor as native listeners)
- Show the explorer rename button on row hover with a delayed fade-in
  instead of only on the keyboard-focused row
2026-07-28 02:16:23 +00:00
LeoVasanko 1258eff42d Fix preview worker pool leak: ffmpeg must not inherit worker stdin
The ffmpeg fallback in the preview worker inherited the worker's stdin
pipe (the framed request protocol). When a slow conversion was killed
at the 10s timeout, the orphaned ffmpeg grandchild kept that pipe open,
so the parent's proc.wait() blocked forever waiting for pipe EOF —
permanently sticking one dispatcher per event until the whole pool
starved and every preview request (pdf, image, office) returned 503.

- Run ffmpeg with stdin=DEVNULL (also stops it eating protocol bytes)
- Drop start_new_session (only needed for group kills, POSIX-only)
- Stop logging the master secret at worker startup
2026-07-28 01:14:15 +00:00
LeoVasanko 718d46e3f9 Fix search in subdirectories (problem saving search field in URL). 2026-06-17 04:01:21 +00:00
LeoVasanko 92d9c40a28 Center file rename input in gallery mode to be more consistent with normal titles. 2026-06-17 03:43:01 +00:00
16 changed files with 73 additions and 38 deletions
+3 -1
View File
@@ -40,7 +40,9 @@ async def watch(req, ws):
if sso.paskia_enabled(): if sso.paskia_enabled():
# SSO auth: call validation to get user info (don't enforce auth in public mode) # SSO auth: call validation to get user info (don't enforce auth in public mode)
try: try:
await sso.validate_sso_request(req) # WebSocket cannot forward Set-Cookie, so ask the auth backend not to
# renew the session here; renewal happens on the HTTP side instead.
await sso.validate_sso_request(req, renew=False)
except Exception as e: except Exception as e:
logger.debug("watch SSO validation failed: %s", e) logger.debug("watch SSO validation failed: %s", e)
if sso_user := getattr(req.ctx, "sso_user", None): if sso_user := getattr(req.ctx, "sso_user", None):
+1 -1
View File
@@ -86,7 +86,7 @@ async def log_access(req, res):
path = f"{path}?{qs}" path = f"{path}?{qs}"
extra = getattr(req.ctx, "log_extra", None) extra = getattr(req.ctx, "log_extra", None)
line = format_access_log( line = format_access_log(
client, res.status, req.method, host, path, duration_ms, extra=extra client, res.status, req.method, host, path, duration_ms=duration_ms, extra=extra
) )
access_logger.info(line) access_logger.info(line)
return res return res
+3 -1
View File
@@ -145,6 +145,9 @@ class _PreviewWorker:
async def kill(self) -> None: async def kill(self) -> None:
if self.proc.returncode is None: if self.proc.returncode is None:
# Safe to hard-kill: the worker is stateless per request, and its
# subprocesses (ffmpeg) use stdin=DEVNULL so they never hold the
# worker's pipes open — proc.wait() cannot hang on pipe EOF.
with contextlib.suppress(ProcessLookupError): with contextlib.suppress(ProcessLookupError):
self.proc.kill() self.proc.kill()
await self.proc.wait() await self.proc.wait()
@@ -179,7 +182,6 @@ class _PreviewWorkerPool:
stdin=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
start_new_session=True,
) )
_active_procs.add(proc) _active_procs.add(proc)
try: try:
+13 -6
View File
@@ -219,7 +219,18 @@ def _image_via_ffmpeg(path: Path, maxsize: int, quality: int) -> bytes:
cmd.insert(5, f"{new_w}x{new_h}") cmd.insert(5, f"{new_w}x{new_h}")
try: try:
try: try:
subprocess.run(cmd, capture_output=True, check=True, shell=False) # noqa: S603 # stdin=DEVNULL is critical: ffmpeg must not inherit the worker's
# stdin, which carries the framed request protocol. An inherited
# stdin lets ffmpeg eat protocol bytes and, if the worker is
# killed mid-conversion, keeps the orphaned ffmpeg holding the
# pipe open so the parent's proc.wait() hangs forever.
subprocess.run( # noqa: S603
cmd,
capture_output=True,
check=True,
shell=False,
stdin=subprocess.DEVNULL,
)
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
shell_cmd = shlex.join(cmd) shell_cmd = shlex.join(cmd)
stderr = (e.stderr or b"").decode(errors="replace").strip() stderr = (e.stderr or b"").decode(errors="replace").strip()
@@ -540,11 +551,7 @@ def main() -> None:
logging.basicConfig(stream=sys.stderr, level=logging.INFO) logging.basicConfig(stream=sys.stderr, level=logging.INFO)
try: try:
config.load_config() config.load_config()
logger.warning( logger.info("preview-worker config=%s", config.conffile)
"preview-worker config=%s master_secret=%s",
config.conffile,
config.config.secret,
)
except Exception: except Exception:
logger.exception("preview-worker failed to load config at startup") logger.exception("preview-worker failed to load config at startup")
if len(sys.argv) > 1: if len(sys.argv) > 1:
+1
View File
@@ -156,6 +156,7 @@ def format_access_log(
method: str, method: str,
host: str, host: str,
path: str, path: str,
*,
duration_ms: float, duration_ms: float,
extra: str | None = None, extra: str | None = None,
) -> str: ) -> str:
+11 -1
View File
@@ -62,12 +62,18 @@ async def close_client():
_client = None _client = None
async def validate_sso_request(request, *, perm: str = "cista:login") -> dict | None: async def validate_sso_request(
request, *, perm: str = "cista:login", renew: bool = True
) -> dict | None:
"""Validate an SSO request against the auth backend. """Validate an SSO request against the auth backend.
Args: Args:
request: The Sanic request object request: The Sanic request object
perm: Permission to validate (default: cista:login, privileged also cista:admin) perm: Permission to validate (default: cista:login, privileged also cista:admin)
renew: Whether to allow the auth backend to renew the session cookie.
Use ``False`` for WebSocket validation where Set-Cookie cannot be
forwarded to the client; this makes the request read-only and avoids
resetting the backend renewal timeout.
Returns: Returns:
User info dict if valid, None if validation fails with auth required response User info dict if valid, None if validation fails with auth required response
@@ -88,12 +94,16 @@ async def validate_sso_request(request, *, perm: str = "cista:login") -> dict |
headers["cookie"] = request.headers["cookie"] headers["cookie"] = request.headers["cookie"]
if "authorization" in request.headers: if "authorization" in request.headers:
headers["authorization"] = request.headers["authorization"] headers["authorization"] = request.headers["authorization"]
if "user-agent" in request.headers:
headers["user-agent"] = request.headers["user-agent"]
headers["accept"] = "application/json" headers["accept"] = "application/json"
headers["x-forwarded-for"] = request.client_ip headers["x-forwarded-for"] = request.client_ip
headers["x-forwarded-host"] = request.host headers["x-forwarded-host"] = request.host
headers["x-forwarded-proto"] = request.scheme headers["x-forwarded-proto"] = request.scheme
url = f"{PASKIA_BACKEND_URL}/auth/api/validate?perm={perm}" url = f"{PASKIA_BACKEND_URL}/auth/api/validate?perm={perm}"
if not renew:
url += "&renew=0"
try: try:
response = await client.post( response = await client.post(
+1 -6
View File
@@ -6,9 +6,8 @@
"dev": "vite", "dev": "vite",
"build": "run-p type-check \"build-only {@}\" --", "build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview", "preview": "vite preview",
"test:unit": "vitest",
"build-only": "vite build", "build-only": "vite build",
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false", "type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
"lint": "biome lint .", "lint": "biome lint .",
"format": "biome format --write .", "format": "biome format --write .",
"format:check": "biome format --check .", "format:check": "biome format --check .",
@@ -37,17 +36,13 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",
"@tsconfig/node18": "^18.2.6", "@tsconfig/node18": "^18.2.6",
"@types/jsdom": "^27.0.0",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^25.1.0", "@types/node": "^25.1.0",
"@vitejs/plugin-vue": "^6.0.3", "@vitejs/plugin-vue": "^6.0.3",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1", "@vue/tsconfig": "^0.8.1",
"jsdom": "^27.4.0",
"npm-run-all2": "^8.0.4", "npm-run-all2": "^8.0.4",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^7.3.1", "vite": "^7.3.1",
"vitest": "^4.0.18",
"vue-tsc": "^3.2.4" "vue-tsc": "^3.2.4"
} }
} }
+1 -1
View File
@@ -94,7 +94,7 @@ const path: ComputedRef<Path> = computed(() => {
const isEditorPath = !!(doc && !doc.dir && doc.text) const isEditorPath = !!(doc && !doc.dir && doc.text)
const canonicalBase = !fullPath ? '/' : doc?.dir ? `/${fullPath}/` : `/${fullPath}` const canonicalBase = !fullPath ? '/' : doc?.dir ? `/${fullPath}/` : `/${fullPath}`
const canonicalPath = query const canonicalPath = query
? rawPath // keep search URL shape untouched ? `${rawPath}//${query}` // keep search URL shape untouched
: canonicalBase : canonicalBase
const pathList = isEditorPath ? routePathList.slice(0, -1) : routePathList const pathList = isEditorPath ? routePathList.slice(0, -1) : routePathList
const breadcrumbPathList = routePathList const breadcrumbPathList = routePathList
+18 -1
View File
@@ -53,7 +53,7 @@
<a :href="doc.text ? doc.editurl : doc.url" tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key"> <a :href="doc.text ? doc.editurl : doc.url" tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
{{ doc.name }} {{ doc.name }}
</a> </a>
<button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊</button> <button tabindex=-1 class="rename-button" @click="() => (editing = doc)">🖊</button>
</template> </template>
</td> </td>
<FileModified :doc=doc :now=nowkey /> <FileModified :doc=doc :now=nowkey />
@@ -84,6 +84,7 @@ import ContextMenu from '@imengyu/vue3-context-menu'
import { import {
computed, computed,
nextTick, nextTick,
onActivated,
onDeactivated, onDeactivated,
onMounted, onMounted,
onUnmounted, onUnmounted,
@@ -336,9 +337,13 @@ const focusBreadcrumb = () => {
const keyboardFollowScroll = createKeyboardFollowScroll() const keyboardFollowScroll = createKeyboardFollowScroll()
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
// Deactivated (KeepAlive-cached) instances stay alive with frozen, potentially
// stale props - their watchers must not react to global store changes.
let isActive = true
watch( watch(
() => store.cursor, () => store.cursor,
cursor => { cursor => {
if (!isActive) return
if (cursor && editing.value && cursor !== editing.value.key) { if (cursor && editing.value && cursor !== editing.value.key) {
exitEditing() exitEditing()
} }
@@ -347,6 +352,7 @@ watch(
watch( watch(
() => store.cursor, () => store.cursor,
cursor => { cursor => {
if (!isActive) return
if (cursor && !editing.value) { if (cursor && !editing.value) {
const a = document.querySelector( const a = document.querySelector(
`#file-${cursor} .name a` `#file-${cursor} .name a`
@@ -359,6 +365,7 @@ watch(
watch( watch(
() => [props.documents.length, store.cursor, store.query, editing.value] as const, () => [props.documents.length, store.cursor, store.query, editing.value] as const,
([len, cursor, query, editingDoc]) => { ([len, cursor, query, editingDoc]) => {
if (!isActive) return
if (!len && cursor && !query && !editingDoc) { if (!len && cursor && !query && !editingDoc) {
store.cursor = '' store.cursor = ''
focusBreadcrumb() focusBreadcrumb()
@@ -378,7 +385,11 @@ onMounted(() => {
active.focus({ preventScroll: true }) active.focus({ preventScroll: true })
} }
}) })
onActivated(() => {
isActive = true
})
onDeactivated(() => { onDeactivated(() => {
isActive = false
if (editing.value) exitEditing() if (editing.value) exitEditing()
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -617,6 +628,12 @@ table td {
.name .rename-button { .name .rename-button {
position: absolute; position: absolute;
right: 0; right: 0;
opacity: 0;
visibility: hidden;
}
tbody tr:hover .name .rename-button {
opacity: 1;
visibility: visible;
animation: appear calc(5 * var(--transition-time)) linear; animation: appear calc(5 * var(--transition-time)) linear;
} }
@keyframes appear { @keyframes appear {
@@ -60,6 +60,7 @@ input#FileRenameInput {
padding: .75em; padding: .75em;
font-weight: 600; font-weight: 600;
width: auto; width: auto;
text-align: center;
} }
</style> </style>
+13 -1
View File
@@ -8,7 +8,7 @@
:editing="editing === doc ? {rename, exit} : null" :editing="editing === doc ? {rename, exit} : null"
:style="{ '--gallery-figure-height': rowHeightsByKey[doc.key] ?? '15em' }" :style="{ '--gallery-figure-height': rowHeightsByKey[doc.key] ?? '15em' }"
@menu="contextMenu($event, doc)" @menu="contextMenu($event, doc)"
@rename="editing = doc; store.cursor = doc.key" @rename="onFigureRename(doc)"
:class="{ 'folder-start': showFolderBreadcrumb(index) }" :class="{ 'folder-start': showFolderBreadcrumb(index) }"
/> />
</template> </template>
@@ -64,6 +64,10 @@ const editing = shallowRef<Doc | null>(null)
const exit = () => { const exit = () => {
editing.value = null editing.value = null
} }
const onFigureRename = (doc: Doc) => {
editing.value = doc
store.cursor = doc.key
}
const rename = async (doc: Doc, newName: string) => { const rename = async (doc: Doc, newName: string) => {
const oldName = doc.name const oldName = doc.name
doc.name = newName // We should get an update from watch but this is quicker doc.name = newName // We should get an update from watch but this is quicker
@@ -396,9 +400,13 @@ const focusBreadcrumb = () => {
const keyboardFollowScroll = createKeyboardFollowScroll() const keyboardFollowScroll = createKeyboardFollowScroll()
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
// Deactivated (KeepAlive-cached) instances stay alive with frozen, potentially
// stale props - their watchers must not react to global store changes.
let isActive = true
watch( watch(
() => store.cursor, () => store.cursor,
cursor => { cursor => {
if (!isActive) return
if (cursor && editing.value && cursor !== editing.value.key) { if (cursor && editing.value && cursor !== editing.value.key) {
exit() exit()
} }
@@ -407,6 +415,7 @@ watch(
watch( watch(
() => store.cursor, () => store.cursor,
cursor => { cursor => {
if (!isActive) return
if (cursor && !editing.value) { if (cursor && !editing.value) {
const a = document.querySelector(`#file-${cursor}`) as HTMLAnchorElement | null const a = document.querySelector(`#file-${cursor}`) as HTMLAnchorElement | null
if (a) { if (a) {
@@ -419,6 +428,7 @@ watch(
watch( watch(
() => [props.documents.length, store.cursor, store.query, editing.value] as const, () => [props.documents.length, store.cursor, store.query, editing.value] as const,
([len, cursor, query, editingDoc]) => { ([len, cursor, query, editingDoc]) => {
if (!isActive) return
if (!len && cursor && !query && !editingDoc) { if (!len && cursor && !query && !editingDoc) {
store.cursor = '' store.cursor = ''
focusBreadcrumb() focusBreadcrumb()
@@ -449,12 +459,14 @@ onMounted(() => {
attachGalleryObservers() attachGalleryObservers()
}) })
onActivated(() => { onActivated(() => {
isActive = true
nextTick(() => { nextTick(() => {
updateColumns() updateColumns()
attachGalleryObservers() attachGalleryObservers()
}) })
}) })
onDeactivated(() => { onDeactivated(() => {
isActive = false
detachGalleryObservers() detachGalleryObservers()
if (editing.value) exit() if (editing.value) exit()
}) })
+5 -1
View File
@@ -29,7 +29,7 @@
<span class="filename">{{ snap.displayName }}<SparseIndicator :doc="doc" class="after-name" /></span> <span class="filename">{{ snap.displayName }}<SparseIndicator :doc="doc" class="after-name" /></span>
<span v-if="snap.ext" class="file-ext">.{{ snap.ext }}</span> <span v-if="snap.ext" class="file-ext">.{{ snap.ext }}</span>
</span> </span>
<button class="rename-btn" @click="$emit('rename')" title="Rename"></button> <button class="rename-btn" @click="emit('rename')" title="Rename"></button>
</div> </div>
<div class=namespacer></div> <div class=namespacer></div>
</template> </template>
@@ -64,6 +64,10 @@ const props = defineProps<{
doc: Doc doc: Doc
editing?: EditingProp editing?: EditingProp
}>() }>()
const emit = defineEmits<{
(e: 'rename'): void
(e: 'menu', ev: MouseEvent): void
}>()
const m = ref<typeof MediaPreview | null>(null) const m = ref<typeof MediaPreview | null>(null)
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null) const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
-3
View File
@@ -6,9 +6,6 @@
}, },
{ {
"path": "./tsconfig.app.json" "path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
} }
] ]
} }
+1 -7
View File
@@ -1,12 +1,6 @@
{ {
"extends": "@tsconfig/node18/tsconfig.json", "extends": "@tsconfig/node18/tsconfig.json",
"include": [ "include": ["vite.config.*"],
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"module": "ESNext", "module": "ESNext",
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.app.json",
"exclude": [],
"compilerOptions": {
"composite": true,
"types": ["node", "jsdom"]
}
}
+1
View File
@@ -132,6 +132,7 @@ ignore = [
"ANN205", # legacy codebase: no full runtime annotation coverage yet "ANN205", # legacy codebase: no full runtime annotation coverage yet
"BLE001", # broad catch remains in boundary/proxy/error-handling paths "BLE001", # broad catch remains in boundary/proxy/error-handling paths
"C901", # legacy complexity; keep other correctness rules enabled "C901", # legacy complexity; keep other correctness rules enabled
"CPY", # copyright notices not wanted in this codebase
"D100", # legacy docs not yet standardized "D100", # legacy docs not yet standardized
"D101", # legacy docs not yet standardized "D101", # legacy docs not yet standardized
"D102", # legacy docs not yet standardized "D102", # legacy docs not yet standardized