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:
Leo Vasanko
2026-04-26 06:59:01 +00:00
parent c49e66323f
commit fb0ddd20e0
19 changed files with 1606 additions and 136 deletions
+31 -13
View File
@@ -6,7 +6,6 @@ from pathlib import Path
from time import time
from uuid import uuid4
import jwt
import pytest
import pytest_asyncio
from sanic import Sanic
@@ -26,19 +25,28 @@ def _ntlm_type1() -> dict[str, str]:
return {"Authorization": f"NTLM {base64.b64encode(msg).decode()}"}
def _ntlm_type3(username: str, password: str, domain: str, challenge: bytes) -> dict[str, str]:
def _ntlm_type3(
username: str, password: str, domain: str, challenge: bytes
) -> dict[str, str]:
"""Build an NTLMv2 Type 3 message for testing."""
from Crypto.Hash import MD4
# NT hash
nt_hash = MD4.new(password.encode("utf-16le")).digest()
# NTLMv2 hash
ntlmv2_hash = hmac.new(nt_hash, (username.upper() + domain).encode("utf-16le"), hashlib.md5).digest()
ntlmv2_hash = hmac.new(
nt_hash, (username.upper() + domain).encode("utf-16le"), hashlib.md5
).digest()
# Build a minimal blob
timestamp = struct.pack("<Q", 0)
client_nonce = b"\x01" * 8
blob = b"\x01\x01\x00\x00\x00\x00\x00\x00" + timestamp + client_nonce + b"\x00\x00\x00\x00"
blob = (
b"\x01\x01\x00\x00\x00\x00\x00\x00"
+ timestamp
+ client_nonce
+ b"\x00\x00\x00\x00"
)
# NT proof
nt_proof = hmac.new(ntlmv2_hash, challenge + blob, hashlib.md5).digest()
@@ -85,11 +93,11 @@ def _ntlm_type3(username: str, password: str, domain: str, challenge: bytes) ->
def _session_cookie_header(username: str) -> dict[str, str]:
token = jwt.encode(
{"exp": int(time()) + session.max_age, "username": username},
session.session_secret(),
algorithm="HS256",
)
token = "test-" + username
session._sessions[token] = {
"exp": int(time()) + session.max_age,
"username": username,
}
return {"Cookie": f"s={token}"}
@@ -133,7 +141,9 @@ async def client(setup_storage: Path):
@pytest.mark.asyncio
async def test_basic_auth_allows_private_file_access(client):
_, res = await client.get("/files/hello.txt", headers=_basic_auth("alice", "secret"))
_, res = await client.get(
"/files/hello.txt", headers=_basic_auth("alice", "secret")
)
assert res.status_code == 200
assert res.body == b"hello"
@@ -162,12 +172,18 @@ async def test_unauthenticated_sends_basic_auth_challenge(client):
_, res = await client.request("PROPFIND", "/files/")
assert res.status_code == 401
assert res.headers.get("www-authenticate", "").lower().startswith('basic realm="cista"')
assert (
res.headers.get("www-authenticate", "")
.lower()
.startswith('basic realm="cista"')
)
@pytest.mark.asyncio
async def test_basic_auth_with_token(client):
_, res = await client.get("/files/hello.txt", headers=_basic_auth("token", "test_token_123"))
_, res = await client.get(
"/files/hello.txt", headers=_basic_auth("token", "test_token_123")
)
assert res.status_code == 200
assert res.body == b"hello"
@@ -175,7 +191,9 @@ async def test_basic_auth_with_token(client):
@pytest.mark.asyncio
async def test_browser_unauthenticated_sends_cookie_challenge(client):
_, res = await client.get("/files/", headers={"Accept": "text/html,application/xhtml+xml"})
_, res = await client.get(
"/files/", headers={"Accept": "text/html,application/xhtml+xml"}
)
assert res.status_code == 401
assert res.headers.get("www-authenticate", "").lower().startswith("cookie")
+8 -1
View File
@@ -1,4 +1,5 @@
"""Path traversal and percent-encoding security tests for the fileserver."""
from pathlib import Path
from uuid import uuid4
@@ -22,7 +23,13 @@ def setup_storage(tmp_path: Path):
@pytest_asyncio.fixture()
async def client(setup_storage: Path):
app = Sanic(f"files-path-sec-test-{uuid4().hex}", strict_slashes=True)
app.router.ALLOWED_METHODS = (*app.router.ALLOWED_METHODS, "MKCOL", "MOVE", "COPY", "PROPFIND")
app.router.ALLOWED_METHODS = (
*app.router.ALLOWED_METHODS,
"MKCOL",
"MOVE",
"COPY",
"PROPFIND",
)
app.blueprint(fileserver_bp)
yield app.asgi_client
+10 -2
View File
@@ -22,7 +22,13 @@ def setup_storage(tmp_path: Path):
@pytest_asyncio.fixture()
async def client(setup_storage: Path):
app = Sanic(f"files-rest-test-{uuid4().hex}", strict_slashes=True)
app.router.ALLOWED_METHODS = (*app.router.ALLOWED_METHODS, "MKCOL", "MOVE", "COPY", "PROPFIND")
app.router.ALLOWED_METHODS = (
*app.router.ALLOWED_METHODS,
"MKCOL",
"MOVE",
"COPY",
"PROPFIND",
)
app.blueprint(fileserver_bp)
yield app.asgi_client
@@ -214,7 +220,9 @@ async def test_post_rejects_multiple_keys_to_file_target(client, setup_storage:
@pytest.mark.asyncio
async def test_post_rejects_directory_to_existing_file_target(client, setup_storage: Path):
async def test_post_rejects_directory_to_existing_file_target(
client, setup_storage: Path
):
(setup_storage / "folder").mkdir()
(setup_storage / "folder" / "nested.txt").write_text("n", encoding="utf-8")
(setup_storage / "existing.txt").write_text("e", encoding="utf-8")
+7 -1
View File
@@ -21,7 +21,13 @@ def setup_storage(tmp_path: Path):
@pytest_asyncio.fixture()
async def client(setup_storage: Path):
app = Sanic(f"files-static-test-{uuid4().hex}", strict_slashes=True)
app.router.ALLOWED_METHODS = (*app.router.ALLOWED_METHODS, "MKCOL", "MOVE", "COPY", "PROPFIND")
app.router.ALLOWED_METHODS = (
*app.router.ALLOWED_METHODS,
"MKCOL",
"MOVE",
"COPY",
"PROPFIND",
)
app.blueprint(fileserver_bp)
yield app.asgi_client
+2 -3
View File
@@ -1,4 +1,5 @@
"""WebDAV protocol tests: OPTIONS, PROPFIND, PROPPATCH, COPY, MOVE, LOCK, UNLOCK."""
import xml.etree.ElementTree as ET
from pathlib import Path
from uuid import uuid4
@@ -113,9 +114,7 @@ async def test_propfind_file_has_content_length(client, setup_storage: Path):
@pytest.mark.asyncio
async def test_propfind_depth_infinity_rejected(client, setup_storage: Path):
_, res = await client.request(
"PROPFIND", "/files/", headers={"Depth": "infinity"}
)
_, res = await client.request("PROPFIND", "/files/", headers={"Depth": "infinity"})
assert res.status_code == 403
+6 -6
View File
@@ -12,19 +12,19 @@ def mock_open(key):
def test_contains():
cache = LRUCache(open=mock_open, capacity=2, maxage=10)
cache = LRUCache(opener=mock_open, capacity=2, maxage=10)
assert "key1" not in cache
cache["key1"]
assert "key1" in cache
def test_getitem():
cache = LRUCache(open=mock_open, capacity=2, maxage=10)
cache = LRUCache(opener=mock_open, capacity=2, maxage=10)
assert cache["key1"].content == "content-key1"
def test_capacity():
cache = LRUCache(open=mock_open, capacity=2, maxage=10)
cache = LRUCache(opener=mock_open, capacity=2, maxage=10)
item1 = cache["key1"]
cache["key2"]
cache["key3"]
@@ -33,7 +33,7 @@ def test_capacity():
def test_expiry():
cache = LRUCache(open=mock_open, capacity=2, maxage=0.1)
cache = LRUCache(opener=mock_open, capacity=2, maxage=0.1)
item = cache["key1"]
sleep(0.2) # Wait for expiration
cache.expire_items()
@@ -42,7 +42,7 @@ def test_expiry():
def test_close():
cache = LRUCache(open=mock_open, capacity=2, maxage=10)
cache = LRUCache(opener=mock_open, capacity=2, maxage=10)
item = cache["key1"]
cache.close()
assert "key1" not in cache
@@ -50,7 +50,7 @@ def test_close():
def test_lru_mechanism():
cache = LRUCache(open=mock_open, capacity=2, maxage=10)
cache = LRUCache(opener=mock_open, capacity=2, maxage=10)
item1 = cache["key1"]
item2 = cache["key2"]
cache["key1"] # Make key1 recently used