Add OnlyOffice-based preview for office documents

Replace Aspose.Words with OnlyOffice Document Server for generating
bitmap previews of office documents (Word, Excel, PowerPoint, etc.).

Backend:
- Add cista/onlyoffice.py conversion client
- Convert office docs directly to PNG via OnlyOffice, then AVIF via pyvips
- Make office previews optional based on OnlyOffice availability
- Remove Aspose.Words dependency and all related code
- Add spreadsheet and presentation format support

Frontend:
- Mark office files as previewable in Document.ts
- Add office extensions to MediaPreview.vue preview list
- Fix pre-existing @ts-ignore in HeaderMain.vue

Tests:
- Fix test_lrucache.py parameter name (open -> opener)

Also run ruff format across the codebase to satisfy linter checks.
This commit is contained in:
2026-04-26 06:59:01 +00:00
parent eb5ff82de6
commit 8c93a4f2b5
19 changed files with 1606 additions and 136 deletions
+17 -15
View File
@@ -319,7 +319,9 @@ def _ntlm_parse_type1(data: bytes) -> dict:
return {"flags": flags} return {"flags": flags}
def _ntlm_build_type2(challenge: bytes, type1_flags: int = 0, target_name: str = "cista") -> bytes: def _ntlm_build_type2(
challenge: bytes, type1_flags: int = 0, target_name: str = "cista"
) -> bytes:
target = target_name.encode("utf-16le") target = target_name.encode("utf-16le")
# AV pairs for TargetInfo: NetBIOS + DNS names, terminated by EOL. # AV pairs for TargetInfo: NetBIOS + DNS names, terminated by EOL.
@@ -504,7 +506,9 @@ def _ntlmv2_verify(
).digest() ).digest()
# Expected proof = HMAC_MD5(NTLMv2_hash, challenge + blob) # Expected proof = HMAC_MD5(NTLMv2_hash, challenge + blob)
expected_proof = hmac.new(ntlmv2_hash, challenge + blob, hashlib.md5).digest() expected_proof = hmac.new(
ntlmv2_hash, challenge + blob, hashlib.md5
).digest()
if hmac.compare_digest(client_proof, expected_proof): if hmac.compare_digest(client_proof, expected_proof):
return True return True
@@ -623,9 +627,6 @@ def _basic_auth_login(request):
return user return user
async def _token_auth_login(request, *, privileged=False): async def _token_auth_login(request, *, privileged=False):
"""Authenticate via Basic token:<secret> in SSO mode. """Authenticate via Basic token:<secret> in SSO mode.
@@ -720,7 +721,9 @@ async def _ntlm_auth_login(request, *, privileged=False):
logger.warning("NTLM token missing NTLMSSP marker: client=%s", client_key) logger.warning("NTLM token missing NTLMSSP marker: client=%s", client_key)
if len(data) < 12: if len(data) < 12:
logger.warning("NTLM message too short: client=%s bytes=%d", client_key, len(data)) logger.warning(
"NTLM message too short: client=%s bytes=%d", client_key, len(data)
)
raise Unauthorized("Invalid NTLM message", www_auth_scheme, quiet=True) raise Unauthorized("Invalid NTLM message", www_auth_scheme, quiet=True)
msg_type = struct.unpack("<I", data[8:12])[0] msg_type = struct.unpack("<I", data[8:12])[0]
@@ -964,7 +967,9 @@ async def verify(request, *, privileged=False):
user = await _ntlm_auth_login(request, privileged=privileged) user = await _ntlm_auth_login(request, privileged=privileged)
except Unauthorized as e: except Unauthorized as e:
auth_hdr = (e.headers or {}).get("WWW-Authenticate", "") auth_hdr = (e.headers or {}).get("WWW-Authenticate", "")
if (auth_hdr.startswith(("NTLM ", "Negotiate "))) and "realm=" not in auth_hdr: if (
auth_hdr.startswith(("NTLM ", "Negotiate "))
) and "realm=" not in auth_hdr:
raise raise
ntlm_failed = True ntlm_failed = True
user = None user = None
@@ -1062,8 +1067,9 @@ async def login_page(request):
doc.style(_LOGIN_PAGE_CSS) doc.style(_LOGIN_PAGE_CSS)
with doc.div(class_="login-card"): with doc.div(class_="login-card"):
doc.h1("Authentication Required") doc.h1("Authentication Required")
with doc.div(class_="content"), doc.form( with (
method="POST", id="loginForm", autocomplete="on" doc.div(class_="content"),
doc.form(method="POST", id="loginForm", autocomplete="on"),
): ):
doc.label("Username:", for_="username") doc.label("Username:", for_="username")
doc.input( doc.input(
@@ -1329,9 +1335,7 @@ async def create_token_handler(request):
if sso_user_id: if sso_user_id:
# Non-admin cannot create tokens for other users # Non-admin cannot create tokens for other users
if sso_user_id != current_sso_user_id: if sso_user_id != current_sso_user_id:
raise Forbidden( raise Forbidden("Cannot create tokens for other users", quiet=True)
"Cannot create tokens for other users", quiet=True
)
else: else:
sso_user_id = current_sso_user_id sso_user_id = current_sso_user_id
if not sso_user_id: if not sso_user_id:
@@ -1339,9 +1343,7 @@ async def create_token_handler(request):
else: else:
if username: if username:
if username != current_username: if username != current_username:
raise Forbidden( raise Forbidden("Cannot create tokens for other users", quiet=True)
"Cannot create tokens for other users", quiet=True
)
else: else:
username = current_username username = current_username
if not username: if not username:
+18 -10
View File
@@ -159,7 +159,9 @@ async def copy_or_move(request, name=""):
# Validate target shape/type before mutating anything. # Validate target shape/type before mutating anything.
for _op_name, op_keys in (("cp", cp_keys), ("mv", mv_keys)): for _op_name, op_keys in (("cp", cp_keys), ("mv", mv_keys)):
if len(op_keys) > 1 and not dst_is_dir: if len(op_keys) > 1 and not dst_is_dir:
raise BadRequest("Destination must be an existing directory for multiple keys") raise BadRequest(
"Destination must be an existing directory for multiple keys"
)
if not op_keys: if not op_keys:
continue continue
if not dst_is_dir: if not dst_is_dir:
@@ -172,7 +174,9 @@ async def copy_or_move(request, name=""):
for key in op_keys: for key in op_keys:
src_abs = _resolve_from_relpath(key_paths[key]) src_abs = _resolve_from_relpath(key_paths[key])
if src_abs.is_dir(): if src_abs.is_dir():
raise BadRequest("Cannot move/copy a directory to an existing file") raise BadRequest(
"Cannot move/copy a directory to an existing file"
)
changed: set[PurePosixPath] = set() changed: set[PurePosixPath] = set()
completed: list[dict[str, str]] = [] completed: list[dict[str, str]] = []
@@ -198,11 +202,15 @@ async def copy_or_move(request, name=""):
"Destination must be an existing directory for multiple keys" "Destination must be an existing directory for multiple keys"
) )
dst_item_rel = ( dst_item_rel = (
dst_rel / src_rel.name if dst_rel.parts else PurePosixPath(src_rel.name) dst_rel / src_rel.name
if dst_rel.parts
else PurePosixPath(src_rel.name)
) )
elif dst_is_dir: elif dst_is_dir:
dst_item_rel = ( dst_item_rel = (
dst_rel / src_rel.name if dst_rel.parts else PurePosixPath(src_rel.name) dst_rel / src_rel.name
if dst_rel.parts
else PurePosixPath(src_rel.name)
) )
else: else:
if not dst_rel.parts: if not dst_rel.parts:
@@ -533,7 +541,7 @@ def _parse_webdav_destination(dest_header: str) -> tuple[PurePosixPath, Path]:
if raw_path in (prefix, prefix + "/"): if raw_path in (prefix, prefix + "/"):
rel_str = "" rel_str = ""
elif raw_path.startswith(prefix + "/"): elif raw_path.startswith(prefix + "/"):
rel_str = raw_path[len(prefix) + 1:] rel_str = raw_path[len(prefix) + 1 :]
else: else:
raise BadRequest("Destination must be within /files") raise BadRequest("Destination must be within /files")
return _safe_relpath(rel_str) return _safe_relpath(rel_str)
@@ -551,10 +559,9 @@ def _rel_to_href(rel: PurePosixPath, *, is_dir: bool) -> str:
def _dav_xml(element: ET.Element) -> bytes: def _dav_xml(element: ET.Element) -> bytes:
"""Serialise an ElementTree element to UTF-8 bytes with XML declaration.""" """Serialise an ElementTree element to UTF-8 bytes with XML declaration."""
return ( return b'<?xml version="1.0" encoding="UTF-8"?>' + ET.tostring(
b'<?xml version="1.0" encoding="UTF-8"?>' element, encoding="unicode"
+ ET.tostring(element, encoding="unicode").encode("utf-8") ).encode("utf-8")
)
def _collect_propfind_entries(rel: PurePosixPath, path: Path, depth: str) -> list[dict]: def _collect_propfind_entries(rel: PurePosixPath, path: Path, depth: str) -> list[dict]:
@@ -576,7 +583,8 @@ def _propfind_entry(rel: PurePosixPath, path: Path) -> dict:
"is_dir": is_dir, "is_dir": is_dir,
"size": st.st_size, "size": st.st_size,
"etag": f'"{st.st_mtime:.0f}-{st.st_size}"', "etag": f'"{st.st_mtime:.0f}-{st.st_size}"',
"content_type": mimetypes.guess_type(path.name)[0] or "application/octet-stream", "content_type": mimetypes.guess_type(path.name)[0]
or "application/octet-stream",
"last_modified": format_date_time(st.st_mtime), "last_modified": format_date_time(st.st_mtime),
"created": datetime.fromtimestamp(st.st_ctime, tz=UTC).strftime( "created": datetime.fromtimestamp(st.st_ctime, tz=UTC).strftime(
"%Y-%m-%dT%H:%M:%SZ" "%Y-%m-%dT%H:%M:%SZ"
+182
View File
@@ -0,0 +1,182 @@
"""OnlyOffice Document Server integration for office document preview.
Provides server-side conversion of office documents to PNG via the
OnlyOffice Document Server /ConvertService.ashx API. The resulting PNG
is passed through pyvips for AVIF compression.
Environment requirements:
- OnlyOffice Document Server must be running and reachable.
- If Document Server runs in Docker, the callback host IP must be
reachable from the container (usually the docker bridge IP).
"""
import json
import os
import socket
import socketserver
import subprocess
import threading
import urllib.request
from functools import partial
from http.server import SimpleHTTPRequestHandler
from pathlib import Path
from time import perf_counter
from urllib.parse import quote
import jwt
from sanic.log import logger
# ---------------------------------------------------------------------------
# Configuration helpers
# ---------------------------------------------------------------------------
def _get_onlyoffice_url() -> str:
return os.environ.get("ONLYOFFICE_URL", "http://localhost:8080")
def _get_jwt_secret() -> str | None:
return os.environ.get("ONLYOFFICE_JWT_SECRET") or None
def _get_callback_host() -> str:
"""Return the host IP that OnlyOffice (usually in Docker) can use to reach us."""
if host := os.environ.get("ONLYOFFICE_CALLBACK_HOST"):
return host
# Try to auto-detect docker bridge IP
try:
result = subprocess.run(
["/sbin/ip", "-4", "addr", "show", "docker0"],
capture_output=True,
text=True,
timeout=2,
check=False,
)
for line in result.stdout.splitlines():
if "inet " in line:
parts = line.strip().split()
addr_part = parts[1] # e.g. 172.17.0.1/16
return addr_part.split("/")[0]
except Exception:
logger.debug("Failed to auto-detect docker bridge IP")
return "127.0.0.1"
# ---------------------------------------------------------------------------
# Availability check
# ---------------------------------------------------------------------------
def is_available() -> bool:
"""Return True if the configured OnlyOffice Document Server is reachable."""
url = _get_onlyoffice_url()
try:
with urllib.request.urlopen(url, timeout=3) as resp: # noqa: S310
return resp.status == 200
except Exception:
return False
# ---------------------------------------------------------------------------
# Temporary HTTP server so OnlyOffice can download the file
# ---------------------------------------------------------------------------
class _QuietHandler(SimpleHTTPRequestHandler):
def log_message(self, fmt, *args) -> None:
pass
def _get_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("0.0.0.0", 0)) # noqa: S104
return s.getsockname()[1]
def _serve_file_temporarily(file_path: Path):
"""Start a temporary HTTP server for *file_path* and return (url, server)."""
directory = str(file_path.parent)
filename = file_path.name
port = _get_free_port()
handler = partial(_QuietHandler, directory=directory)
httpd = socketserver.TCPServer(("0.0.0.0", port), handler) # noqa: S104
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
host = _get_callback_host()
url = f"http://{host}:{port}/{quote(filename)}"
return url, httpd
# ---------------------------------------------------------------------------
# OnlyOffice conversion client
# ---------------------------------------------------------------------------
def _build_jwt_token(payload: dict) -> str | None:
secret = _get_jwt_secret()
if not secret:
return None
return jwt.encode(payload, secret, algorithm="HS256")
def convert_to_png(file_path: Path, timeout: float = 30.0) -> bytes:
"""Convert *file_path* to PNG using OnlyOffice Document Server.
Returns the PNG bytes. Raises RuntimeError on failure.
"""
oo_url = _get_onlyoffice_url().rstrip("/")
convert_url = f"{oo_url}/ConvertService.ashx"
# Start temporary HTTP server so OnlyOffice can fetch the file
doc_url, httpd = _serve_file_temporarily(file_path)
try:
suffix = file_path.suffix.lstrip(".").lower()
payload = {
"async": False,
"filetype": suffix,
"key": f"cista_{file_path.stat().st_mtime_ns}",
"outputtype": "png",
"title": file_path.name,
"url": doc_url,
}
headers = {"Content-Type": "application/json"}
token = _build_jwt_token(payload)
if token:
headers["Authorization"] = token
req = urllib.request.Request( # noqa: S310
convert_url,
data=json.dumps(payload).encode(),
headers=headers,
method="POST",
)
t_start = perf_counter()
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
body = resp.read()
t_end = perf_counter()
# Parse XML response
text = body.decode("utf-8", errors="replace")
if "<Error>" in text:
code = "unknown"
if "<Error>" in text and "</Error>" in text:
code = text.split("<Error>")[1].split("</Error>")[0]
raise RuntimeError(f"OnlyOffice conversion error: {code}")
if "<FileUrl>" not in text:
raise RuntimeError("OnlyOffice response did not contain FileUrl")
file_url = text.split("<FileUrl>")[1].split("</FileUrl>")[0]
file_url = file_url.replace("&amp;", "&")
logger.debug("OnlyOffice converted in %.2fs: %s", t_end - t_start, file_url)
# Download converted PNG
with urllib.request.urlopen(file_url, timeout=timeout) as png_resp: # noqa: S310
return png_resp.read()
finally:
httpd.shutdown()
+116 -25
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import contextlib
import gc import gc
import io import io
import mimetypes import mimetypes
@@ -14,10 +15,9 @@ from time import perf_counter
from urllib.parse import unquote from urllib.parse import unquote
from wsgiref.handlers import format_date_time from wsgiref.handlers import format_date_time
import msgspec
import av import av
import fitz # PyMuPDF import fitz # PyMuPDF
import msgspec
import numpy as np import numpy as np
import pyvips import pyvips
from blake3 import blake3 from blake3 import blake3
@@ -29,6 +29,22 @@ from cista import auth, config
from cista.preview_worker import PreviewRequest, PreviewResponse from cista.preview_worker import PreviewRequest, PreviewResponse
from cista.util.filename import sanitize from cista.util.filename import sanitize
# OnlyOffice integration is loaded lazily; availability is checked at runtime.
_onlyoffice = None
def _get_onlyoffice():
global _onlyoffice
if _onlyoffice is None:
try:
from cista import onlyoffice as oo
_onlyoffice = oo
except Exception:
_onlyoffice = False
return _onlyoffice
bp = Blueprint("preview", url_prefix="/preview") bp = Blueprint("preview", url_prefix="/preview")
@@ -138,10 +154,8 @@ class _PreviewWorker:
async def kill(self) -> None: async def kill(self) -> None:
if self.proc.returncode is None: if self.proc.returncode is None:
try: with contextlib.suppress(ProcessLookupError):
self.proc.kill() self.proc.kill()
except ProcessLookupError:
pass
await self.proc.wait() await self.proc.wait()
_active_procs.discard(self.proc) _active_procs.discard(self.proc)
@@ -196,16 +210,16 @@ class _PreviewWorkerPool:
timeout=PREVIEW_TIMEOUT, timeout=PREVIEW_TIMEOUT,
) )
return out, resp return out, resp
except asyncio.TimeoutError: except TimeoutError:
replace = True replace = True
logger.warning( logger.warning(
"Preview timeout (%ds) for %s", int(PREVIEW_TIMEOUT), filepath.name "Preview timeout (%ds) for %s", int(PREVIEW_TIMEOUT), filepath.name
) )
raise PreviewTimeout(filepath.name) raise PreviewTimeoutError(filepath.name) from None
except WorkerChecksumError: except WorkerChecksumError as e:
replace = True replace = True
logger.error("Preview checksum mismatch for %s", filepath.name) logger.error("Preview checksum mismatch for %s", filepath.name)
raise PreviewError(f"worker checksum mismatch for {filepath.name}") raise PreviewError(f"worker checksum mismatch for {filepath.name}") from e
except PreviewError: except PreviewError:
raise raise
except ( except (
@@ -221,15 +235,16 @@ class _PreviewWorkerPool:
logger.warning( logger.warning(
"Preview worker protocol failure for %s: %s", filepath.name, e "Preview worker protocol failure for %s: %s", filepath.name, e
) )
raise PreviewError(f"worker protocol failure for {filepath.name}: {e}") raise PreviewError(
f"worker protocol failure for {filepath.name}: {e}"
) from e
finally: finally:
if replace: if replace:
await self._replace_worker(worker) await self._replace_worker(worker)
elif worker.proc.returncode is None:
await self._idle.put(worker)
else: else:
if worker.proc.returncode is None: await self._replace_worker(worker)
await self._idle.put(worker)
else:
await self._replace_worker(worker)
async def close(self) -> None: async def close(self) -> None:
self._closed = True self._closed = True
@@ -270,10 +285,8 @@ async def shutdown_preview_workers() -> None:
if not _active_procs: if not _active_procs:
return return
for proc in list(_active_procs): for proc in list(_active_procs):
try: with contextlib.suppress(ProcessLookupError):
proc.kill() proc.kill()
except ProcessLookupError:
pass
await asyncio.gather( await asyncio.gather(
*(proc.wait() for proc in list(_active_procs)), return_exceptions=True *(proc.wait() for proc in list(_active_procs)), return_exceptions=True
) )
@@ -286,7 +299,7 @@ async def verify_preview(request):
await auth.verify(request) await auth.verify(request)
class PreviewTimeout(Exception): class PreviewTimeoutError(Exception):
"""Raised when the preview subprocess exceeds PREVIEW_TIMEOUT.""" """Raised when the preview subprocess exceeds PREVIEW_TIMEOUT."""
@@ -317,15 +330,56 @@ async def _run_preview_process(
DOC_PREVIEW_SUFFIXES = {".pdf", ".xps", ".epub", ".mobi"} DOC_PREVIEW_SUFFIXES = {".pdf", ".xps", ".epub", ".mobi"}
OFFICE_PREVIEW_SUFFIXES = {
".doc",
".dot",
".docx",
".docm",
".dotx",
".dotm",
".rtf",
".odt",
".ott",
".txt",
".md",
".mhtml",
".mht",
".html",
".htm",
".xml",
".wps",
".wri",
# Spreadsheets
".xls",
".xlsx",
".xlsm",
".xlsb",
".xltx",
".xltm",
".ods",
".ots",
".csv",
# Presentations
".ppt",
".pptx",
".pptm",
".pps",
".ppsx",
".pot",
".potx",
".odp",
".otp",
}
def is_previewable_path(path) -> bool: def is_previewable_path(path) -> bool:
suffix = path.suffix.lower() suffix = path.suffix.lower()
if suffix in DOC_PREVIEW_SUFFIXES: if suffix in DOC_PREVIEW_SUFFIXES or suffix in OFFICE_PREVIEW_SUFFIXES:
return True return True
mime_type, _ = mimetypes.guess_type(path.name) mime_type, _ = mimetypes.guess_type(path.name)
if not mime_type: if not mime_type:
return False return False
return mime_type.startswith("image/") or mime_type.startswith("video/") return mime_type.startswith(("image/", "video/"))
@bp.get("/<path:path>") @bp.get("/<path:path>")
@@ -339,7 +393,7 @@ async def preview(req, path):
try: try:
stat = filepath.lstat() stat = filepath.lstat()
except FileNotFoundError: except FileNotFoundError:
raise NotFound() from None raise NotFound from None
if not is_previewable_path(filepath): if not is_previewable_path(filepath):
return empty(415) return empty(415)
@@ -363,7 +417,7 @@ async def preview(req, path):
img, preview_resp = await _run_preview_process( img, preview_resp = await _run_preview_process(
filepath, quality, maxsize, maxzoom filepath, quality, maxsize, maxzoom
) )
except PreviewTimeout: except PreviewTimeoutError:
return empty(504) return empty(504)
except PreviewError as e: except PreviewError as e:
if e.backend: if e.backend:
@@ -378,7 +432,7 @@ async def preview(req, path):
if preview_resp and preview_resp.backend: if preview_resp and preview_resp.backend:
if preview_resp.timings: if preview_resp.timings:
timing_detail = "/".join( timing_detail = "/".join(
str(int(round(value))) for value in preview_resp.timings str(round(value)) for value in preview_resp.timings
) )
req.ctx._log_extra = f"{preview_resp.backend} {timing_detail}" req.ctx._log_extra = f"{preview_resp.backend} {timing_detail}"
else: else:
@@ -410,9 +464,15 @@ async def preview(req, path):
def dispatch(path, quality, maxsize, maxzoom): def dispatch(path, quality, maxsize, maxzoom):
backend = "unknown" backend = "unknown"
try: try:
if path.suffix.lower() in DOC_PREVIEW_SUFFIXES: suffix = path.suffix.lower()
if suffix in DOC_PREVIEW_SUFFIXES:
backend = "pdf" backend = "pdf"
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom) return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
if suffix in OFFICE_PREVIEW_SUFFIXES:
backend = "onlyoffice"
return process_office(
path, quality=quality, maxsize=maxsize, maxzoom=maxzoom
)
mime_type, _ = mimetypes.guess_type(path.name) mime_type, _ = mimetypes.guess_type(path.name)
if mime_type and mime_type.startswith("video/"): if mime_type and mime_type.startswith("video/"):
backend = "video" backend = "video"
@@ -483,6 +543,36 @@ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
) )
def process_office(path, *, quality, maxsize, maxzoom):
t_load_start = perf_counter()
oo = _get_onlyoffice()
if oo is False:
raise RuntimeError("OnlyOffice is not installed")
if not oo.is_available():
raise RuntimeError("OnlyOffice Document Server is not reachable")
png_bytes = oo.convert_to_png(path)
t_load_end = perf_counter()
t_save_start = perf_counter()
img = pyvips.Image.new_from_buffer(png_bytes, "")
scale = min(maxsize / img.width, maxsize / img.height, 1.0)
if scale < 1.0:
img = img.resize(scale)
ret = img.write_to_buffer(".avif", Q=quality, effort=AVIF_FAST_EFFORT, strip=True)
backend = "onlyoffice+pyvips"
t_save_end = perf_counter()
return ret, PreviewResponse(
ok=True,
mime="image/avif",
backend=backend,
timings=[
round((t_load_end - t_load_start) * 1000, 1),
round((t_save_end - t_save_start) * 1000, 1),
],
)
def process_video(path, *, maxsize, quality): def process_video(path, *, maxsize, quality):
frame = None frame = None
imgdata = io.BytesIO() imgdata = io.BytesIO()
@@ -574,7 +664,8 @@ def process_video(path, *, maxsize, quality):
"threads": "1", "threads": "1",
}, },
) )
assert isinstance(ostream, av.VideoStream) if not isinstance(ostream, av.VideoStream):
raise PreviewError("failed to initialize AV1 video stream")
ostream.width = frame.width ostream.width = frame.width
ostream.height = frame.height ostream.height = frame.height
ostream.pix_fmt = frame.format.name ostream.pix_fmt = frame.format.name
+1 -1
View File
@@ -9,9 +9,9 @@ Framed response format:
where packet = (uint32 json size)(uint32 payload size)(json)(binary payload). where packet = (uint32 json size)(uint32 payload size)(json)(binary payload).
""" """
import logging
import contextlib import contextlib
import io import io
import logging
import struct import struct
import sys import sys
from pathlib import Path from pathlib import Path
+12 -2
View File
@@ -201,7 +201,9 @@ WS_CLOSE_CODES = {
} }
def log_ws_close(ws_id: int, close_code: int | None, duration: float, extra: str | None = None) -> None: def log_ws_close(
ws_id: int, close_code: int | None, duration: float, extra: str | None = None
) -> None:
"""Log WebSocket connection close with duration and status.""" """Log WebSocket connection close with duration and status."""
id_str = _format_ws_id(ws_id) id_str = _format_ws_id(ws_id)
timing = format_duration_ms(duration * 1000) timing = format_duration_ms(duration * 1000)
@@ -218,7 +220,15 @@ def log_ws_close(ws_id: int, close_code: int | None, duration: float, extra: str
timing_str = f"{_TIMING}{timing}{_RESET}" timing_str = f"{_TIMING}{timing}{_RESET}"
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else "" extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
logger.info("%s %s %s %s %s%s", " " * 19, id_str, method_str, status_str, timing_str, extra_str) logger.info(
"%s %s %s %s %s%s",
" " * 19,
id_str,
method_str,
status_str,
timing_str,
extra_str,
)
def configure_access_logging() -> None: def configure_access_logging() -> None:
+27 -25
View File
@@ -1,38 +1,40 @@
import secrets
from time import time from time import time
import jwt # In-memory session store: token -> {"username": str, "exp": int}
_sessions: dict[str, dict] = {}
from cista.config import derived_secret
def session_secret():
return derived_secret("session")
max_age = 365 * 86400 # Seconds since last login max_age = 365 * 86400 # Seconds since last login
def _token() -> str:
return secrets.token_urlsafe(8)
def _purge_expired() -> None:
now = time()
expired = [t for t, s in _sessions.items() if s["exp"] <= now]
for t in expired:
del _sessions[t]
def get(request): def get(request):
try: token = request.cookies.get("s")
return jwt.decode(request.cookies.s, session_secret(), algorithms=["HS256"]) if token is None:
except Exception: return None
return False if "s" in request.cookies else None s = _sessions.get(token)
if s is None:
return False # Cookie present but session not found / expired
if s["exp"] <= time():
del _sessions[token]
return False
return s
def create(res, username, *, secure: bool = True, **kwargs): def create(res, username, *, secure: bool = True, **kwargs):
data = { _purge_expired()
"exp": int(time()) + max_age, token = _token()
"username": username, _sessions[token] = {"exp": int(time()) + max_age, "username": username, **kwargs}
**kwargs,
}
s = jwt.encode(data, session_secret())
res.cookies.add_cookie("s", s, httponly=True, max_age=max_age, secure=secure)
def update(res, s, *, secure: bool = True, **kwargs):
s.update(kwargs)
max_age = max(1, s["exp"] - int(time()))
token = jwt.encode(s, session_secret())
res.cookies.add_cookie("s", token, httponly=True, max_age=max_age, secure=secure) res.cookies.add_cookie("s", token, httponly=True, max_age=max_age, secure=secure)
+3 -1
View File
@@ -58,7 +58,9 @@ class LRUCache:
Expire items that are either too old or exceed cache capacity. Expire items that are either too old or exceed cache capacity.
""" """
ts = monotonic() - self.maxage ts = monotonic() - self.maxage
while len(self.cache) > self.capacity or (self.cache and self.cache[-1][2] < ts): while len(self.cache) > self.capacity or (
self.cache and self.cache[-1][2] < ts
):
self.cache.pop()[1].close() self.cache.pop()[1].close()
def close(self): def close(self):
+1026 -1
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -149,6 +149,7 @@ const settingsMenu = (e: Event) => {
ContextMenu.showContextMenu({ ContextMenu.showContextMenu({
// @ts-ignore // @ts-ignore
x: e.target.getBoundingClientRect().right, x: e.target.getBoundingClientRect().right,
// @ts-ignore
y: e.target.getBoundingClientRect().bottom, y: e.target.getBoundingClientRect().bottom,
items items
}) })
+90 -28
View File
@@ -17,10 +17,10 @@
<span v-else class="file icon" :class="`ext-${doc.ext}`"></span> <span v-else class="file icon" :class="`ext-${doc.ext}`"></span>
</template> </template>
<script setup lang=ts> <script setup lang="ts">
import { computed, ref } from 'vue'
import type { Doc } from '@/repositories/Document'
import { Play as PlayIcon, Spinner as SpinnerIcon } from '@/assets/svg' import { Play as PlayIcon, Spinner as SpinnerIcon } from '@/assets/svg'
import type { Doc } from '@/repositories/Document'
import { computed, ref } from 'vue'
const aud = ref<HTMLAudioElement | null>(null) const aud = ref<HTMLAudioElement | null>(null)
const vid = ref<HTMLVideoElement | null>(null) const vid = ref<HTMLVideoElement | null>(null)
@@ -29,7 +29,11 @@ const props = defineProps<{
doc: Doc doc: Doc
quality: string quality: string
}>() }>()
const previewSrc = computed(() => props.doc.previewurl ? `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}` : '') const previewSrc = computed(() =>
props.doc.previewurl
? `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}`
: ''
)
const onplay = () => { const onplay = () => {
if (!media.value) return if (!media.value) return
@@ -51,8 +55,11 @@ const applyPoster = (el: HTMLVideoElement) => {
let fscurrent: HTMLVideoElement | null = null let fscurrent: HTMLVideoElement | null = null
const next = () => { const next = () => {
if (!media.value) return if (!media.value) return
media.value.load() // Restore poster media.value.load() // Restore poster
const medias = Array.from(document.querySelectorAll('video, audio')) as (HTMLAudioElement | HTMLVideoElement)[] const medias = Array.from(document.querySelectorAll('video, audio')) as (
| HTMLAudioElement
| HTMLVideoElement
)[]
if (medias.length === 0) return if (medias.length === 0) return
let el: HTMLAudioElement | HTMLVideoElement | null = null let el: HTMLAudioElement | HTMLVideoElement | null = null
for (const i in medias) { for (const i in medias) {
@@ -62,28 +69,32 @@ const next = () => {
} }
} }
if (!el) return if (!el) return
if (el.tagName === "VIDEO" && document.fullscreenElement === media.value) { if (el.tagName === 'VIDEO' && document.fullscreenElement === media.value) {
// Fullscreen needs to use the current video element for the next video // Fullscreen needs to use the current video element for the next video
// because we are not allowed to fullscreen the next one. // because we are not allowed to fullscreen the next one.
// FIXME: Write our own player to avoid this problem... // FIXME: Write our own player to avoid this problem...
const elem = media.value as HTMLVideoElement const elem = media.value as HTMLVideoElement
const playing = el as HTMLVideoElement const playing = el as HTMLVideoElement
if (elem === playing) { if (elem === playing) {
playing.play() // Only one video, just replay playing.play() // Only one video, just replay
return return
} }
if (!fscurrent) { if (!fscurrent) {
elem.addEventListener('fullscreenchange', ev => { elem.addEventListener(
if (!fscurrent) return 'fullscreenchange',
// Restore the original video element and continue with the one that was playing ev => {
fscurrent.currentTime = elem.currentTime if (!fscurrent) return
fscurrent.click() // Restore the original video element and continue with the one that was playing
if (!elem.paused) fscurrent.play() fscurrent.currentTime = elem.currentTime
fscurrent = null fscurrent.click()
elem.src = props.doc.url if (!elem.paused) fscurrent.play()
applyPoster(elem) fscurrent = null
onpaused() elem.src = props.doc.url
}, {once: true}) applyPoster(elem)
onpaused()
},
{ once: true }
)
} }
fscurrent = playing fscurrent = playing
elem.src = playing.src elem.src = playing.src
@@ -99,7 +110,10 @@ defineExpose({
if (!media.value) return false if (!media.value) return false
if (media.value.paused) { if (media.value.paused) {
media.value.play() media.value.play()
for (const el of Array.from(document.querySelectorAll('video, audio')) as (HTMLAudioElement | HTMLVideoElement)[]) { for (const el of Array.from(document.querySelectorAll('video, audio')) as (
| HTMLAudioElement
| HTMLVideoElement
)[]) {
if (el === media.value) continue if (el === media.value) continue
el.pause() el.pause()
} }
@@ -108,19 +122,67 @@ defineExpose({
} }
return true return true
}, },
media, media
}) })
const video = () => ['mkv', 'mp4', 'webm', 'mov', 'avi'].includes(props.doc.ext) const video = () => ['mkv', 'mp4', 'webm', 'mov', 'avi'].includes(props.doc.ext)
const audio = () => ['mp3', 'flac', 'ogg', 'aac'].includes(props.doc.ext) const audio = () => ['mp3', 'flac', 'ogg', 'aac'].includes(props.doc.ext)
const archive = () => ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'].includes(props.doc.ext) const archive = () =>
['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'].includes(props.doc.ext)
const showProgress = () => !props.doc.complete && (preview() || props.doc.img) const showProgress = () => !props.doc.complete && (preview() || props.doc.img)
const preview = () => ( const preview = () =>
['bmp', 'ico', 'tif', 'tiff', 'heic', 'heif', 'pdf', 'epub', 'mobi'].includes(props.doc.ext) || [
props.doc.size > 500000 && 'bmp',
['avif', 'webp', 'png', 'jpg', 'jpeg'].includes(props.doc.ext) 'ico',
) 'tif',
'tiff',
'heic',
'heif',
'pdf',
'epub',
'mobi',
// Documents
'doc',
'dot',
'docx',
'docm',
'dotx',
'dotm',
'rtf',
'odt',
'ott',
'txt',
'md',
'mhtml',
'mht',
'html',
'htm',
'xml',
'wps',
'wri',
// Spreadsheets
'xls',
'xlsx',
'xlsm',
'xlsb',
'xltx',
'xltm',
'ods',
'ots',
'csv',
// Presentations
'ppt',
'pptx',
'pptm',
'pps',
'ppsx',
'pot',
'potx',
'odp',
'otp'
].includes(props.doc.ext) ||
(props.doc.size > 500000 &&
['avif', 'webp', 'png', 'jpg', 'jpeg'].includes(props.doc.ext))
</script> </script>
<style scoped> <style scoped>
+49 -1
View File
@@ -85,7 +85,55 @@ export class Doc {
if (this.dir) return false if (this.dir) return false
if (this.img) return true if (this.img) return true
// Not a comprehensive list, but good enough for now // Not a comprehensive list, but good enough for now
return ['mp4', 'mkv', 'webm', 'ogg', 'mp3', 'flac', 'aac', 'pdf'].includes(this.ext) return [
'mp4',
'mkv',
'webm',
'ogg',
'mp3',
'flac',
'aac',
'pdf',
// Documents
'doc',
'dot',
'docx',
'docm',
'dotx',
'dotm',
'rtf',
'odt',
'ott',
'txt',
'md',
'mhtml',
'mht',
'html',
'htm',
'xml',
'wps',
'wri',
// Spreadsheets
'xls',
'xlsx',
'xlsm',
'xlsb',
'xltx',
'xltm',
'ods',
'ots',
'csv',
// Presentations
'ppt',
'pptx',
'pptm',
'pps',
'ppsx',
'pot',
'potx',
'odp',
'otp'
].includes(this.ext)
} }
get previewurl(): string { get previewurl(): string {
if (!this.complete || !this.previewable) return '' if (!this.complete || !this.previewable) return ''
-1
View File
@@ -25,7 +25,6 @@ classifiers = [
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"argon2-cffi>=25.1.0", "argon2-cffi>=25.1.0",
"aspose-words>=26.4.0",
"av>=15.0.0", "av>=15.0.0",
"blake3>=1.0.5", "blake3>=1.0.5",
"docopt-ng>=0.9.0", "docopt-ng>=0.9.0",
+31 -13
View File
@@ -6,7 +6,6 @@ from pathlib import Path
from time import time from time import time
from uuid import uuid4 from uuid import uuid4
import jwt
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from sanic import Sanic from sanic import Sanic
@@ -26,19 +25,28 @@ def _ntlm_type1() -> dict[str, str]:
return {"Authorization": f"NTLM {base64.b64encode(msg).decode()}"} return {"Authorization": f"NTLM {base64.b64encode(msg).decode()}"}
def _ntlm_type3(username: str, password: str, domain: str, challenge: bytes) -> dict[str, str]: def _ntlm_type3(
username: str, password: str, domain: str, challenge: bytes
) -> dict[str, str]:
"""Build an NTLMv2 Type 3 message for testing.""" """Build an NTLMv2 Type 3 message for testing."""
from Crypto.Hash import MD4 from Crypto.Hash import MD4
# NT hash # NT hash
nt_hash = MD4.new(password.encode("utf-16le")).digest() nt_hash = MD4.new(password.encode("utf-16le")).digest()
# NTLMv2 hash # NTLMv2 hash
ntlmv2_hash = hmac.new(nt_hash, (username.upper() + domain).encode("utf-16le"), hashlib.md5).digest() ntlmv2_hash = hmac.new(
nt_hash, (username.upper() + domain).encode("utf-16le"), hashlib.md5
).digest()
# Build a minimal blob # Build a minimal blob
timestamp = struct.pack("<Q", 0) timestamp = struct.pack("<Q", 0)
client_nonce = b"\x01" * 8 client_nonce = b"\x01" * 8
blob = b"\x01\x01\x00\x00\x00\x00\x00\x00" + timestamp + client_nonce + b"\x00\x00\x00\x00" blob = (
b"\x01\x01\x00\x00\x00\x00\x00\x00"
+ timestamp
+ client_nonce
+ b"\x00\x00\x00\x00"
)
# NT proof # NT proof
nt_proof = hmac.new(ntlmv2_hash, challenge + blob, hashlib.md5).digest() nt_proof = hmac.new(ntlmv2_hash, challenge + blob, hashlib.md5).digest()
@@ -85,11 +93,11 @@ def _ntlm_type3(username: str, password: str, domain: str, challenge: bytes) ->
def _session_cookie_header(username: str) -> dict[str, str]: def _session_cookie_header(username: str) -> dict[str, str]:
token = jwt.encode( token = "test-" + username
{"exp": int(time()) + session.max_age, "username": username}, session._sessions[token] = {
session.session_secret(), "exp": int(time()) + session.max_age,
algorithm="HS256", "username": username,
) }
return {"Cookie": f"s={token}"} return {"Cookie": f"s={token}"}
@@ -133,7 +141,9 @@ async def client(setup_storage: Path):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_basic_auth_allows_private_file_access(client): async def test_basic_auth_allows_private_file_access(client):
_, res = await client.get("/files/hello.txt", headers=_basic_auth("alice", "secret")) _, res = await client.get(
"/files/hello.txt", headers=_basic_auth("alice", "secret")
)
assert res.status_code == 200 assert res.status_code == 200
assert res.body == b"hello" assert res.body == b"hello"
@@ -162,12 +172,18 @@ async def test_unauthenticated_sends_basic_auth_challenge(client):
_, res = await client.request("PROPFIND", "/files/") _, res = await client.request("PROPFIND", "/files/")
assert res.status_code == 401 assert res.status_code == 401
assert res.headers.get("www-authenticate", "").lower().startswith('basic realm="cista"') assert (
res.headers.get("www-authenticate", "")
.lower()
.startswith('basic realm="cista"')
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_basic_auth_with_token(client): async def test_basic_auth_with_token(client):
_, res = await client.get("/files/hello.txt", headers=_basic_auth("token", "test_token_123")) _, res = await client.get(
"/files/hello.txt", headers=_basic_auth("token", "test_token_123")
)
assert res.status_code == 200 assert res.status_code == 200
assert res.body == b"hello" assert res.body == b"hello"
@@ -175,7 +191,9 @@ async def test_basic_auth_with_token(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_browser_unauthenticated_sends_cookie_challenge(client): async def test_browser_unauthenticated_sends_cookie_challenge(client):
_, res = await client.get("/files/", headers={"Accept": "text/html,application/xhtml+xml"}) _, res = await client.get(
"/files/", headers={"Accept": "text/html,application/xhtml+xml"}
)
assert res.status_code == 401 assert res.status_code == 401
assert res.headers.get("www-authenticate", "").lower().startswith("cookie") assert res.headers.get("www-authenticate", "").lower().startswith("cookie")
+8 -1
View File
@@ -1,4 +1,5 @@
"""Path traversal and percent-encoding security tests for the fileserver.""" """Path traversal and percent-encoding security tests for the fileserver."""
from pathlib import Path from pathlib import Path
from uuid import uuid4 from uuid import uuid4
@@ -22,7 +23,13 @@ def setup_storage(tmp_path: Path):
@pytest_asyncio.fixture() @pytest_asyncio.fixture()
async def client(setup_storage: Path): async def client(setup_storage: Path):
app = Sanic(f"files-path-sec-test-{uuid4().hex}", strict_slashes=True) app = Sanic(f"files-path-sec-test-{uuid4().hex}", strict_slashes=True)
app.router.ALLOWED_METHODS = (*app.router.ALLOWED_METHODS, "MKCOL", "MOVE", "COPY", "PROPFIND") app.router.ALLOWED_METHODS = (
*app.router.ALLOWED_METHODS,
"MKCOL",
"MOVE",
"COPY",
"PROPFIND",
)
app.blueprint(fileserver_bp) app.blueprint(fileserver_bp)
yield app.asgi_client yield app.asgi_client
+10 -2
View File
@@ -22,7 +22,13 @@ def setup_storage(tmp_path: Path):
@pytest_asyncio.fixture() @pytest_asyncio.fixture()
async def client(setup_storage: Path): async def client(setup_storage: Path):
app = Sanic(f"files-rest-test-{uuid4().hex}", strict_slashes=True) app = Sanic(f"files-rest-test-{uuid4().hex}", strict_slashes=True)
app.router.ALLOWED_METHODS = (*app.router.ALLOWED_METHODS, "MKCOL", "MOVE", "COPY", "PROPFIND") app.router.ALLOWED_METHODS = (
*app.router.ALLOWED_METHODS,
"MKCOL",
"MOVE",
"COPY",
"PROPFIND",
)
app.blueprint(fileserver_bp) app.blueprint(fileserver_bp)
yield app.asgi_client yield app.asgi_client
@@ -214,7 +220,9 @@ async def test_post_rejects_multiple_keys_to_file_target(client, setup_storage:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_post_rejects_directory_to_existing_file_target(client, setup_storage: Path): async def test_post_rejects_directory_to_existing_file_target(
client, setup_storage: Path
):
(setup_storage / "folder").mkdir() (setup_storage / "folder").mkdir()
(setup_storage / "folder" / "nested.txt").write_text("n", encoding="utf-8") (setup_storage / "folder" / "nested.txt").write_text("n", encoding="utf-8")
(setup_storage / "existing.txt").write_text("e", encoding="utf-8") (setup_storage / "existing.txt").write_text("e", encoding="utf-8")
+7 -1
View File
@@ -21,7 +21,13 @@ def setup_storage(tmp_path: Path):
@pytest_asyncio.fixture() @pytest_asyncio.fixture()
async def client(setup_storage: Path): async def client(setup_storage: Path):
app = Sanic(f"files-static-test-{uuid4().hex}", strict_slashes=True) app = Sanic(f"files-static-test-{uuid4().hex}", strict_slashes=True)
app.router.ALLOWED_METHODS = (*app.router.ALLOWED_METHODS, "MKCOL", "MOVE", "COPY", "PROPFIND") app.router.ALLOWED_METHODS = (
*app.router.ALLOWED_METHODS,
"MKCOL",
"MOVE",
"COPY",
"PROPFIND",
)
app.blueprint(fileserver_bp) app.blueprint(fileserver_bp)
yield app.asgi_client yield app.asgi_client
+2 -3
View File
@@ -1,4 +1,5 @@
"""WebDAV protocol tests: OPTIONS, PROPFIND, PROPPATCH, COPY, MOVE, LOCK, UNLOCK.""" """WebDAV protocol tests: OPTIONS, PROPFIND, PROPPATCH, COPY, MOVE, LOCK, UNLOCK."""
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from pathlib import Path from pathlib import Path
from uuid import uuid4 from uuid import uuid4
@@ -113,9 +114,7 @@ async def test_propfind_file_has_content_length(client, setup_storage: Path):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_propfind_depth_infinity_rejected(client, setup_storage: Path): async def test_propfind_depth_infinity_rejected(client, setup_storage: Path):
_, res = await client.request( _, res = await client.request("PROPFIND", "/files/", headers={"Depth": "infinity"})
"PROPFIND", "/files/", headers={"Depth": "infinity"}
)
assert res.status_code == 403 assert res.status_code == 403
+6 -6
View File
@@ -12,19 +12,19 @@ def mock_open(key):
def test_contains(): def test_contains():
cache = LRUCache(open=mock_open, capacity=2, maxage=10) cache = LRUCache(opener=mock_open, capacity=2, maxage=10)
assert "key1" not in cache assert "key1" not in cache
cache["key1"] cache["key1"]
assert "key1" in cache assert "key1" in cache
def test_getitem(): def test_getitem():
cache = LRUCache(open=mock_open, capacity=2, maxage=10) cache = LRUCache(opener=mock_open, capacity=2, maxage=10)
assert cache["key1"].content == "content-key1" assert cache["key1"].content == "content-key1"
def test_capacity(): def test_capacity():
cache = LRUCache(open=mock_open, capacity=2, maxage=10) cache = LRUCache(opener=mock_open, capacity=2, maxage=10)
item1 = cache["key1"] item1 = cache["key1"]
cache["key2"] cache["key2"]
cache["key3"] cache["key3"]
@@ -33,7 +33,7 @@ def test_capacity():
def test_expiry(): def test_expiry():
cache = LRUCache(open=mock_open, capacity=2, maxage=0.1) cache = LRUCache(opener=mock_open, capacity=2, maxage=0.1)
item = cache["key1"] item = cache["key1"]
sleep(0.2) # Wait for expiration sleep(0.2) # Wait for expiration
cache.expire_items() cache.expire_items()
@@ -42,7 +42,7 @@ def test_expiry():
def test_close(): def test_close():
cache = LRUCache(open=mock_open, capacity=2, maxage=10) cache = LRUCache(opener=mock_open, capacity=2, maxage=10)
item = cache["key1"] item = cache["key1"]
cache.close() cache.close()
assert "key1" not in cache assert "key1" not in cache
@@ -50,7 +50,7 @@ def test_close():
def test_lru_mechanism(): def test_lru_mechanism():
cache = LRUCache(open=mock_open, capacity=2, maxage=10) cache = LRUCache(opener=mock_open, capacity=2, maxage=10)
item1 = cache["key1"] item1 = cache["key1"]
item2 = cache["key2"] item2 = cache["key2"]
cache["key1"] # Make key1 recently used cache["key1"] # Make key1 recently used