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:
+17
-15
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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("&", "&")
|
||||||
|
|
||||||
|
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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ''
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user