"""Документация оборудования из Nextcloud. Портал ходит в Nextcloud сам, по WebDAV, от служебной учётной записи (пароль приложения, только чтение). Сотрудники Nextcloud не видят: список папок и файлы отдаёт портал, проверяя права на машину. Документы Office для просмотра переводятся в PDF через ONLYOFFICE Document Server (ConvertService): сервер документов скачивает исходник по короткой подписанной ссылке портала (/api/v1/docs/raw/...), результат кэшируется в DATA_DIR/docs_cache.""" import base64 import hashlib import hmac import logging import os import time import xml.etree.ElementTree as ET from dataclasses import dataclass from email.utils import parsedate_to_datetime from pathlib import Path from urllib.parse import quote, unquote, urlsplit import httpx import jwt from sqlalchemy.orm import Session from . import config, settings_store as ss log = logging.getLogger("nextcloud") TIMEOUT = httpx.Timeout(30, read=120) CACHE_DIR = Path(config.DATA_DIR) / "docs_cache" CACHE_MAX_MB = int(os.getenv("DOCS_CACHE_MB", "2048")) MAX_FILE_MB = int(os.getenv("DOCS_MAX_FILE_MB", "300")) # больше — не открываем через портал RAW_TTL = 600 # ссылка для ONLYOFFICE живёт 10 минут DAV = "{DAV:}" IMAGES = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "gif": "image/gif", "webp": "image/webp", "bmp": "image/bmp"} TEXT = {"txt", "csv", "log", "md", "ini", "cfg"} OFFICE = {"doc", "docx", "docm", "dotx", "rtf", "odt", "xls", "xlsx", "xlsm", "ods", "ppt", "pptx", "odp"} class DocsError(Exception): pass @dataclass(frozen=True) class Conf: url: str user: str password: str root: str oo_url: str oo_jwt: str internal_url: str @property def ready(self) -> bool: return bool(self.url and self.user and self.password) def conf(db: Session) -> Conf: g = lambda k: (ss.get(db, k) or "").strip() return Conf(g("nc_url").rstrip("/"), g("nc_user"), ss.get(db, "nc_password") or "", clean(g("nc_root")), g("onlyoffice_url").rstrip("/"), ss.get(db, "onlyoffice_jwt") or "", g("portal_internal_url").rstrip("/")) def clean(path: str | None, allow_empty: bool = True) -> str: """Относительный путь без «..», обратных слешей и управляющих символов. Пустой — корень.""" parts = [x for x in (path or "").replace("\\", "/").strip().split("/") if x] for x in parts: if x in (".", "..") or any(ord(ch) < 32 for ch in x) or len(x) > 255: raise DocsError("Недопустимый путь") if not parts and not allow_empty: raise DocsError("Не указан путь") return "/".join(parts) def join(*parts: str) -> str: return "/".join(x for x in (clean(p) for p in parts) if x) def ext_of(name: str) -> str: return name.rsplit(".", 1)[-1].lower() if "." in name else "" def kind(name: str) -> str: ext = ext_of(name) if ext == "pdf": return "pdf" if ext in IMAGES: return "image" if ext in TEXT: return "text" if ext in OFFICE: return "office" return "file" # ---------- WebDAV ---------- def _base(c: Conf) -> str: return f"{c.url}/remote.php/dav/files/{quote(c.user, safe='')}" def _href(c: Conf, rel: str) -> str: full = join(c.root, rel) return _base(c) + ("/" + quote(full) if full else "") def _oo_client(timeout) -> httpx.Client: return httpx.Client(timeout=timeout) def _ready(c: Conf): if not c.ready: raise DocsError("Nextcloud не настроен: укажите адрес, учётную запись и пароль приложения в настройках сервера") def _client(c: Conf) -> httpx.Client: return httpx.Client(auth=(c.user, c.password), timeout=TIMEOUT, follow_redirects=False) PROPS = """ """ def _propfind(c: Conf, rel: str, depth: int) -> list[dict]: _ready(c) try: with _client(c) as h: r = h.request("PROPFIND", _href(c, rel), headers={"Depth": str(depth), "Content-Type": "application/xml"}, content=PROPS) except httpx.HTTPError as e: raise DocsError(f"Nextcloud недоступен: {type(e).__name__}") if r.status_code == 401: raise DocsError("Nextcloud отклонил учётную запись: проверьте логин и пароль приложения") if r.status_code == 404: raise DocsError("Папка или файл не найдены в Nextcloud") if r.status_code != 207: raise DocsError(f"Nextcloud ответил {r.status_code}") return parse_multistatus(r.content, urlsplit(_href(c, "")).path) def parse_multistatus(body: bytes, prefix: str) -> list[dict]: """Ответ PROPFIND → [{path (от корня документации), name, is_dir, size, mtime, etag, mime, kind}].""" prefix = unquote(prefix).rstrip("/") out = [] try: root = ET.fromstring(body) except ET.ParseError: raise DocsError("Nextcloud ответил непонятно — проверьте адрес") for resp in root.iter(DAV + "response"): href = unquote(urlsplit(resp.findtext(DAV + "href") or "").path).rstrip("/") if href != prefix and not href.startswith(prefix + "/"): continue rel = href[len(prefix):].strip("/") prop = next((p.find(DAV + "prop") for p in resp.iter(DAV + "propstat") if "200" in (p.findtext(DAV + "status") or "")), None) if prop is None: continue is_dir = prop.find(DAV + "resourcetype/" + DAV + "collection") is not None lm = prop.findtext(DAV + "getlastmodified") try: mtime = parsedate_to_datetime(lm).astimezone(config.TZ).isoformat() if lm else None except (TypeError, ValueError): mtime = None name = rel.rsplit("/", 1)[-1] out.append({"path": rel, "name": name, "is_dir": is_dir, "size": int(prop.findtext(DAV + "getcontentlength") or 0), "mtime": mtime, "etag": (prop.findtext(DAV + "getetag") or "").strip('"'), "mime": prop.findtext(DAV + "getcontenttype") or "", "kind": "dir" if is_dir else kind(name)}) return out def listdir(c: Conf, rel: str) -> list[dict]: """Содержимое папки (без самой папки и скрытых файлов): сначала папки, потом файлы, по имени.""" rel = clean(rel) items = [x for x in _propfind(c, rel, 1) if x["path"] != rel and not x["name"].startswith(".")] return sorted(items, key=lambda x: (not x["is_dir"], x["name"].lower())) def stat(c: Conf, rel: str) -> dict: rel = clean(rel, allow_empty=False) items = [x for x in _propfind(c, rel, 0) if x["path"] == rel] if not items: raise DocsError("Файл не найден в Nextcloud") return items[0] def walk(c: Conf, rel: str, max_depth: int = 6, limit: int = 3000) -> list[dict]: """Все файлы в папке и вложенных (для индекса).""" out, stack = [], [(clean(rel), 0)] while stack and len(out) < limit: p, d = stack.pop() for x in listdir(c, p): if x["is_dir"]: if d < max_depth: stack.append((x["path"], d + 1)) else: out.append(x) return out[:limit] # ---------- кэш файлов ---------- def cache_path(rel: str, etag: str, suffix: str) -> Path: return CACHE_DIR / (hashlib.sha1(f"{rel}|{etag}".encode()).hexdigest() + suffix) def _prune(): files = sorted((p for p in CACHE_DIR.glob("*") if p.is_file()), key=lambda p: p.stat().st_mtime) total = sum(p.stat().st_size for p in files) while files and total > CACHE_MAX_MB * 1024 * 1024: p = files.pop(0) total -= p.stat().st_size p.unlink(missing_ok=True) def original_path(info: dict) -> Path: ext = ext_of(info["name"]) return cache_path(info["path"], info["etag"], "." + ext if ext else "") def fetch(c: Conf, info: dict) -> Path: """Скачать файл в кэш (если ещё нет этой версии) и вернуть путь к нему.""" if info["size"] > MAX_FILE_MB * 1024 * 1024: raise DocsError(f"Файл больше {MAX_FILE_MB} МБ — его нельзя открыть через портал") _ready(c) dest = original_path(info) if dest.exists(): os.utime(dest) return dest CACHE_DIR.mkdir(parents=True, exist_ok=True) tmp = dest.with_name(dest.name + f".{os.getpid()}.part") try: with _client(c) as h, h.stream("GET", _href(c, info["path"])) as r: if r.status_code != 200: raise DocsError(f"Nextcloud не отдал файл ({r.status_code})") with open(tmp, "wb") as f: for chunk in r.iter_bytes(1 << 16): f.write(chunk) tmp.replace(dest) except httpx.HTTPError as e: raise DocsError(f"Nextcloud недоступен: {type(e).__name__}") finally: tmp.unlink(missing_ok=True) _prune() return dest # ---------- ONLYOFFICE: Office → PDF ---------- def _sign(msg: str) -> str: return hmac.new(config.SECRET_KEY.encode(), msg.encode(), hashlib.sha256).hexdigest()[:32] def raw_token(info: dict, now: float | None = None) -> str: """Короткая подписанная ссылка на исходник в кэше — по ней ONLYOFFICE скачивает документ без входа в портал.""" exp = int((now or time.time()) + RAW_TTL) p = base64.urlsafe_b64encode(f"{info['path']}\n{info['etag']}\n{info['name']}".encode()).decode().rstrip("=") return f"{p}.{exp}.{_sign(f'raw|{p}|{exp}')}" def check_raw_token(token: str) -> Path: try: p, exp, sig = token.split(".") if not hmac.compare_digest(sig, _sign(f"raw|{p}|{exp}")) or int(exp) < time.time(): raise ValueError path, etag, name = base64.urlsafe_b64decode(p + "=" * (-len(p) % 4)).decode().split("\n") except (ValueError, UnicodeDecodeError): raise DocsError("Ссылка недействительна") f = original_path({"path": path, "etag": etag, "name": name}) if not f.exists(): raise DocsError("Файл не найден") return f def to_pdf(c: Conf, info: dict) -> Path: """Документ Office → PDF через ONLYOFFICE ConvertService (результат кэшируется по версии файла).""" dest = cache_path(info["path"], info["etag"], ".view.pdf") if dest.exists(): os.utime(dest) return dest if not (c.oo_url and c.internal_url): raise DocsError("Просмотр документов Office не настроен: укажите адрес ONLYOFFICE и внутренний адрес портала") fetch(c, info) # исходник должен лежать в кэше: его отдаст /docs/raw body = {"async": False, "filetype": ext_of(info["name"]), "outputtype": "pdf", "title": info["name"][:200], "key": hashlib.sha1(f"{info['path']}|{info['etag']}".encode()).hexdigest(), "url": f"{c.internal_url}/api/v1/docs/raw/{raw_token(info)}"} headers = {"Accept": "application/json"} if c.oo_jwt: # новые версии читают token в теле, старые — заголовок Authorization headers["Authorization"] = "Bearer " + jwt.encode({"payload": body}, c.oo_jwt, algorithm="HS256") body = {**body, "token": jwt.encode(body, c.oo_jwt, algorithm="HS256")} try: with _oo_client(httpx.Timeout(30, read=180)) as h: r = h.post(f"{c.oo_url}/ConvertService.ashx", json=body, headers=headers) data = r.json() if r.status_code == 200 else {} if data.get("error") or not data.get("endConvert") or not data.get("fileUrl"): code = data.get("error", r.status_code) hint = ": проверьте секрет JWT" if code == -8 else ": ONLYOFFICE не смог скачать файл с портала — проверьте внутренний адрес портала" if code == -4 else "" raise DocsError(f"ONLYOFFICE не смог преобразовать документ (код {code}){hint}") out = h.get(data["fileUrl"]) if out.status_code != 200: raise DocsError(f"ONLYOFFICE не отдал результат ({out.status_code})") except httpx.HTTPError as e: raise DocsError(f"ONLYOFFICE недоступен: {type(e).__name__}") except ValueError: raise DocsError("ONLYOFFICE ответил непонятно — проверьте адрес Document Server") CACHE_DIR.mkdir(parents=True, exist_ok=True) dest.write_bytes(out.content) _prune() return dest def check(c: Conf) -> dict: """Проверка подключения: Nextcloud (корень документации) и ONLYOFFICE (healthcheck).""" items = listdir(c, "") out = {"nextcloud": f"в корне документации {sum(x['is_dir'] for x in items)} папок и {sum(not x['is_dir'] for x in items)} файлов", "onlyoffice": None} if c.oo_url: try: with _oo_client(10) as h: r = h.get(f"{c.oo_url}/healthcheck") out["onlyoffice"] = "работает" if r.status_code == 200 and r.text.strip().lower() == "true" else f"ответ {r.status_code}" except httpx.HTTPError as e: out["onlyoffice"] = f"недоступен: {type(e).__name__}" return out