Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f40d9c1abd | ||
|
|
3d8845cf99 | ||
|
|
87e1443e7d | ||
|
|
f45c57e901 |
+11
-2
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user