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}
|
||||
|
||||
|
||||
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:
|
||||
|
||||
+18
-10
@@ -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:
|
||||
@@ -533,7 +541,7 @@ def _parse_webdav_destination(dest_header: str) -> tuple[PurePosixPath, Path]:
|
||||
if raw_path in (prefix, prefix + "/"):
|
||||
rel_str = ""
|
||||
elif raw_path.startswith(prefix + "/"):
|
||||
rel_str = raw_path[len(prefix) + 1:]
|
||||
rel_str = raw_path[len(prefix) + 1 :]
|
||||
else:
|
||||
raise BadRequest("Destination must be within /files")
|
||||
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:
|
||||
"""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"
|
||||
|
||||
@@ -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 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,15 +235,16 @@ 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)
|
||||
elif worker.proc.returncode is None:
|
||||
await self._idle.put(worker)
|
||||
else:
|
||||
if worker.proc.returncode is None:
|
||||
await self._idle.put(worker)
|
||||
else:
|
||||
await self._replace_worker(worker)
|
||||
await self._replace_worker(worker)
|
||||
|
||||
async def close(self) -> None:
|
||||
self._closed = True
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -51,8 +55,11 @@ const applyPoster = (el: HTMLVideoElement) => {
|
||||
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)[]
|
||||
media.value.load() // Restore poster
|
||||
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,28 +69,32 @@ 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...
|
||||
const elem = media.value as HTMLVideoElement
|
||||
const playing = el as HTMLVideoElement
|
||||
if (elem === playing) {
|
||||
playing.play() // Only one video, just replay
|
||||
playing.play() // Only one video, just replay
|
||||
return
|
||||
}
|
||||
if (!fscurrent) {
|
||||
elem.addEventListener('fullscreenchange', ev => {
|
||||
if (!fscurrent) return
|
||||
// Restore the original video element and continue with the one that was playing
|
||||
fscurrent.currentTime = elem.currentTime
|
||||
fscurrent.click()
|
||||
if (!elem.paused) fscurrent.play()
|
||||
fscurrent = null
|
||||
elem.src = props.doc.url
|
||||
applyPoster(elem)
|
||||
onpaused()
|
||||
}, {once: true})
|
||||
elem.addEventListener(
|
||||
'fullscreenchange',
|
||||
ev => {
|
||||
if (!fscurrent) return
|
||||
// Restore the original video element and continue with the one that was playing
|
||||
fscurrent.currentTime = elem.currentTime
|
||||
fscurrent.click()
|
||||
if (!elem.paused) fscurrent.play()
|
||||
fscurrent = null
|
||||
elem.src = props.doc.url
|
||||
applyPoster(elem)
|
||||
onpaused()
|
||||
},
|
||||
{ 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>
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user