Compare commits

...
4 Commits
6 changed files with 91 additions and 23 deletions
+11 -2
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.""" """Create a framed startup box with server information."""
title = f"Cista {cista.__version__}" title = f"Cista {cista.__version__}"
listen = unix if unix else url listen = unix if unix else url
location = f"{folder} @ {listen}" location = f"{folder} @ {listen}"
lines = [title, location] lines = [title, location]
# Auth line: Paskia <url> or Password, with optional Public suffix
if paskia_url: 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: if dev:
lines.append("dev mode") lines.append("dev mode")
@@ -157,6 +165,7 @@ def _main():
unix=opts.get("unix"), unix=opts.get("unix"),
dev=dev, dev=dev,
paskia_url=PASKIA_BACKEND_URL or None, paskia_url=PASKIA_BACKEND_URL or None,
public=config.config.public,
) )
sys.stderr.write(startup_box) sys.stderr.write(startup_box)
# Run the server # Run the server
+6 -4
View File
@@ -166,10 +166,12 @@ def subscribe(uuid, ws):
@bp.get("config") @bp.get("config")
async def get_config(request): async def get_config(request):
await auth.verify(request, privileged=True) await auth.verify(request, privileged=True)
return json({ return json(
"name": config.config.name, {
"public": config.config.public, "name": config.config.name,
}) "public": config.config.public,
}
)
@bp.put("config/public") @bp.put("config/public")
+2 -2
View File
@@ -269,7 +269,7 @@ async def verify(request, *, privileged=False):
raise Unauthorized( raise Unauthorized(
f"Login required for {request.path}", f"Login required for {request.path}",
"cookie", "cookie",
context={"auth": {"iframe": "/auth/restricted"}}, context={"auth": {"iframe": "/auth/restricted/"}},
quiet=True, quiet=True,
) )
@@ -278,7 +278,7 @@ async def verify(request, *, privileged=False):
bp = Blueprint("auth", url_prefix="/auth") bp = Blueprint("auth", url_prefix="/auth")
@bp.get("/restricted") @bp.get("/restricted/")
async def login_page(request): async def login_page(request):
"""Login page that works both standalone and in paskia iframe.""" """Login page that works both standalone and in paskia iframe."""
s = session.get(request) s = session.get(request)
+69 -12
View File
@@ -2,7 +2,10 @@ import asyncio
import gc import gc
import io import io
import mimetypes import mimetypes
import threading
import urllib.parse import urllib.parse
from collections import OrderedDict
from dataclasses import dataclass
from pathlib import PurePosixPath from pathlib import PurePosixPath
from time import perf_counter from time import perf_counter
from urllib.parse import unquote from urllib.parse import unquote
@@ -25,6 +28,48 @@ pillow_heif.register_heif_opener()
bp = Blueprint("preview", url_prefix="/preview") 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 @bp.on_request
async def verify_preview(request): async def verify_preview(request):
"""Verify access to preview routes.""" """Verify access to preview routes."""
@@ -55,6 +100,29 @@ async def preview(req, path):
etag = config.derived_secret( etag = config.derived_secret(
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom "preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
).hex() ).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") savename = PurePosixPath(filepath.name).with_suffix(".avif")
headers = { headers = {
"etag": etag, "etag": etag,
@@ -64,19 +132,8 @@ async def preview(req, path):
"content-type": "image/avif", "content-type": "image/avif",
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}", "content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
} }
if req.headers.if_none_match == etag: _preview_cache.set(etag, CachedPreview(headers=headers, body=img))
# The client has it cached, respond 304 Not Modified
return empty(304, headers=headers)
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) return raw(img, headers=headers)
+2 -2
View File
@@ -157,8 +157,8 @@ const freeColor = computed(() => {
if (!s.disk) return '#6c6' if (!s.disk) return '#6c6'
const freePct = s.free / s.disk const freePct = s.free / s.disk
if (freePct > 0.25) return '#5b5' if (freePct > 0.25) return '#5b5'
if (freePct > 0.10) return '#db3' if (freePct > 0.10) return '#ff0'
return '#d44' return '#f00'
}) })
const PIE_RADIUS = 55 const PIE_RADIUS = 55
+1 -1
View File
@@ -115,7 +115,7 @@ const settingsMenu = (e: Event) => {
// Show login option only in public mode (non-public modes trigger auth automatically) // 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#theme=light') await showAuthIframe('/auth/restricted/#theme=light')
resumeWatching() resumeWatching()
} catch (e) { } catch (e) {
console.log('Login cancelled') console.log('Login cancelled')