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}
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")
# AV pairs for TargetInfo: NetBIOS + DNS names, terminated by EOL.
@@ -504,7 +506,9 @@ def _ntlmv2_verify(
).digest()
# 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):
return True
@@ -623,9 +627,6 @@ def _basic_auth_login(request):
return user
async def _token_auth_login(request, *, privileged=False):
"""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)
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)
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)
except Unauthorized as e:
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
ntlm_failed = True
user = None
@@ -1062,8 +1067,9 @@ async def login_page(request):
doc.style(_LOGIN_PAGE_CSS)
with doc.div(class_="login-card"):
doc.h1("Authentication Required")
with doc.div(class_="content"), doc.form(
method="POST", id="loginForm", autocomplete="on"
with (
doc.div(class_="content"),
doc.form(method="POST", id="loginForm", autocomplete="on"),
):
doc.label("Username:", for_="username")
doc.input(
@@ -1329,9 +1335,7 @@ async def create_token_handler(request):
if sso_user_id:
# Non-admin cannot create tokens for other users
if sso_user_id != current_sso_user_id:
raise Forbidden(
"Cannot create tokens for other users", quiet=True
)
raise Forbidden("Cannot create tokens for other users", quiet=True)
else:
sso_user_id = current_sso_user_id
if not sso_user_id:
@@ -1339,9 +1343,7 @@ async def create_token_handler(request):
else:
if username:
if username != current_username:
raise Forbidden(
"Cannot create tokens for other users", quiet=True
)
raise Forbidden("Cannot create tokens for other users", quiet=True)
else:
username = current_username
if not username:
+17 -9
View File
@@ -159,7 +159,9 @@ async def copy_or_move(request, name=""):
# Validate target shape/type before mutating anything.
for _op_name, op_keys in (("cp", cp_keys), ("mv", mv_keys)):
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:
continue
if not dst_is_dir:
@@ -172,7 +174,9 @@ async def copy_or_move(request, name=""):
for key in op_keys:
src_abs = _resolve_from_relpath(key_paths[key])
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()
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"
)
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:
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:
if not dst_rel.parts:
@@ -551,10 +559,9 @@ def _rel_to_href(rel: PurePosixPath, *, is_dir: bool) -> str:
def _dav_xml(element: ET.Element) -> bytes:
"""Serialise an ElementTree element to UTF-8 bytes with XML declaration."""
return (
b'<?xml version="1.0" encoding="UTF-8"?>'
+ ET.tostring(element, encoding="unicode").encode("utf-8")
)
return b'<?xml version="1.0" encoding="UTF-8"?>' + ET.tostring(
element, encoding="unicode"
).encode("utf-8")
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,
"size": 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),
"created": datetime.fromtimestamp(st.st_ctime, tz=UTC).strftime(
"%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()
+114 -23
View File
@@ -1,4 +1,5 @@
import asyncio
import contextlib
import gc
import io
import mimetypes
@@ -14,10 +15,9 @@ from time import perf_counter
from urllib.parse import unquote
from wsgiref.handlers import format_date_time
import msgspec
import av
import fitz # PyMuPDF
import msgspec
import numpy as np
import pyvips
from blake3 import blake3
@@ -29,6 +29,22 @@ from cista import auth, config
from cista.preview_worker import PreviewRequest, PreviewResponse
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")
@@ -138,10 +154,8 @@ class _PreviewWorker:
async def kill(self) -> None:
if self.proc.returncode is None:
try:
with contextlib.suppress(ProcessLookupError):
self.proc.kill()
except ProcessLookupError:
pass
await self.proc.wait()
_active_procs.discard(self.proc)
@@ -196,16 +210,16 @@ class _PreviewWorkerPool:
timeout=PREVIEW_TIMEOUT,
)
return out, resp
except asyncio.TimeoutError:
except TimeoutError:
replace = True
logger.warning(
"Preview timeout (%ds) for %s", int(PREVIEW_TIMEOUT), filepath.name
)
raise PreviewTimeout(filepath.name)
except WorkerChecksumError:
raise PreviewTimeoutError(filepath.name) from None
except WorkerChecksumError as e:
replace = True
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:
raise
except (
@@ -221,12 +235,13 @@ class _PreviewWorkerPool:
logger.warning(
"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:
if replace:
await self._replace_worker(worker)
else:
if worker.proc.returncode is None:
elif worker.proc.returncode is None:
await self._idle.put(worker)
else:
await self._replace_worker(worker)
@@ -270,10 +285,8 @@ async def shutdown_preview_workers() -> None:
if not _active_procs:
return
for proc in list(_active_procs):
try:
with contextlib.suppress(ProcessLookupError):
proc.kill()
except ProcessLookupError:
pass
await asyncio.gather(
*(proc.wait() for proc in list(_active_procs)), return_exceptions=True
)
@@ -286,7 +299,7 @@ async def verify_preview(request):
await auth.verify(request)
class PreviewTimeout(Exception):
class PreviewTimeoutError(Exception):
"""Raised when the preview subprocess exceeds PREVIEW_TIMEOUT."""
@@ -317,15 +330,56 @@ async def _run_preview_process(
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:
suffix = path.suffix.lower()
if suffix in DOC_PREVIEW_SUFFIXES:
if suffix in DOC_PREVIEW_SUFFIXES or suffix in OFFICE_PREVIEW_SUFFIXES:
return True
mime_type, _ = mimetypes.guess_type(path.name)
if not mime_type:
return False
return mime_type.startswith("image/") or mime_type.startswith("video/")
return mime_type.startswith(("image/", "video/"))
@bp.get("/<path:path>")
@@ -339,7 +393,7 @@ async def preview(req, path):
try:
stat = filepath.lstat()
except FileNotFoundError:
raise NotFound() from None
raise NotFound from None
if not is_previewable_path(filepath):
return empty(415)
@@ -363,7 +417,7 @@ async def preview(req, path):
img, preview_resp = await _run_preview_process(
filepath, quality, maxsize, maxzoom
)
except PreviewTimeout:
except PreviewTimeoutError:
return empty(504)
except PreviewError as e:
if e.backend:
@@ -378,7 +432,7 @@ async def preview(req, path):
if preview_resp and preview_resp.backend:
if preview_resp.timings:
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}"
else:
@@ -410,9 +464,15 @@ async def preview(req, path):
def dispatch(path, quality, maxsize, maxzoom):
backend = "unknown"
try:
if path.suffix.lower() in DOC_PREVIEW_SUFFIXES:
suffix = path.suffix.lower()
if suffix in DOC_PREVIEW_SUFFIXES:
backend = "pdf"
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)
if mime_type and mime_type.startswith("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):
frame = None
imgdata = io.BytesIO()
@@ -574,7 +664,8 @@ def process_video(path, *, maxsize, quality):
"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.height = frame.height
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).
"""
import logging
import contextlib
import io
import logging
import struct
import sys
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."""
id_str = _format_ws_id(ws_id)
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}"
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:
+27 -25
View File
@@ -1,38 +1,40 @@
import secrets
from time import time
import jwt
from cista.config import derived_secret
def session_secret():
return derived_secret("session")
# In-memory session store: token -> {"username": str, "exp": int}
_sessions: dict[str, dict] = {}
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):
try:
return jwt.decode(request.cookies.s, session_secret(), algorithms=["HS256"])
except Exception:
return False if "s" in request.cookies else None
token = request.cookies.get("s")
if token is None:
return 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):
data = {
"exp": int(time()) + max_age,
"username": username,
**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())
_purge_expired()
token = _token()
_sessions[token] = {"exp": int(time()) + max_age, "username": username, **kwargs}
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.
"""
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()
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({
// @ts-ignore
x: e.target.getBoundingClientRect().right,
// @ts-ignore
y: e.target.getBoundingClientRect().bottom,
items
})
+79 -17
View File
@@ -17,10 +17,10 @@
<span v-else class="file icon" :class="`ext-${doc.ext}`"></span>
</template>
<script setup lang=ts>
import { computed, ref } from 'vue'
import type { Doc } from '@/repositories/Document'
<script setup lang="ts">
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 vid = ref<HTMLVideoElement | null>(null)
@@ -29,7 +29,11 @@ const props = defineProps<{
doc: Doc
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 = () => {
if (!media.value) return
@@ -52,7 +56,10 @@ let fscurrent: HTMLVideoElement | null = null
const next = () => {
if (!media.value) return
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
let el: HTMLAudioElement | HTMLVideoElement | null = null
for (const i in medias) {
@@ -62,7 +69,7 @@ const next = () => {
}
}
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
// because we are not allowed to fullscreen the next one.
// FIXME: Write our own player to avoid this problem...
@@ -73,7 +80,9 @@ const next = () => {
return
}
if (!fscurrent) {
elem.addEventListener('fullscreenchange', ev => {
elem.addEventListener(
'fullscreenchange',
ev => {
if (!fscurrent) return
// Restore the original video element and continue with the one that was playing
fscurrent.currentTime = elem.currentTime
@@ -83,7 +92,9 @@ const next = () => {
elem.src = props.doc.url
applyPoster(elem)
onpaused()
}, {once: true})
},
{ once: true }
)
}
fscurrent = playing
elem.src = playing.src
@@ -99,7 +110,10 @@ defineExpose({
if (!media.value) return false
if (media.value.paused) {
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
el.pause()
}
@@ -108,19 +122,67 @@ defineExpose({
}
return true
},
media,
media
})
const video = () => ['mkv', 'mp4', 'webm', 'mov', 'avi'].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 preview = () => (
['bmp', 'ico', 'tif', 'tiff', 'heic', 'heif', 'pdf', 'epub', 'mobi'].includes(props.doc.ext) ||
props.doc.size > 500000 &&
['avif', 'webp', 'png', 'jpg', 'jpeg'].includes(props.doc.ext)
)
const preview = () =>
[
'bmp',
'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>
<style scoped>
+49 -1
View File
@@ -85,7 +85,55 @@ export class Doc {
if (this.dir) return false
if (this.img) return true
// 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 {
if (!this.complete || !this.previewable) return ''
-1
View File
@@ -25,7 +25,6 @@ classifiers = [
requires-python = ">=3.11"
dependencies = [
"argon2-cffi>=25.1.0",
"aspose-words>=26.4.0",
"av>=15.0.0",
"blake3>=1.0.5",
"docopt-ng>=0.9.0",
+31 -13
View File
@@ -6,7 +6,6 @@ from pathlib import Path
from time import time
from uuid import uuid4
import jwt
import pytest
import pytest_asyncio
from sanic import Sanic
@@ -26,19 +25,28 @@ def _ntlm_type1() -> dict[str, str]:
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."""
from Crypto.Hash import MD4
# NT hash
nt_hash = MD4.new(password.encode("utf-16le")).digest()
# 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
timestamp = struct.pack("<Q", 0)
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 = 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]:
token = jwt.encode(
{"exp": int(time()) + session.max_age, "username": username},
session.session_secret(),
algorithm="HS256",
)
token = "test-" + username
session._sessions[token] = {
"exp": int(time()) + session.max_age,
"username": username,
}
return {"Cookie": f"s={token}"}
@@ -133,7 +141,9 @@ async def client(setup_storage: Path):
@pytest.mark.asyncio
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.body == b"hello"
@@ -162,12 +172,18 @@ async def test_unauthenticated_sends_basic_auth_challenge(client):
_, res = await client.request("PROPFIND", "/files/")
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
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.body == b"hello"
@@ -175,7 +191,9 @@ async def test_basic_auth_with_token(client):
@pytest.mark.asyncio
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.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."""
from pathlib import Path
from uuid import uuid4
@@ -22,7 +23,13 @@ def setup_storage(tmp_path: Path):
@pytest_asyncio.fixture()
async def client(setup_storage: Path):
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)
yield app.asgi_client
+10 -2
View File
@@ -22,7 +22,13 @@ def setup_storage(tmp_path: Path):
@pytest_asyncio.fixture()
async def client(setup_storage: Path):
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)
yield app.asgi_client
@@ -214,7 +220,9 @@ async def test_post_rejects_multiple_keys_to_file_target(client, setup_storage:
@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" / "nested.txt").write_text("n", 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()
async def client(setup_storage: Path):
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)
yield app.asgi_client
+2 -3
View File
@@ -1,4 +1,5 @@
"""WebDAV protocol tests: OPTIONS, PROPFIND, PROPPATCH, COPY, MOVE, LOCK, UNLOCK."""
import xml.etree.ElementTree as ET
from pathlib import Path
from uuid import uuid4
@@ -113,9 +114,7 @@ async def test_propfind_file_has_content_length(client, setup_storage: Path):
@pytest.mark.asyncio
async def test_propfind_depth_infinity_rejected(client, setup_storage: Path):
_, res = await client.request(
"PROPFIND", "/files/", headers={"Depth": "infinity"}
)
_, res = await client.request("PROPFIND", "/files/", headers={"Depth": "infinity"})
assert res.status_code == 403
+6 -6
View File
@@ -12,19 +12,19 @@ def mock_open(key):
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
cache["key1"]
assert "key1" in cache
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"
def test_capacity():
cache = LRUCache(open=mock_open, capacity=2, maxage=10)
cache = LRUCache(opener=mock_open, capacity=2, maxage=10)
item1 = cache["key1"]
cache["key2"]
cache["key3"]
@@ -33,7 +33,7 @@ def test_capacity():
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"]
sleep(0.2) # Wait for expiration
cache.expire_items()
@@ -42,7 +42,7 @@ def test_expiry():
def test_close():
cache = LRUCache(open=mock_open, capacity=2, maxage=10)
cache = LRUCache(opener=mock_open, capacity=2, maxage=10)
item = cache["key1"]
cache.close()
assert "key1" not in cache
@@ -50,7 +50,7 @@ def test_close():
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"]
item2 = cache["key2"]
cache["key1"] # Make key1 recently used