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
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
"""Документация оборудования из 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
|
||||
Reference in New Issue
Block a user