Files
tech-service-portal/app/media.py
T
darkorion 0f239d5015 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

70 lines
2.8 KiB
Python

"""Картинки портала: иконки оборудования, фото сотрудников, карта завода, фото к заданиям.
Хранятся в подкаталоге media/ каталога фото. Имя файла — случайное (hex), путь в БД проверяется регуляркой,
так что выйти за пределы каталога через подставленное значение нельзя."""
import re
from pathlib import Path
from fastapi import HTTPException, Request
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from . import settings_store as ss
MAGIC = [(b"\xff\xd8\xff", "image/jpeg", ".jpg"), (b"\x89PNG\r\n\x1a\n", "image/png", ".png")]
CT = {".jpg": "image/jpeg", ".png": "image/png", ".webp": "image/webp"}
NAME_RE = re.compile(r"(keep/)?[0-9a-f]{32}\.(jpg|png|webp)") # keep/ — хранить долго (очистка не трогает)
KINDS = ("equipment", "users", "map", "tasks")
def sniff(data: bytes):
for sig, ct, ext in MAGIC:
if data.startswith(sig):
return ct, ext
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "image/webp", ".webp"
return None
def root(db: Session, kind: str) -> Path:
assert kind in KINDS
p = ss.photo_root(db) / "media" / kind
p.mkdir(parents=True, exist_ok=True)
return p
async def read_image(db: Session, request: Request, max_mb: int | None = None) -> tuple[bytes, str, str]:
"""Тело запроса — сам файл (image/jpeg|png|webp), без multipart."""
limit = (max_mb or int(ss.get(db, "max_photo_mb"))) * 1024 * 1024
data = b""
async for chunk in request.stream():
data += chunk
if len(data) > limit:
raise HTTPException(413, f"Файл больше {limit // 1024 // 1024} МБ")
kind = sniff(data)
if not kind:
raise HTTPException(415, "Допустимы JPEG, PNG, WebP")
return data, kind[0], kind[1]
def save(db: Session, kind: str, data: bytes, ext: str, keep: bool = False) -> str:
name = ("keep/" if keep else "") + ss.new_name() + ext
f = root(db, kind) / name
f.parent.mkdir(parents=True, exist_ok=True)
f.write_bytes(data)
return name
def remove(db: Session, kind: str, name: str | None):
if name and NAME_RE.fullmatch(name):
(root(db, kind) / name).unlink(missing_ok=True)
def respond(db: Session, kind: str, name: str | None) -> FileResponse:
if not name or not NAME_RE.fullmatch(name):
raise HTTPException(404, "Картинка не загружена")
f = root(db, kind) / name
if not f.is_file():
raise HTTPException(404, "Файл отсутствует на сервере")
return FileResponse(f, media_type=CT[f.suffix], headers={"Cache-Control": "private, max-age=86400"})