Ctrl K
ComunidadGuías…

simple_nas/app/wopi.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Collabora Online glue: discovery, access tokens, proof keys, locks.

Collabora never stores documents. It opens and saves them through WOPI calls
to the host that embedded it - here the public share site. This module holds
everything that is not HTTP routing, so it stays Flask-free and testable:

- discovery: which file types Collabora handles and the editor URL for each,
  plus the RSA proof key Collabora signs its WOPI calls with
- access tokens: HMAC-signed, bound to one link, one file, one account and the
  link/account auth epochs, short-lived; the file id is derived from the same
  pair so a token for one file never opens another
- proof check: RSA PKCS#1 v1.5 / SHA-256 in pure Python (no extra package in
  the image), so only the configured Collabora can call the WOPI endpoints
- locks: persisted in /data so a worker restart does not orphan an open editor
"""

import base64
import hashlib
import hmac
import json
import os
import re
import ssl
import struct
import threading
import time
import urllib.request
import xml.etree.ElementTree as ET
from urllib.parse import quote, urlsplit

LOCKS_FILE = "wopi_locks.json"
LOCK_SECONDS = 30 * 60            # WOPI: a lock expires after 30 minutes without refresh
MAX_LOCK_LEN = 1024
PROOF_MAX_AGE = 20 * 60           # WOPI: reject proofs older than 20 minutes
DISCOVERY_TTL = 12 * 3600
DISCOVERY_RETRY = 60
TOKEN_VERSION = "1"


class WopiError(ValueError):
    pass


# ── helpers ──────────────────────────────────────────────────────────────────

def _b64e(raw):
    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()


def _b64d(s):
    s = str(s)
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))


def origin_of(url):
    """'https://Host:443/x' -> 'https://host:443'; '' when not an http(s) URL."""
    try:
        p = urlsplit(str(url or "").strip())
    except ValueError:
        return ""
    if p.scheme not in ("http", "https") or not p.hostname:
        return ""
    host = p.hostname.lower()
    if ":" in host:
        host = f"[{host}]"
    return f"{p.scheme}://{host}" + (f":{p.port}" if p.port else "")


def iso_mtime(mtime):
    """LastModifiedTime as Collabora echoes it back in X-COOL-WOPI-Timestamp.
    Deterministic for one mtime, so comparing strings is exact."""
    whole = int(mtime)
    micro = int(round((mtime - whole) * 1_000_000))
    if micro >= 1_000_000:
        whole, micro = whole + 1, micro - 1_000_000
    return time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(whole)) + f".{micro:06d}Z"


def file_id(link_id, rel):
    """Stable id per (link, file): every editor of the same file on the same
    link joins one Collabora session. Reveals neither the path nor the token."""
    return hashlib.sha256(f"wopi\0{link_id}\0{rel}".encode()).hexdigest()[:40]


# ── discovery ────────────────────────────────────────────────────────────────

_PLACEHOLDER = re.compile(r"<[^>]*>")


def parse_discovery(xml_text, public_base):
    """{'actions': {ext: {action: urlsrc}}, 'proof': {...} or None}.

    Collabora writes its urlsrc with whatever host the discovery request used.
    Fetched over an internal address that host is useless to a browser, so the
    origin is always replaced by the public Collabora URL."""
    root = ET.fromstring(xml_text)
    pub = origin_of(public_base)
    actions = {}
    for action in root.iter("action"):
        ext = (action.get("ext") or "").lower().lstrip(".")
        name = action.get("name") or ""
        src = action.get("urlsrc") or ""
        if not ext or not name or not src:
            continue
        src = _PLACEHOLDER.sub("", src)
        if pub:
            p = urlsplit(src)
            src = pub + p.path + ("?" + p.query if p.query else "?")
        actions.setdefault(ext, {})[name] = src
    proof = None
    pk = next(iter(root.iter("proof-key")), None)
    if pk is not None and pk.get("modulus") and pk.get("exponent"):
        proof = {
            "modulus": pk.get("modulus"), "exponent": pk.get("exponent"),
            "oldmodulus": pk.get("oldmodulus") or pk.get("modulus"),
            "oldexponent": pk.get("oldexponent") or pk.get("exponent"),
        }
    return {"actions": actions, "proof": proof}


def fetch_discovery(url, verify_tls=True, timeout=8):
    ctx = ssl.create_default_context()
    if not verify_tls:
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
    req = urllib.request.Request(url, headers={"User-Agent": "SimpleNAS-WOPI"})
    with urllib.request.urlopen(req, timeout=timeout, context=ctx) as r:   # noqa: S310 - admin-configured URL
        return r.read(2 * 1024 * 1024).decode("utf-8", "replace")


class Discovery:
    """Cached discovery. A failure is retried after a minute, not on every
    request, so an unreachable Collabora never slows down the file listing."""

    def __init__(self, fetch=fetch_discovery, clock=time.time):
        self._fetch = fetch
        self._clock = clock
        self._lock = threading.Lock()
        self._data = None
        self._ts = 0.0
        self._failed_ts = 0.0
        self._key = None
        self.error = ""

    def get(self, public_url, internal_url="", verify_tls=True, force=False):
        public_url = str(public_url or "").strip().rstrip("/")
        if not public_url:
            return None
        base = (str(internal_url or "").strip().rstrip("/") or public_url)
        key = (public_url, base, bool(verify_tls))
        now = self._clock()
        with self._lock:
            if key != self._key:
                self._key, self._data, self._ts, self._failed_ts = key, None, 0.0, 0.0
            fresh = self._data is not None and now - self._ts < DISCOVERY_TTL
            if fresh and not force:
                return self._data
            if not force and self._failed_ts and now - self._failed_ts < DISCOVERY_RETRY:
                return self._data            # stale data beats none; None if never loaded
            try:
                data = parse_discovery(self._fetch(base + "/hosting/discovery", verify_tls), public_url)
                if not data["actions"]:
                    raise WopiError("Discovery enthaelt keine Dateitypen")
            except Exception as e:           # network, TLS, XML - all mean "not now"
                self._failed_ts = now
                self.error = f"{type(e).__name__}: {e}"
                return self._data
            self._data, self._ts, self._failed_ts, self.error = data, now, 0.0, ""
            return data


def pick_action(discovery, filename, want_write):
    """(action name, urlsrc) for this file, or (None, None) if Collabora cannot
    open it. Writers get 'edit'; readers get 'view' where one exists, else the
    edit URL (CheckFileInfo's UserCanWrite=false keeps it read-only)."""
    if not discovery:
        return None, None
    ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
    acts = discovery["actions"].get(ext) or {}
    order = ("edit", "view") if want_write else ("view", "edit")
    for name in order:
        if name in acts:
            return name, acts[name]
    return None, None


def editor_url(urlsrc, wopi_src, lang="de"):
    sep = "" if urlsrc.endswith(("?", "&")) else ("&" if "?" in urlsrc else "?")
    return f"{urlsrc}{sep}WOPISrc={quote(wopi_src, safe='')}&lang={quote(lang)}&closebutton=1"


# ── access tokens ────────────────────────────────────────────────────────────

def _token_key(secret):
    return hmac.new(str(secret).encode(), b"simplenas-wopi-token", hashlib.sha256).digest()


def make_token(secret, link_id, rel, user, can_write, link_epoch, user_epoch, ttl, origin="", now=None):
    """origin: the page that frames Collabora, returned as PostMessageOrigin."""
    now = int(now if now is not None else time.time())
    claims = {"v": TOKEN_VERSION, "l": link_id, "p": rel, "u": user or "", "w": 1 if can_write else 0,
              "le": int(link_epoch or 1), "ue": int(user_epoch or 0), "o": origin or "", "e": now + int(ttl)}
    body = _b64e(json.dumps(claims, separators=(",", ":"), ensure_ascii=False).encode())
    sig = _b64e(hmac.new(_token_key(secret), body.encode(), hashlib.sha256).digest())
    return f"{body}.{sig}", claims["e"]


def read_token(secret, token, now=None):
    """Claims dict, or None for anything forged, malformed or expired."""
    if not token or len(token) > 4096 or token.count(".") != 1:
        return None
    body, sig = token.split(".")
    want = _b64e(hmac.new(_token_key(secret), body.encode(), hashlib.sha256).digest())
    if not hmac.compare_digest(sig, want):
        return None
    try:
        claims = json.loads(_b64d(body))
    except (ValueError, TypeError):
        return None
    if not isinstance(claims, dict) or claims.get("v") != TOKEN_VERSION:
        return None
    now = now if now is not None else time.time()
    if not isinstance(claims.get("e"), int) or now > claims["e"]:
        return None
    if not isinstance(claims.get("l"), str) or not isinstance(claims.get("p"), str):
        return None
    if not isinstance(claims.get("le"), int) or not isinstance(claims.get("ue"), int):
        return None
    return claims


# ── proof keys ───────────────────────────────────────────────────────────────

_SHA256_DIGEST_INFO = bytes.fromhex("3031300d060960864801650304020105000420")


def _b64int(s):
    return int.from_bytes(base64.b64decode(s), "big")


def rsa_verify_sha256(modulus_b64, exponent_b64, message, signature):
    """RSASSA-PKCS1-v1_5 with SHA-256. Pure Python: one modular exponentiation
    and a constant-time compare against the padded digest."""
    try:
        n, e = _b64int(modulus_b64), _b64int(exponent_b64)
    except (ValueError, TypeError):
        return False
    k = (n.bit_length() + 7) // 8
    if not signature or len(signature) != k or n < 2 ** 1023:
        return False
    s = int.from_bytes(signature, "big")
    if s >= n:
        return False
    em = pow(s, e, n).to_bytes(k, "big")
    t = _SHA256_DIGEST_INFO + hashlib.sha256(message).digest()
    if k < len(t) + 11:
        return False
    expected = b"\x00\x01" + b"\xff" * (k - len(t) - 3) + b"\x00" + t
    return hmac.compare_digest(em, expected)


def proof_message(access_token, url, timestamp):
    tok = access_token.encode()
    u = url.upper().encode()
    return (struct.pack(">i", len(tok)) + tok + struct.pack(">i", len(u)) + u
            + struct.pack(">i", 8) + struct.pack(">q", int(timestamp)))


def ticks_to_unix(ticks):
    return int(ticks) / 10_000_000 - 62135596800


def verify_proof(proof, access_token, urls, timestamp, sig_b64, old_sig_b64, now=None):
    """True when Collabora really signed one of the candidate URLs.

    urls holds every spelling the request could have been signed under (the
    configured WOPI base vs. the Host header behind a proxy); Collabora signed
    the one it called. Current key with either signature, or the old key with
    the current signature, covers a key rotation in progress."""
    try:
        ts = int(timestamp)
        sig = base64.b64decode(sig_b64 or "")
        old_sig = base64.b64decode(old_sig_b64) if old_sig_b64 else b""
    except (ValueError, TypeError):
        return False
    now = now if now is not None else time.time()
    if abs(now - ticks_to_unix(ts)) > PROOF_MAX_AGE:
        return False
    for url in dict.fromkeys(u for u in urls if u):
        msg = proof_message(access_token, url, ts)
        if rsa_verify_sha256(proof["modulus"], proof["exponent"], msg, sig):
            return True
        if old_sig and rsa_verify_sha256(proof["modulus"], proof["exponent"], msg, old_sig):
            return True
        if rsa_verify_sha256(proof["oldmodulus"], proof["oldexponent"], msg, sig):
            return True
    return False


# ── locks ────────────────────────────────────────────────────────────────────

class LockTable:
    """path -> (lock id, expiry). Persisted as JSON so a restarted worker keeps
    honouring the lock an open editor holds; one process writes it."""

    def __init__(self, data_dir, clock=time.time):
        self._path = os.path.join(data_dir, LOCKS_FILE)
        self._clock = clock
        self._mutex = threading.Lock()

    def _load(self):
        try:
            with open(self._path, encoding="utf-8") as f:
                d = json.load(f)
        except (OSError, ValueError):
            return {}
        if not isinstance(d, dict):
            return {}
        now = self._clock()
        return {p: v for p, v in d.items()
                if isinstance(v, dict) and isinstance(v.get("id"), str) and float(v.get("exp", 0)) > now}

    def _save(self, d):
        tmp = f"{self._path}.tmp.{os.getpid()}"
        try:
            with open(tmp, "w", encoding="utf-8") as f:
                json.dump(d, f)
            os.replace(tmp, self._path)
        except OSError:
            pass

    def get(self, path):
        with self._mutex:
            v = self._load().get(path)
            return v["id"] if v else ""

    def paths(self):
        """Every path currently locked by an open editor."""
        with self._mutex:
            return set(self._load())

    def lock(self, path, lock_id, old_lock=None):
        """(ok, current). Same id refreshes; with old_lock this is
        UnlockAndRelock and only succeeds when old_lock is the holder."""
        if not lock_id or len(lock_id) > MAX_LOCK_LEN:
            return False, ""
        with self._mutex:
            d = self._load()
            cur = d.get(path, {}).get("id", "")
            if old_lock is not None:
                if cur != old_lock:
                    return False, cur
            elif cur and cur != lock_id:
                return False, cur
            d[path] = {"id": lock_id, "exp": self._clock() + LOCK_SECONDS}
            self._save(d)
            return True, lock_id

    def refresh(self, path, lock_id):
        with self._mutex:
            d = self._load()
            cur = d.get(path, {}).get("id", "")
            if not cur or cur != lock_id:
                return False, cur
            d[path]["exp"] = self._clock() + LOCK_SECONDS
            self._save(d)
            return True, cur

    def unlock(self, path, lock_id):
        with self._mutex:
            d = self._load()
            cur = d.get(path, {}).get("id", "")
            if not cur or cur != lock_id:
                return False, cur
            d.pop(path, None)
            self._save(d)
            return True, ""