Files
tech-service-portal/app/nextcloud.py
T
darkorion e67c175f80 feat: документация из Nextcloud, график у сотрудника, автоочистка фото, мониторинг, итог мастера, фильтр по местам; доп. сервисы в docker-compose
- docker-compose.yml: профили docs (Nextcloud), office (ONLYOFFICE Document Server), ai (Ollama + скачивание модели)
- автонастройка: адреса Nextcloud, ONLYOFFICE, портала и модели подставляются из .env или по именам сервисов
  во внутренней сети Docker; значения из интерфейса важнее
- локальная модель в своей сети (имя сервиса, частный IP) подключается без ключа API
- .env.example, docs/DEPLOY.md (раздел 7а), README
2026-10-13 13:27:20 +03:00

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