2026-10-16 09:31:45 -06:00
|
|
|
"""Stored files. One row per file in `assets`; the bytes live at ASSETS_DIR/<id[:2]>/<id>.
|
|
|
|
|
|
|
|
|
|
An upload is streamed to a temp file in ASSETS_DIR in chunks (never held in memory whole), hashed as it goes,
|
|
|
|
|
typed by its first bytes (not by its name or the browser's claim), fsynced and renamed into place. The same bytes
|
|
|
|
|
uploaded twice (same sha256 and size) return the existing row.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import contextlib
|
|
|
|
|
import hashlib
|
|
|
|
|
import logging
|
|
|
|
|
import os
|
|
|
|
|
import re
|
|
|
|
|
import tempfile
|
|
|
|
|
import uuid
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Any, BinaryIO
|
|
|
|
|
|
|
|
|
|
from psycopg.rows import dict_row
|
2026-10-16 14:45:38 -06:00
|
|
|
from psycopg.types.json import Jsonb
|
2026-10-16 09:31:45 -06:00
|
|
|
|
|
|
|
|
from .. import audit
|
|
|
|
|
from ..api.errors import ApiError
|
|
|
|
|
from ..settings import Settings
|
|
|
|
|
from ..text import clean_text
|
|
|
|
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
CHUNK = 1024 * 1024
|
|
|
|
|
HEAD = 16
|
|
|
|
|
PDF = "application/pdf"
|
|
|
|
|
MAGIC: tuple[tuple[bytes, str], ...] = (
|
|
|
|
|
(b"%PDF-", PDF),
|
|
|
|
|
(b"\x89PNG\r\n\x1a\n", "image/png"),
|
|
|
|
|
(b"\xff\xd8\xff", "image/jpeg"),
|
|
|
|
|
)
|
|
|
|
|
EXT = {PDF: ".pdf", "image/png": ".png", "image/jpeg": ".jpg"}
|
|
|
|
|
NAME_MAX = 160
|
|
|
|
|
_UNSAFE_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def sniff(head: bytes) -> str | None:
|
|
|
|
|
"""The mime type from the first bytes, or None when it is not a type Packet Station stores."""
|
|
|
|
|
for magic, mime in MAGIC:
|
|
|
|
|
if head.startswith(magic):
|
|
|
|
|
return mime
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def safe_name(name: Any) -> str | None:
|
|
|
|
|
"""The original file name, cleaned: no path, no separators or reserved characters, at most 160 characters."""
|
|
|
|
|
text = clean_text(name, 1000)
|
|
|
|
|
text = text.replace("\\", "/").rsplit("/", 1)[-1]
|
|
|
|
|
text = _UNSAFE_NAME.sub("_", text).strip(" .")
|
|
|
|
|
return text[:NAME_MAX] or None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def storage_key(asset_id: uuid.UUID | str) -> str:
|
|
|
|
|
s = str(asset_id)
|
|
|
|
|
return f"{s[:2]}/{s}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def path_of(settings: Settings, key: str) -> Path:
|
|
|
|
|
root = Path(settings.assets_dir).resolve()
|
|
|
|
|
p = (root / key).resolve()
|
|
|
|
|
if not p.is_relative_to(root):
|
|
|
|
|
raise ApiError(404, "not_found", "No such file.")
|
|
|
|
|
return p
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def pdf_pages(path: Path) -> int:
|
2026-10-16 14:45:38 -06:00
|
|
|
"""Page count through pypdfium2, or pypdf when pypdfium2 is missing or cannot read the file.
|
|
|
|
|
|
|
|
|
|
422 bad_pdf when neither parses it or it has no pages.
|
|
|
|
|
"""
|
|
|
|
|
n = _pages_pdfium(path)
|
|
|
|
|
if n is None:
|
|
|
|
|
n = _pages_pypdf(path)
|
|
|
|
|
if n < 1:
|
|
|
|
|
raise ApiError(422, "bad_pdf", "That PDF has no pages.")
|
|
|
|
|
return n
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _pages_pdfium(path: Path) -> int | None:
|
|
|
|
|
try:
|
|
|
|
|
import pypdfium2
|
|
|
|
|
except ImportError:
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
doc = pypdfium2.PdfDocument(str(path))
|
|
|
|
|
except Exception as e: # any pdfium failure falls back to pypdf
|
|
|
|
|
log.info("pypdfium2 could not read the PDF (%s); trying pypdf", type(e).__name__)
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
return len(doc)
|
|
|
|
|
finally:
|
|
|
|
|
doc.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _pages_pypdf(path: Path) -> int:
|
2026-10-16 09:31:45 -06:00
|
|
|
from pypdf import PdfReader
|
|
|
|
|
from pypdf.errors import PyPdfError
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
n = len(PdfReader(str(path)).pages)
|
|
|
|
|
except (PyPdfError, ValueError, KeyError, TypeError, OSError, IndexError, AttributeError) as e:
|
|
|
|
|
log.info("upload is not a readable PDF: %s", type(e).__name__)
|
|
|
|
|
raise ApiError(422, "bad_pdf", "That PDF could not be read.") from None
|
|
|
|
|
return n
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def public(row: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"id": str(row["id"]),
|
|
|
|
|
"mime": row["mime"],
|
|
|
|
|
"size": int(row["size"]),
|
|
|
|
|
"sha256": bytes(row["sha256"]).hex(),
|
|
|
|
|
"pages": row["pages"],
|
|
|
|
|
"original_name": row["original_name"],
|
|
|
|
|
"url": f"/_blob/{row['id']}",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get(conn: Any, asset_id: Any) -> dict[str, Any] | None:
|
|
|
|
|
try:
|
|
|
|
|
aid = uuid.UUID(str(asset_id))
|
|
|
|
|
except ValueError:
|
|
|
|
|
return None
|
|
|
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
|
|
|
|
r = cur.execute("SELECT * FROM assets WHERE id = %s", (aid,)).fetchone()
|
|
|
|
|
return dict(r) if r else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fsync_dir(path: Path) -> None:
|
|
|
|
|
if os.name != "posix":
|
|
|
|
|
return
|
|
|
|
|
fd = os.open(str(path), os.O_RDONLY)
|
|
|
|
|
try:
|
|
|
|
|
os.fsync(fd)
|
|
|
|
|
finally:
|
|
|
|
|
os.close(fd)
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 14:45:38 -06:00
|
|
|
def _existing(conn: Any, sha: bytes, size: int) -> dict[str, Any] | None:
|
|
|
|
|
"""The stored asset with the same bytes (same sha256 and size), oldest first."""
|
|
|
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
|
|
|
|
r = cur.execute(
|
|
|
|
|
"SELECT * FROM assets WHERE sha256 = %s AND size = %s ORDER BY uploaded_at LIMIT 1", (sha, size)
|
|
|
|
|
).fetchone()
|
|
|
|
|
return dict(r) if r else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _place(settings: Settings, tmp: Path) -> tuple[uuid.UUID, str, Path]:
|
|
|
|
|
"""Rename a fsynced file to ASSETS_DIR/<id[:2]>/<id>, then fsync the directory. Returns (id, key, path)."""
|
|
|
|
|
aid = uuid.uuid4()
|
|
|
|
|
key = storage_key(aid)
|
|
|
|
|
final = path_of(settings, key)
|
|
|
|
|
final.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
os.replace(tmp, final)
|
|
|
|
|
_fsync_dir(final.parent)
|
|
|
|
|
return aid, key, final
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _insert(conn: Any, values: tuple[Any, ...]) -> dict[str, Any]:
|
|
|
|
|
"""Insert one assets row from (id, sha256, size, mime, name, key, source, source_ref, pages, by, packet_id)."""
|
|
|
|
|
v = list(values)
|
|
|
|
|
if v[7] is not None and not isinstance(v[7], Jsonb):
|
|
|
|
|
v[7] = Jsonb(v[7])
|
|
|
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
|
|
|
|
row = cur.execute(
|
|
|
|
|
"INSERT INTO assets (id, sha256, size, mime, original_name, storage_key, source, source_ref, pages,"
|
|
|
|
|
" uploaded_by, packet_id) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING *",
|
|
|
|
|
v,
|
|
|
|
|
).fetchone()
|
|
|
|
|
return dict(row)
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 09:31:45 -06:00
|
|
|
def store_upload(
|
|
|
|
|
conn: Any,
|
|
|
|
|
settings: Settings,
|
|
|
|
|
stream: BinaryIO,
|
|
|
|
|
*,
|
|
|
|
|
original_name: Any,
|
|
|
|
|
user: Any,
|
|
|
|
|
packet_id: int | None = None,
|
|
|
|
|
source: str = "upload",
|
|
|
|
|
source_ref: Any = None,
|
|
|
|
|
actor: Any = None,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""Store one upload and return its public row. Raises 413 too_large, 415 unsupported_type, 422 bad_pdf.
|
|
|
|
|
|
|
|
|
|
`user` is the uploader's user id; `actor` (the session Principal) is used for the audit row when given.
|
|
|
|
|
"""
|
|
|
|
|
root = Path(settings.assets_dir)
|
|
|
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
limit = int(settings.upload_max_bytes)
|
|
|
|
|
fd, tmp_name = tempfile.mkstemp(prefix=".up-", dir=str(root))
|
|
|
|
|
tmp = Path(tmp_name)
|
|
|
|
|
keep = False
|
|
|
|
|
placed: Path | None = None
|
|
|
|
|
try:
|
|
|
|
|
digest = hashlib.sha256()
|
|
|
|
|
size = 0
|
|
|
|
|
head = b""
|
|
|
|
|
mime: str | None = None
|
|
|
|
|
with os.fdopen(fd, "wb") as out:
|
|
|
|
|
while True:
|
|
|
|
|
chunk = stream.read(CHUNK)
|
|
|
|
|
if not chunk:
|
|
|
|
|
break
|
|
|
|
|
size += len(chunk)
|
|
|
|
|
if size > limit:
|
|
|
|
|
raise ApiError(413, "too_large", f"The file is over the {limit // (1024 * 1024)} MB limit.")
|
|
|
|
|
if mime is None and len(head) < HEAD:
|
|
|
|
|
head += chunk[: HEAD - len(head)]
|
|
|
|
|
if len(head) >= HEAD:
|
|
|
|
|
mime = sniff(head)
|
|
|
|
|
if mime is None:
|
|
|
|
|
raise ApiError(415, "unsupported_type", "Only PDF, PNG and JPEG files can be stored.")
|
|
|
|
|
digest.update(chunk)
|
|
|
|
|
out.write(chunk)
|
|
|
|
|
out.flush()
|
|
|
|
|
os.fsync(out.fileno())
|
|
|
|
|
if mime is None:
|
|
|
|
|
mime = sniff(head)
|
|
|
|
|
if mime is None or size == 0:
|
|
|
|
|
raise ApiError(415, "unsupported_type", "Only PDF, PNG and JPEG files can be stored.")
|
|
|
|
|
sha = digest.digest()
|
2026-10-16 14:45:38 -06:00
|
|
|
existing = _existing(conn, sha, size)
|
2026-10-16 09:31:45 -06:00
|
|
|
if existing is not None:
|
2026-10-16 14:45:38 -06:00
|
|
|
return public(existing)
|
2026-10-16 09:31:45 -06:00
|
|
|
pages = pdf_pages(tmp) if mime == PDF else None
|
2026-10-16 14:45:38 -06:00
|
|
|
aid, key, placed = _place(settings, tmp)
|
2026-10-16 09:31:45 -06:00
|
|
|
keep = True
|
|
|
|
|
name = safe_name(original_name)
|
|
|
|
|
with conn.transaction():
|
2026-10-16 14:45:38 -06:00
|
|
|
row = _insert(conn, (aid, sha, size, mime, name, key, source, source_ref, pages, user, packet_id))
|
2026-10-16 09:31:45 -06:00
|
|
|
audit.append(
|
|
|
|
|
conn,
|
|
|
|
|
action="asset.uploaded",
|
|
|
|
|
user_id=user,
|
|
|
|
|
session_id=getattr(actor, "session_id", None),
|
|
|
|
|
role_snapshot=getattr(actor, "role_snapshot", None),
|
|
|
|
|
subject_type="asset",
|
|
|
|
|
subject_id=str(aid),
|
|
|
|
|
packet_id=packet_id,
|
|
|
|
|
detail={"mime": mime, "size": size, "pages": pages, "sha256": sha.hex()},
|
|
|
|
|
ip=getattr(actor, "ip", None),
|
|
|
|
|
)
|
|
|
|
|
return public(dict(row))
|
|
|
|
|
except BaseException:
|
|
|
|
|
if placed is not None: # the row was not written: do not leave an orphan file
|
|
|
|
|
with contextlib.suppress(OSError):
|
|
|
|
|
placed.unlink()
|
|
|
|
|
raise
|
|
|
|
|
finally:
|
|
|
|
|
if not keep:
|
|
|
|
|
with contextlib.suppress(OSError):
|
|
|
|
|
tmp.unlink()
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 14:45:38 -06:00
|
|
|
def store_file(
|
|
|
|
|
conn: Any,
|
|
|
|
|
settings: Settings,
|
|
|
|
|
path: Path,
|
|
|
|
|
*,
|
|
|
|
|
mime: str = PDF,
|
|
|
|
|
original_name: Any,
|
|
|
|
|
source: str,
|
|
|
|
|
source_ref: Any = None,
|
|
|
|
|
uploaded_by: Any = None,
|
|
|
|
|
packet_id: int | None = None,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""Store a file the server wrote itself (the worker's M-Files download). Returns the full assets row.
|
|
|
|
|
|
|
|
|
|
The file is consumed: renamed into ASSETS_DIR when its bytes are new, deleted when the same bytes are already
|
|
|
|
|
stored (same sha256 and size; that row comes back). Write it under ASSETS_DIR (ASSETS_DIR/tmp) so the rename
|
|
|
|
|
stays on one file system. It is hashed in chunks, its first bytes must match `mime`, and it is fsynced before
|
|
|
|
|
the rename. The row carries one extra key, `reused`. No audit row: the caller writes its own.
|
|
|
|
|
Raises 413 too_large, 415 unsupported_type, 422 bad_pdf.
|
|
|
|
|
"""
|
|
|
|
|
path = Path(path)
|
|
|
|
|
limit = int(settings.upload_max_bytes)
|
|
|
|
|
placed: Path | None = None
|
|
|
|
|
done = False
|
|
|
|
|
try:
|
|
|
|
|
digest = hashlib.sha256()
|
|
|
|
|
size = 0
|
|
|
|
|
head = b""
|
|
|
|
|
with path.open("r+b") as f:
|
|
|
|
|
while True:
|
|
|
|
|
chunk = f.read(CHUNK)
|
|
|
|
|
if not chunk:
|
|
|
|
|
break
|
|
|
|
|
if len(head) < HEAD:
|
|
|
|
|
head += chunk[: HEAD - len(head)]
|
|
|
|
|
size += len(chunk)
|
|
|
|
|
if size > limit:
|
|
|
|
|
raise ApiError(413, "too_large", f"The file is over the {limit // (1024 * 1024)} MB limit.")
|
|
|
|
|
digest.update(chunk)
|
|
|
|
|
os.fsync(f.fileno())
|
|
|
|
|
if size == 0 or sniff(head) != mime:
|
|
|
|
|
raise ApiError(415, "unsupported_type", f"The file is not {EXT.get(mime, mime)}.")
|
|
|
|
|
sha = digest.digest()
|
|
|
|
|
existing = _existing(conn, sha, size)
|
|
|
|
|
if existing is not None:
|
|
|
|
|
return {**existing, "reused": True}
|
|
|
|
|
pages = pdf_pages(path) if mime == PDF else None
|
|
|
|
|
aid, key, placed = _place(settings, path)
|
|
|
|
|
name = safe_name(original_name)
|
|
|
|
|
with conn.transaction():
|
|
|
|
|
row = _insert(conn, (aid, sha, size, mime, name, key, source, source_ref, pages, uploaded_by, packet_id))
|
|
|
|
|
done = True
|
|
|
|
|
return {**row, "reused": False}
|
|
|
|
|
except BaseException:
|
|
|
|
|
if placed is not None and not done: # the row was not written: do not leave an orphan file
|
|
|
|
|
with contextlib.suppress(OSError):
|
|
|
|
|
placed.unlink()
|
|
|
|
|
raise
|
|
|
|
|
finally:
|
|
|
|
|
with contextlib.suppress(OSError):
|
|
|
|
|
path.unlink()
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 09:31:45 -06:00
|
|
|
def open_blob(settings: Settings, row: dict[str, Any]) -> Path:
|
|
|
|
|
"""The stored file of an asset row; 404 when it is missing on disk."""
|
|
|
|
|
p = path_of(settings, str(row["storage_key"]))
|
|
|
|
|
if not p.is_file():
|
|
|
|
|
log.error("asset %s is missing on disk", row["id"])
|
|
|
|
|
raise ApiError(404, "not_found", "No such file.")
|
|
|
|
|
return p
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def download_name(row: dict[str, Any]) -> str:
|
|
|
|
|
"""A safe file name for Content-Disposition (ASCII only, quotes removed)."""
|
|
|
|
|
name = row.get("original_name") or ""
|
|
|
|
|
name = name.encode("ascii", "ignore").decode("ascii").replace('"', "").replace(";", "_").strip()
|
|
|
|
|
if not name:
|
|
|
|
|
name = str(row["id"]) + EXT.get(str(row["mime"]), "")
|
|
|
|
|
return name
|