68 lines
2.6 KiB
Python
68 lines
2.6 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"[0-9a-f]{32}\.(jpg|png|webp)")
|
|
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) -> str:
|
|
name = ss.new_name() + ext
|
|
(root(db, kind) / name).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"})
|