- docker-compose.yml: профили docs (Nextcloud), office (ONLYOFFICE Document Server), ai (Ollama + скачивание модели) - автонастройка: адреса Nextcloud, ONLYOFFICE, портала и модели подставляются из .env или по именам сервисов во внутренней сети Docker; значения из интерфейса важнее - локальная модель в своей сети (имя сервиса, частный IP) подключается без ключа API - .env.example, docs/DEPLOY.md (раздел 7а), README
317 lines
14 KiB
Python
317 lines
14 KiB
Python
"""Документация оборудования из 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 = """<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/><d:getcontentlength/><d:getlastmodified/>
|
|
<d:getetag/><d:getcontenttype/></d:prop></d:propfind>"""
|
|
|
|
|
|
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
|