From 7e528ca98929a941d9497ee543aaf84ab83d2f92 Mon Sep 17 00:00:00 2001 From: Jose David Date: Wed, 23 Sep 2026 16:54:46 +0200 Subject: [PATCH 1/4] feat(storage): content-addressed blob store, metadata scrubbing and renditions Uploads are cleaned at the container level so the compressed image data stays byte-for-byte the original: JPEG APPn and COM segments and PNG text, EXIF and time chunks are dropped, keeping JFIF and ICC; capture time and orientation are extracted first. Formats browsers cannot show are re-encoded as JPEG and marked as such. Blobs are written atomically under a hash-keyed tree with a free-space guard; renditions (2048, 1024, 256 px) are upright, metadata-free progressive JPEGs. Signed-off-by: Jose David --- backend/pyproject.toml | 5 +- backend/src/nevus/config.py | 3 + backend/src/nevus/storage/__init__.py | 1 + backend/src/nevus/storage/blobs.py | 73 +++++++++++ backend/src/nevus/storage/renditions.py | 51 ++++++++ backend/src/nevus/storage/scrub.py | 161 ++++++++++++++++++++++++ backend/uv.lock | 54 ++++++++ 7 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 backend/src/nevus/storage/__init__.py create mode 100644 backend/src/nevus/storage/blobs.py create mode 100644 backend/src/nevus/storage/renditions.py create mode 100644 backend/src/nevus/storage/scrub.py diff --git a/backend/pyproject.toml b/backend/pyproject.toml index e2f49d6..756a272 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -14,6 +14,9 @@ dependencies = [ "argon2-cffi>=23.1", "structlog>=24.4", "psycopg[binary]>=3.2", + "pillow>=11.0", + "pillow-heif>=1.0", + "python-multipart>=0.0.20", ] [project.scripts] @@ -55,7 +58,7 @@ packages = ["nevus"] plugins = ["pydantic.mypy"] [[tool.mypy.overrides]] -module = ["argon2.*"] +module = ["argon2.*", "pillow_heif.*"] ignore_missing_imports = true [tool.pytest.ini_options] diff --git a/backend/src/nevus/config.py b/backend/src/nevus/config.py index 762179f..6f2acf1 100644 --- a/backend/src/nevus/config.py +++ b/backend/src/nevus/config.py @@ -35,6 +35,9 @@ class Settings(BaseSettings): login_attempts: int = Field(default=10, ge=3, le=100, description="Failed logins allowed per window") login_window_minutes: int = Field(default=15, ge=1, le=1440) trust_proxy_headers: bool = Field(default=False, description="Trust X-Forwarded-Proto/For from a reverse proxy") + max_upload_bytes: int = Field(default=30 * 1024 * 1024, ge=1024 * 1024) + max_upload_pixels: int = Field(default=24_000_000, ge=1_000_000) + min_free_bytes: int = Field(default=2 * 1024**3, ge=0, description="Refuse uploads below this free space") @field_validator("allowed_hosts", mode="before") @classmethod diff --git a/backend/src/nevus/storage/__init__.py b/backend/src/nevus/storage/__init__.py new file mode 100644 index 0000000..89dc7cf --- /dev/null +++ b/backend/src/nevus/storage/__init__.py @@ -0,0 +1 @@ +"""Image storage: metadata scrubbing, the content-addressed blob store and renditions.""" diff --git a/backend/src/nevus/storage/blobs.py b/backend/src/nevus/storage/blobs.py new file mode 100644 index 0000000..a571e0d --- /dev/null +++ b/backend/src/nevus/storage/blobs.py @@ -0,0 +1,73 @@ +"""Content-addressed blob store on the data volume. + +Files are named by the SHA-256 of their bytes under `blobs/originals/ab/cd/` (immutable, scrubbed +originals) and `blobs/derived/ab/cd/` (regenerable renditions). Writes are atomic renames from a +temporary file on the same filesystem, so a crash never leaves a half-written blob; directories are keyed +by hash, never by person, so the tree reveals nothing about who is who. +""" + +from __future__ import annotations + +import hashlib +import os +import shutil +import tempfile +from pathlib import Path + +MIN_FREE_BYTES = 2 * 1024**3 + + +class InsufficientStorageError(OSError): + """The data volume is nearly full; refusing to write.""" + + +def sha256_hex(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +class BlobStore: + def __init__(self, root: Path, min_free_bytes: int = MIN_FREE_BYTES) -> None: + self.root = root + self.min_free_bytes = min_free_bytes + (root / "originals").mkdir(parents=True, exist_ok=True) + (root / "derived").mkdir(parents=True, exist_ok=True) + (root / "tmp").mkdir(parents=True, exist_ok=True) + + def path(self, sha256: str, derived: bool = False) -> Path: + kind = "derived" if derived else "originals" + return self.root / kind / sha256[:2] / sha256[2:4] / sha256 + + def exists(self, sha256: str, derived: bool = False) -> bool: + return self.path(sha256, derived).is_file() + + def free_bytes(self) -> int: + return shutil.disk_usage(self.root).free + + def put(self, data: bytes, derived: bool = False) -> str: + """Store bytes, returning their hash; identical content is written once.""" + digest = sha256_hex(data) + target = self.path(digest, derived) + if target.is_file(): + return digest + if self.free_bytes() - len(data) < self.min_free_bytes: + raise InsufficientStorageError("Less than the reserved free space would remain on the data volume.") + target.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(dir=self.root / "tmp") + try: + with os.fdopen(fd, "wb") as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + os.chmod(tmp_name, 0o640) + os.replace(tmp_name, target) + except BaseException: + Path(tmp_name).unlink(missing_ok=True) + raise + return digest + + def delete(self, sha256: str, derived: bool = False) -> None: + self.path(sha256, derived).unlink(missing_ok=True) + + def verify(self, sha256: str, derived: bool = False) -> bool: + path = self.path(sha256, derived) + return path.is_file() and sha256_hex(path.read_bytes()) == sha256 diff --git a/backend/src/nevus/storage/renditions.py b/backend/src/nevus/storage/renditions.py new file mode 100644 index 0000000..4b0caa5 --- /dev/null +++ b/backend/src/nevus/storage/renditions.py @@ -0,0 +1,51 @@ +"""Renditions: orientation-normalised, metadata-free JPEGs at fixed sizes, regenerable from the original.""" + +from __future__ import annotations + +import io +from dataclasses import dataclass + +from PIL import Image + +SIZES: dict[str, int] = {"full": 2048, "preview": 1024, "thumb": 256} +QUALITY = 85 + + +@dataclass(frozen=True) +class Rendition: + kind: str + data: bytes + width: int + height: int + mime: str = "image/jpeg" + + +# EXIF orientation values and the transpose that puts the image upright +TRANSPOSE = { + 2: Image.Transpose.FLIP_LEFT_RIGHT, + 3: Image.Transpose.ROTATE_180, + 4: Image.Transpose.FLIP_TOP_BOTTOM, + 5: Image.Transpose.TRANSPOSE, + 6: Image.Transpose.ROTATE_270, + 7: Image.Transpose.TRANSVERSE, + 8: Image.Transpose.ROTATE_90, +} + + +def make_renditions( + original: bytes, orientation: int = 1, kinds: tuple[str, ...] = ("full", "preview", "thumb") +) -> list[Rendition]: + """The original has had its metadata removed, so the orientation recorded at ingest is applied explicitly.""" + with Image.open(io.BytesIO(original)) as image: + image.draft("RGB", (SIZES[kinds[0]], SIZES[kinds[0]])) # cheaper JPEG decode when only a smaller size is needed + upright = image.transpose(TRANSPOSE[orientation]) if orientation in TRANSPOSE else image + rgb = upright.convert("RGB") + out = [] + for kind in kinds: + longest = SIZES[kind] + copy = rgb.copy() + copy.thumbnail((longest, longest), Image.Resampling.LANCZOS) + buffer = io.BytesIO() + copy.save(buffer, format="JPEG", quality=QUALITY, optimize=True, progressive=True) + out.append(Rendition(kind, buffer.getvalue(), copy.width, copy.height)) + return out diff --git a/backend/src/nevus/storage/scrub.py b/backend/src/nevus/storage/scrub.py new file mode 100644 index 0000000..950caab --- /dev/null +++ b/backend/src/nevus/storage/scrub.py @@ -0,0 +1,161 @@ +"""Remove every metadata segment from an uploaded photograph without touching its pixels. + +Phones embed GPS coordinates, serial numbers, maker notes and thumbnails in every file. The privacy policy +keeps only the capture time and the orientation, both extracted here before the metadata is dropped. JPEG +and PNG are cleaned at the container level, so the compressed image data is byte-for-byte the original; +formats browsers cannot display (HEIC) are decoded and re-encoded as JPEG, which the record notes. +""" + +from __future__ import annotations + +import io +import struct +from dataclasses import dataclass +from datetime import UTC, datetime + +from PIL import Image, ImageFile + +ImageFile.LOAD_TRUNCATED_IMAGES = False +Image.MAX_IMAGE_PIXELS = 60_000_000 # decompression-bomb guard; the API enforces its own lower limit + +EXIF_DATETIME_ORIGINAL = 0x9003 +EXIF_DATETIME = 0x0132 +EXIF_ORIENTATION = 0x0112 +EXIF_IFD = 0x8769 + +JPEG_KEEP = { + 0xC0, + 0xC1, + 0xC2, + 0xC3, + 0xC5, + 0xC6, + 0xC7, + 0xC9, + 0xCA, + 0xCB, + 0xCD, + 0xCE, + 0xCF, # SOF + 0xC4, + 0xCC, + 0xDB, + 0xDD, + 0xE0, +} # DHT, DAC, DQT, DRI, APP0 (JFIF) +JPEG_APP2 = 0xE2 +JPEG_SOS = 0xDA +JPEG_EOI = 0xD9 +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" +PNG_DROP = {b"tEXt", b"zTXt", b"iTXt", b"eXIf", b"tIME"} + + +class UnsupportedImageError(ValueError): + """The bytes are not an image we accept.""" + + +@dataclass(frozen=True) +class Scrubbed: + data: bytes + mime: str + width: int + height: int + orientation: int + captured_at: datetime | None + source_format: str + re_encoded: bool + + +def scrub(data: bytes) -> Scrubbed: + try: + with Image.open(io.BytesIO(data)) as image: + image.verify() + with Image.open(io.BytesIO(data)) as image: + source_format = (image.format or "").upper() + width, height = image.size + exif = image.getexif() + orientation = int(exif.get(EXIF_ORIENTATION, 1) or 1) + captured_at = _capture_time(exif) + if source_format == "JPEG": + return Scrubbed(strip_jpeg(data), "image/jpeg", width, height, orientation, captured_at, "JPEG", False) + if source_format == "PNG": + return Scrubbed(strip_png(data), "image/png", width, height, orientation, captured_at, "PNG", False) + # anything else (HEIC, WebP, TIFF...) is re-encoded as a high-quality JPEG without metadata + converted = image.convert("RGB") + buffer = io.BytesIO() + converted.save(buffer, format="JPEG", quality=95, subsampling=0, optimize=True) + return Scrubbed( + buffer.getvalue(), + "image/jpeg", + width, + height, + orientation, + captured_at, + source_format or "UNKNOWN", + True, + ) + except (OSError, SyntaxError, ValueError) as error: + raise UnsupportedImageError("The file is not a readable image.") from error + + +def _capture_time(exif: Image.Exif) -> datetime | None: + raw = None + try: + raw = exif.get_ifd(EXIF_IFD).get(EXIF_DATETIME_ORIGINAL) + except Exception: + raw = None + raw = raw or exif.get(EXIF_DATETIME) + if not raw or not isinstance(raw, str): + return None + for fmt in ("%Y:%m:%d %H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y:%m:%d %H:%M"): + try: + return datetime.strptime(raw.strip("\x00 "), fmt).replace(tzinfo=UTC) + except ValueError: + continue + return None + + +def strip_jpeg(data: bytes) -> bytes: + """Drop APPn (except JFIF and ICC), COM and other metadata segments; copy the scan data verbatim.""" + if data[:2] != b"\xff\xd8": + raise UnsupportedImageError("Not a JPEG.") + out = bytearray(b"\xff\xd8") + pos = 2 + while pos + 4 <= len(data): + if data[pos] != 0xFF: + raise UnsupportedImageError("Corrupt JPEG marker structure.") + marker = data[pos + 1] + if marker == 0xFF: # fill byte + pos += 1 + continue + if marker == JPEG_SOS: + out += data[pos:] # entropy-coded data, possibly more scans and the EOI marker + return bytes(out) + if marker == JPEG_EOI: + out += data[pos : pos + 2] + return bytes(out) + length = struct.unpack(">H", data[pos + 2 : pos + 4])[0] + segment = data[pos : pos + 2 + length] + keep = marker in JPEG_KEEP or (marker == JPEG_APP2 and segment[4:16] == b"ICC_PROFILE\x00") + if keep: + out += segment + pos += 2 + length + raise UnsupportedImageError("JPEG ended before its image data.") + + +def strip_png(data: bytes) -> bytes: + """Drop textual, EXIF and time chunks; keep everything needed to decode and colour-manage the image.""" + if data[:8] != PNG_SIGNATURE: + raise UnsupportedImageError("Not a PNG.") + out = bytearray(PNG_SIGNATURE) + pos = 8 + while pos + 8 <= len(data): + length = struct.unpack(">I", data[pos : pos + 4])[0] + kind = data[pos + 4 : pos + 8] + end = pos + 12 + length + if kind not in PNG_DROP: + out += data[pos:end] + pos = end + if kind == b"IEND": + break + return bytes(out) diff --git a/backend/uv.lock b/backend/uv.lock index 8d89ec1..dc41c11 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -408,8 +408,11 @@ dependencies = [ { name = "alembic" }, { name = "argon2-cffi" }, { name = "fastapi" }, + { name = "pillow" }, + { name = "pillow-heif" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic-settings" }, + { name = "python-multipart" }, { name = "sqlalchemy" }, { name = "structlog" }, { name = "uvicorn", extra = ["standard"] }, @@ -429,8 +432,11 @@ requires-dist = [ { name = "alembic", specifier = ">=1.14" }, { name = "argon2-cffi", specifier = ">=23.1" }, { name = "fastapi", specifier = ">=0.120" }, + { name = "pillow", specifier = ">=11.0" }, + { name = "pillow-heif", specifier = ">=1.0" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "pydantic-settings", specifier = ">=2.6" }, + { name = "python-multipart", specifier = ">=0.0.20" }, { name = "sqlalchemy", specifier = ">=2.0.40" }, { name = "structlog", specifier = ">=24.4" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34" }, @@ -463,6 +469,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, +] + +[[package]] +name = "pillow-heif" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/4c/d5319a1f276c70528ff97893afc42a300ff28029e27ca8de89bb3b271680/pillow_heif-1.8.0.tar.gz", hash = "sha256:e47c27432c6fd3d66c22f0de9f27fd379383b646c947520bc485854ce72060d0", size = 17395353, upload-time = "2026-09-22T10:04:17.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/9f/2c601980b4cacc1cb44dfb9f8db88c67dc14adb77d544186f9cde8ca70e5/pillow_heif-1.8.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:01aeb134dbd99a6b2cbadffd312693c29dff6413c98ac6000674100b827c09ae", size = 4781323, upload-time = "2026-09-22T10:02:59.006Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/e1aa6d072d4778821d6cb81763dd1993527d4f2062be701360fe4d9f853c/pillow_heif-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ec5ff22dac945f169d04b18b870f369e439370ba919c0f15ea3a37ac40048fb7", size = 4303427, upload-time = "2026-09-22T10:03:00.491Z" }, + { url = "https://files.pythonhosted.org/packages/11/7c/b5f71083cfbeae902bcf615062c51e8795aa7e1feeedea663521a510227e/pillow_heif-1.8.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34d2cae783350949fb204d29ce85e50d6059c4efbf310fe473585a78f2790add", size = 6394506, upload-time = "2026-09-22T10:03:02.252Z" }, + { url = "https://files.pythonhosted.org/packages/a6/bb/e48ff21447a2b213eaaad90f67e473c59396d33a8ed8c227b24deeebc31d/pillow_heif-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06db8d10f000438ba605fbd12f4dffed4b792afe9c923cf4c589d04025fcc6b4", size = 5652131, upload-time = "2026-09-22T10:03:04.143Z" }, + { url = "https://files.pythonhosted.org/packages/d8/3e/cf7104e89572eee71549f7898aefa28fd396922d41939d4af8bbcd11f388/pillow_heif-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6ca3739b5ad2cfb1b6a1cb631a7b21c4f6219aeb95c883be5b540e6d29d1ca4b", size = 7427115, upload-time = "2026-09-22T10:03:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/d9/88/3abaf871d71d92e27ff59eb95f69669886108478fa955d4306664a67e451/pillow_heif-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f150de06a4387644df54fb3a6162264825b99cbabfd804ca3154fb867a35ab16", size = 6687026, upload-time = "2026-09-22T10:03:07.839Z" }, + { url = "https://files.pythonhosted.org/packages/71/77/fa70118bafb15584e49cba1af544d5be6756b5bfaca76ce25d42481bf4c2/pillow_heif-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:70161d9963702d94bbaa31b2e08e43f6fb3baffccfac8c9ec1d702f6ff620cde", size = 6604416, upload-time = "2026-09-22T10:03:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/11/bd/a30b115ea07917a0b668ef315c7e54c7e34a966107869529f6253ac529fa/pillow_heif-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:18057aa9b02d2f47e7dbe8145d90b12754e2fd37679cfce983c135cd9e1bda24", size = 3868201, upload-time = "2026-09-22T10:03:11.124Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -619,6 +664,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" From e8a38653ebbb2b31dd916cb03c5f48c944e59bb2 Mon Sep 17 00:00:00 2001 From: Jose David Date: Wed, 23 Sep 2026 16:54:46 +0200 Subject: [PATCH 2/4] feat(db): add images and renditions Images belong to a person and record role, modality, hash, size, orientation, source format, whether they were re-encoded and the capture time; renditions are unique per image and kind. Signed-off-by: Jose David --- ...0923_b85ca8527eb6_images_and_renditions.py | 85 +++++++++++++++++++ backend/src/nevus/db/models.py | 53 +++++++++++- 2 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/20260923_b85ca8527eb6_images_and_renditions.py diff --git a/backend/alembic/versions/20260923_b85ca8527eb6_images_and_renditions.py b/backend/alembic/versions/20260923_b85ca8527eb6_images_and_renditions.py new file mode 100644 index 0000000..844f9c7 --- /dev/null +++ b/backend/alembic/versions/20260923_b85ca8527eb6_images_and_renditions.py @@ -0,0 +1,85 @@ +"""images and renditions + +Revision ID: b85ca8527eb6 +Revises: 57fafd32a1da +Create Date: 2026-09-23 16:52:18.726916 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision: str = "b85ca8527eb6" +down_revision: str | None = "57fafd32a1da" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "images", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("person_id", sa.Uuid(), nullable=False), + sa.Column("role", sa.String(length=16), nullable=False), + sa.Column("modality", sa.String(length=16), nullable=False), + sa.Column("sha256", sa.String(length=64), nullable=False), + sa.Column("bytes", sa.BigInteger(), nullable=False), + sa.Column("mime", sa.String(length=64), nullable=False), + sa.Column("width", sa.Integer(), nullable=False), + sa.Column("height", sa.Integer(), nullable=False), + sa.Column("orientation", sa.Integer(), nullable=False), + sa.Column("source_format", sa.String(length=16), nullable=False), + sa.Column("re_encoded", sa.Boolean(), nullable=False), + sa.Column("captured_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("captured_tz", sa.String(length=64), nullable=True), + sa.Column("created_by", sa.Uuid(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["created_by"], ["users.id"], name=op.f("fk_images_created_by_users"), ondelete="SET NULL" + ), + sa.ForeignKeyConstraint( + ["person_id"], ["persons.id"], name=op.f("fk_images_person_id_persons"), ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_images")), + ) + with op.batch_alter_table("images", schema=None) as batch_op: + batch_op.create_index("ix_images_person_id", ["person_id"], unique=False) + batch_op.create_index("ix_images_sha256", ["sha256"], unique=False) + + op.create_table( + "renditions", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("image_id", sa.Uuid(), nullable=False), + sa.Column("kind", sa.String(length=16), nullable=False), + sa.Column("sha256", sa.String(length=64), nullable=False), + sa.Column("bytes", sa.BigInteger(), nullable=False), + sa.Column("mime", sa.String(length=64), nullable=False), + sa.Column("width", sa.Integer(), nullable=False), + sa.Column("height", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["image_id"], ["images.id"], name=op.f("fk_renditions_image_id_images"), ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_renditions")), + ) + with op.batch_alter_table("renditions", schema=None) as batch_op: + batch_op.create_index("ix_renditions_image_id_kind", ["image_id", "kind"], unique=True) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("renditions", schema=None) as batch_op: + batch_op.drop_index("ix_renditions_image_id_kind") + + op.drop_table("renditions") + with op.batch_alter_table("images", schema=None) as batch_op: + batch_op.drop_index("ix_images_sha256") + batch_op.drop_index("ix_images_person_id") + + op.drop_table("images") + # ### end Alembic commands ### diff --git a/backend/src/nevus/db/models.py b/backend/src/nevus/db/models.py index 3e9d61e..02cedb6 100644 --- a/backend/src/nevus/db/models.py +++ b/backend/src/nevus/db/models.py @@ -6,7 +6,7 @@ from datetime import datetime from typing import Any -from sqlalchemy import JSON, Boolean, ForeignKey, Index, Integer, String, Uuid +from sqlalchemy import JSON, BigInteger, Boolean, ForeignKey, Index, Integer, String, Uuid from sqlalchemy.orm import Mapped, mapped_column, relationship from nevus.db.base import Base @@ -123,3 +123,54 @@ class Setting(Base): value: Mapped[dict[str, Any] | list[Any] | str | int | bool | None] = mapped_column(JSON) encrypted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) updated_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False, default=utcnow, onupdate=utcnow) + + +IMAGE_ROLES = ("overview", "close_up", "with_reference", "other") +IMAGE_MODALITIES = ("camera", "dermatoscope") +RENDITION_KINDS = ("full", "preview", "thumb") + + +class Image(Base): + """A stored photograph: the scrubbed original (by content hash) and what little metadata survives.""" + + __tablename__ = "images" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid7) + person_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("persons.id", ondelete="CASCADE"), nullable=False) + role: Mapped[str] = mapped_column(String(16), nullable=False, default="close_up") + modality: Mapped[str] = mapped_column(String(16), nullable=False, default="camera") + sha256: Mapped[str] = mapped_column(String(64), nullable=False) + bytes: Mapped[int] = mapped_column(BigInteger, nullable=False) + mime: Mapped[str] = mapped_column(String(64), nullable=False) + width: Mapped[int] = mapped_column(Integer, nullable=False) + height: Mapped[int] = mapped_column(Integer, nullable=False) + orientation: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + source_format: Mapped[str] = mapped_column(String(16), nullable=False) + re_encoded: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + captured_at: Mapped[datetime | None] = mapped_column(UTCDateTime) + captured_tz: Mapped[str | None] = mapped_column(String(64)) + created_by: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("users.id", ondelete="SET NULL")) + created_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False, default=utcnow) + deleted_at: Mapped[datetime | None] = mapped_column(UTCDateTime) + + renditions: Mapped[list[Rendition]] = relationship(back_populates="image", cascade="all, delete-orphan") + + __table_args__ = (Index("ix_images_person_id", "person_id"), Index("ix_images_sha256", "sha256")) + + +class Rendition(Base): + __tablename__ = "renditions" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid7) + image_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("images.id", ondelete="CASCADE"), nullable=False) + kind: Mapped[str] = mapped_column(String(16), nullable=False) + sha256: Mapped[str] = mapped_column(String(64), nullable=False) + bytes: Mapped[int] = mapped_column(BigInteger, nullable=False) + mime: Mapped[str] = mapped_column(String(64), nullable=False) + width: Mapped[int] = mapped_column(Integer, nullable=False) + height: Mapped[int] = mapped_column(Integer, nullable=False) + created_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False, default=utcnow) + + image: Mapped[Image] = relationship(back_populates="renditions") + + __table_args__ = (Index("ix_renditions_image_id_kind", "image_id", "kind", unique=True),) From 0977ad034e2eeb5c27eeb1aae196fc0392f12263 Mon Sep 17 00:00:00 2001 From: Jose David Date: Wed, 23 Sep 2026 16:54:46 +0200 Subject: [PATCH 3/4] feat(api): upload photographs and serve them to the people allowed to see them Owners and managers upload (size, pixel and free-space limits; unsupported files refused); everyone with access to the person fetches the original or a rendition with immutable private caching and ETags; deletion moves the image to the trash. Signed-off-by: Jose David --- backend/src/nevus/api/images.py | 214 ++++++++++++++++++++++++++++++++ backend/src/nevus/app.py | 4 + 2 files changed, 218 insertions(+) create mode 100644 backend/src/nevus/api/images.py diff --git a/backend/src/nevus/api/images.py b/backend/src/nevus/api/images.py new file mode 100644 index 0000000..168d01f --- /dev/null +++ b/backend/src/nevus/api/images.py @@ -0,0 +1,214 @@ +"""Uploading photographs and serving them to the people allowed to see them.""" + +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Annotated, Literal + +from fastapi import APIRouter, File, Form, HTTPException, Request, Response, UploadFile, status +from fastapi.responses import FileResponse +from pydantic import BaseModel, ConfigDict +from sqlalchemy import select +from sqlalchemy.orm import Session + +from nevus.auth import service +from nevus.auth.dependencies import AppSettings, CurrentUser, DbSession, client_ip +from nevus.db.models import ( + ACCESS_MANAGER, + ACCESS_OWNER, + IMAGE_MODALITIES, + IMAGE_ROLES, + Image, + Person, + PersonAccess, + Rendition, + User, +) +from nevus.db.types import utcnow +from nevus.storage.blobs import BlobStore, InsufficientStorageError +from nevus.storage.renditions import make_renditions +from nevus.storage.scrub import UnsupportedImageError, scrub + +router = APIRouter(prefix="/api", tags=["images"]) + +ImageRole = Literal["overview", "close_up", "with_reference", "other"] +Modality = Literal["camera", "dermatoscope"] +RenditionKind = Literal["original", "full", "preview", "thumb"] +IMMUTABLE_PRIVATE = "private, max-age=31536000, immutable" + + +class RenditionOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + kind: str + width: int + height: int + bytes: int + + +class ImageOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + person_id: uuid.UUID + role: str + modality: str + sha256: str + bytes: int + mime: str + width: int + height: int + orientation: int + source_format: str + re_encoded: bool + captured_at: datetime | None + created_at: datetime + renditions: list[RenditionOut] + + +def _store(request: Request) -> BlobStore: + store: BlobStore = request.app.state.blob_store + return store + + +def _person_for(db: Session, person_id: uuid.UUID, user: User, *roles: str) -> Person: + person = db.get(Person, person_id) + access = db.get(PersonAccess, (person_id, user.id)) if person else None + if person is None or person.deleted_at is not None or access is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "No such person.") + if roles and access.role not in roles: + raise HTTPException(status.HTTP_403_FORBIDDEN, "Your access to this person does not allow that.") + return person + + +@router.post("/persons/{person_id}/images", response_model=ImageOut, status_code=status.HTTP_201_CREATED) +async def upload_image( + person_id: uuid.UUID, + request: Request, + user: CurrentUser, + db: DbSession, + settings: AppSettings, + file: Annotated[UploadFile, File()], + role: Annotated[ImageRole, Form()] = "close_up", + modality: Annotated[Modality, Form()] = "camera", + captured_at: Annotated[datetime | None, Form()] = None, + captured_tz: Annotated[str | None, Form(max_length=64)] = None, +) -> ImageOut: + _person_for(db, person_id, user, ACCESS_OWNER, ACCESS_MANAGER) + data = await file.read(settings.max_upload_bytes + 1) + if len(data) > settings.max_upload_bytes: + raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "The photo is larger than the upload limit.") + if not data: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "The upload is empty.") + try: + scrubbed = scrub(data) + except UnsupportedImageError as error: + raise HTTPException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, str(error)) from error + if scrubbed.width * scrubbed.height > settings.max_upload_pixels: + raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "The photo has more pixels than the limit.") + store = _store(request) + try: + digest = store.put(scrubbed.data) + renditions = make_renditions(scrubbed.data, scrubbed.orientation) + stored = [(r, store.put(r.data, derived=True)) for r in renditions] + except InsufficientStorageError as error: + raise HTTPException(status.HTTP_507_INSUFFICIENT_STORAGE, "The data volume is nearly full.") from error + when = captured_at or scrubbed.captured_at + image = Image( + person_id=person_id, + role=role, + modality=modality, + sha256=digest, + bytes=len(scrubbed.data), + mime=scrubbed.mime, + width=scrubbed.width, + height=scrubbed.height, + orientation=scrubbed.orientation, + source_format=scrubbed.source_format, + re_encoded=scrubbed.re_encoded, + captured_at=when.astimezone() if when and when.tzinfo else when, + captured_tz=captured_tz, + created_by=user.id, + ) + db.add(image) + db.flush() + for rendition, rendition_digest in stored: + db.add( + Rendition( + image_id=image.id, + kind=rendition.kind, + sha256=rendition_digest, + bytes=len(rendition.data), + mime=rendition.mime, + width=rendition.width, + height=rendition.height, + ) + ) + db.flush() + db.refresh(image) + service.audit(db, "image.upload", user, "image", image.id, client_ip(request, settings), {"person": str(person_id)}) + return ImageOut.model_validate(image) + + +@router.get("/persons/{person_id}/images", response_model=list[ImageOut]) +def list_images(person_id: uuid.UUID, user: CurrentUser, db: DbSession) -> list[ImageOut]: + _person_for(db, person_id, user) + rows = db.scalars( + select(Image).where(Image.person_id == person_id, Image.deleted_at.is_(None)).order_by(Image.created_at.desc()) + ) + return [ImageOut.model_validate(i) for i in rows] + + +def _image_for(db: Session, image_id: uuid.UUID, user: User, *roles: str) -> Image: + image = db.get(Image, image_id) + if image is None or image.deleted_at is not None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "No such image.") + _person_for(db, image.person_id, user, *roles) + return image + + +@router.get("/images/{image_id}", response_model=ImageOut) +def get_image(image_id: uuid.UUID, user: CurrentUser, db: DbSession) -> ImageOut: + return ImageOut.model_validate(_image_for(db, image_id, user)) + + +@router.get("/images/{image_id}/{kind}", response_class=FileResponse) +def image_file( + image_id: uuid.UUID, kind: RenditionKind, request: Request, user: CurrentUser, db: DbSession +) -> Response: + """The bytes, only for people who may see the person; cacheable forever because the content is addressed by hash.""" + image = _image_for(db, image_id, user) + store = _store(request) + if kind == "original": + digest, mime = image.sha256, image.mime + path = store.path(digest) + else: + rendition = next((r for r in image.renditions if r.kind == kind), None) + if rendition is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "No such rendition.") + digest, mime = rendition.sha256, rendition.mime + path = store.path(digest, derived=True) + if request.headers.get("if-none-match") == f'"{digest}"': + return Response(status_code=status.HTTP_304_NOT_MODIFIED) + if not path.is_file(): + raise HTTPException(status.HTTP_404_NOT_FOUND, "The file is missing from the store.") + return FileResponse( + path, + media_type=mime, + headers={"Cache-Control": IMMUTABLE_PRIVATE, "ETag": f'"{digest}"', "Content-Disposition": "inline"}, + ) + + +@router.delete("/images/{image_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_image( + image_id: uuid.UUID, request: Request, user: CurrentUser, db: DbSession, settings: AppSettings +) -> Response: + """Moves the image to the trash; the purge and the garbage collection of blobs arrive with data management.""" + image = _image_for(db, image_id, user, ACCESS_OWNER, ACCESS_MANAGER) + image.deleted_at = utcnow() + service.audit(db, "image.delete", user, "image", image.id, client_ip(request, settings)) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +__all__ = ["IMAGE_MODALITIES", "IMAGE_ROLES", "router"] diff --git a/backend/src/nevus/app.py b/backend/src/nevus/app.py index 83e5181..1875fe1 100644 --- a/backend/src/nevus/app.py +++ b/backend/src/nevus/app.py @@ -11,6 +11,7 @@ from nevus import __version__ from nevus.api.auth import router as auth_router from nevus.api.health import router as health_router +from nevus.api.images import router as images_router from nevus.api.persons import router as persons_router from nevus.api.users import router as users_router from nevus.auth.ratelimit import LoginRateLimiter @@ -19,6 +20,7 @@ from nevus.db.engine import make_engine, make_session_factory from nevus.db.migrate import upgrade_to_head from nevus.logging import configure_logging, get_logger +from nevus.storage.blobs import BlobStore from nevus.web.csrf import CsrfMiddleware from nevus.web.security import HostAllowlistMiddleware, SecurityHeadersMiddleware from nevus.web.static import mount_frontend @@ -62,6 +64,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app.state.settings = settings app.state.engine = engine app.state.session_factory = session_factory + app.state.blob_store = BlobStore(settings.blobs_dir, settings.min_free_bytes) app.state.login_limiter = LoginRateLimiter(settings.login_attempts, settings.login_window_minutes * 60) app.add_middleware(SecurityHeadersMiddleware) @@ -72,6 +75,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app.include_router(auth_router) app.include_router(users_router) app.include_router(persons_router) + app.include_router(images_router) mount_frontend(app, _static_dir(settings)) return app From 8eddac710bf3ae192c779d790b399685e47fdffa Mon Sep 17 00:00:00 2001 From: Jose David Date: Wed, 23 Sep 2026 16:54:46 +0200 Subject: [PATCH 4/4] test: cover scrubbing, content addressing, renditions, image access and limits Signed-off-by: Jose David --- backend/tests/test_images.py | 202 +++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 backend/tests/test_images.py diff --git a/backend/tests/test_images.py b/backend/tests/test_images.py new file mode 100644 index 0000000..fec6001 --- /dev/null +++ b/backend/tests/test_images.py @@ -0,0 +1,202 @@ +"""Uploading photographs: metadata scrubbing, content addressing, renditions, access and limits.""" + +from __future__ import annotations + +import io +import struct +import zlib + +import pytest +from fastapi.testclient import TestClient +from PIL import Image +from PIL.TiffImagePlugin import IFDRational + +from nevus.storage.scrub import scrub, strip_jpeg, strip_png +from tests.test_accounts import ADMIN, MEMBER, claim + + +def jpeg_with_metadata(orientation: int = 6, size: tuple[int, int] = (640, 480)) -> bytes: + """A JPEG carrying capture time, orientation, GPS and a comment, like a phone would produce.""" + image = Image.new("RGB", size, (200, 120, 90)) + exif = Image.Exif() + exif[0x0112] = orientation + exif[0x0132] = "2026:09:12 10:41:07" + exif[0x010F] = "PhoneMaker" + exif[0x0110] = "Phone 12" + ifd = exif.get_ifd(0x8769) + ifd[0x9003] = "2026:09:12 10:41:07" + gps = exif.get_ifd(0x8825) + gps[0x0001] = "N" + gps[0x0002] = (IFDRational(40, 1), IFDRational(25, 1), IFDRational(0, 1)) + gps[0x0003] = "W" + gps[0x0004] = (IFDRational(3, 1), IFDRational(42, 1), IFDRational(0, 1)) + buffer = io.BytesIO() + image.save(buffer, format="JPEG", quality=90, exif=exif.tobytes(), comment=b"private comment") + return buffer.getvalue() + + +def png_with_text(size: tuple[int, int] = (300, 200)) -> bytes: + image = Image.new("RGB", size, (10, 20, 30)) + buffer = io.BytesIO() + from PIL import PngImagePlugin + + meta = PngImagePlugin.PngInfo() + meta.add_text("Author", "somebody") + meta.add_text("Comment", "private") + image.save(buffer, format="PNG", pnginfo=meta) + return buffer.getvalue() + + +def test_jpeg_scrubbing_removes_metadata_and_keeps_the_scan_bytes() -> None: + original = jpeg_with_metadata() + result = scrub(original) + assert result.mime == "image/jpeg" and result.source_format == "JPEG" and not result.re_encoded + assert result.orientation == 6 + assert result.captured_at is not None and result.captured_at.isoformat().startswith("2026-09-12T10:41:07") + cleaned = result.data + assert b"Exif" not in cleaned and b"PhoneMaker" not in cleaned and b"private comment" not in cleaned + # the compressed image data is byte-identical: pixels untouched + assert original[original.index(b"\xff\xda") :] == cleaned[cleaned.index(b"\xff\xda") :] + with Image.open(io.BytesIO(cleaned)) as image: + assert image.size == (640, 480) + assert dict(image.getexif()) == {} + + +def test_png_scrubbing_drops_text_chunks_and_keeps_pixels() -> None: + original = png_with_text() + cleaned = strip_png(original) + assert b"somebody" not in cleaned and b"private" not in cleaned + with Image.open(io.BytesIO(original)) as a, Image.open(io.BytesIO(cleaned)) as b: + assert list(a.getdata()) == list(b.getdata()) + + +def test_non_images_are_rejected() -> None: + with pytest.raises(Exception, match="not a readable image"): + scrub(b"definitely not an image") + with pytest.raises(Exception, match="Not a JPEG"): + strip_jpeg(b"\x89PNG") + + +def test_upload_stores_scrubbed_original_and_renditions_with_orientation_applied(client: TestClient) -> None: + claim(client) + person_id = client.post("/api/persons", json={"display_name": "Ana"}).json()["id"] + response = client.post( + f"/api/persons/{person_id}/images", + files={"file": ("IMG_0001.JPG", jpeg_with_metadata(orientation=6), "image/jpeg")}, + data={"role": "with_reference", "modality": "camera", "captured_tz": "Europe/Madrid"}, + ) + assert response.status_code == 201, response.text + body = response.json() + assert body["role"] == "with_reference" and body["orientation"] == 6 and body["source_format"] == "JPEG" + assert body["captured_at"].startswith("2026-09-12T10:41:07") + kinds = {r["kind"]: (r["width"], r["height"]) for r in body["renditions"]} + assert set(kinds) == {"full", "preview", "thumb"} + assert kinds["full"] == (480, 640) # orientation 6 rotates the 640x480 source upright + assert kinds["thumb"] == (192, 256) + + thumb = client.get(f"/api/images/{body['id']}/thumb") + assert thumb.status_code == 200 and thumb.headers["content-type"] == "image/jpeg" + assert thumb.headers["cache-control"] == "private, max-age=31536000, immutable" + with Image.open(io.BytesIO(thumb.content)) as image: + assert image.size == (192, 256) and dict(image.getexif()) == {} + etag = thumb.headers["etag"] + assert client.get(f"/api/images/{body['id']}/thumb", headers={"if-none-match": etag}).status_code == 304 + + original = client.get(f"/api/images/{body['id']}/original") + assert original.status_code == 200 and b"Exif" not in original.content + assert client.get(f"/api/persons/{person_id}/images").json()[0]["id"] == body["id"] + + +def test_identical_uploads_share_one_blob(client: TestClient, settings) -> None: # type: ignore[no-untyped-def] + claim(client) + person_id = client.post("/api/persons", json={"display_name": "Ana"}).json()["id"] + payload = jpeg_with_metadata(orientation=1) + first = client.post(f"/api/persons/{person_id}/images", files={"file": ("a.jpg", payload, "image/jpeg")}).json() + second = client.post(f"/api/persons/{person_id}/images", files={"file": ("b.jpg", payload, "image/jpeg")}).json() + assert first["id"] != second["id"] and first["sha256"] == second["sha256"] + originals = list((settings.blobs_dir / "originals").rglob("*")) + assert len([p for p in originals if p.is_file()]) == 1 + + +def test_access_rules_apply_to_images(client: TestClient) -> None: + claim(client) + assert client.post("/api/users", json=MEMBER).status_code == 201 + member = TestClient(client.app, base_url="http://localhost") + member.post("/api/auth/login", json=MEMBER) + person_id = client.post("/api/persons", json={"display_name": "Ana"}).json()["id"] + image_id = client.post( + f"/api/persons/{person_id}/images", files={"file": ("a.jpg", jpeg_with_metadata(), "image/jpeg")} + ).json()["id"] + # not shared: invisible + assert member.get(f"/api/images/{image_id}/thumb").status_code == 404 + assert ( + member.post( + f"/api/persons/{person_id}/images", files={"file": ("a.jpg", jpeg_with_metadata(), "image/jpeg")} + ).status_code + == 404 + ) + # viewer: may look, may not upload or delete + client.put(f"/api/persons/{person_id}/access", json={"username": "ana", "role": "viewer"}) + assert member.get(f"/api/images/{image_id}/thumb").status_code == 200 + assert ( + member.post( + f"/api/persons/{person_id}/images", files={"file": ("a.jpg", jpeg_with_metadata(), "image/jpeg")} + ).status_code + == 403 + ) + assert member.delete(f"/api/images/{image_id}").status_code == 403 + # manager: may upload and delete + client.put(f"/api/persons/{person_id}/access", json={"username": "ana", "role": "manager"}) + assert ( + member.post( + f"/api/persons/{person_id}/images", files={"file": ("a.jpg", jpeg_with_metadata(), "image/jpeg")} + ).status_code + == 201 + ) + assert member.delete(f"/api/images/{image_id}").status_code == 204 + assert client.get(f"/api/images/{image_id}").status_code == 404 + assert client.get("/api/auth/session").json()["user"]["username"] == ADMIN["username"].lower() + + +def test_limits_and_unsupported_files(client: TestClient, settings) -> None: # type: ignore[no-untyped-def] + claim(client) + person_id = client.post("/api/persons", json={"display_name": "Ana"}).json()["id"] + assert ( + client.post(f"/api/persons/{person_id}/images", files={"file": ("a.txt", b"hello", "text/plain")}).status_code + == 415 + ) + huge = jpeg_with_metadata(orientation=1, size=(6000, 4100)) # 24.6 megapixels + assert ( + client.post(f"/api/persons/{person_id}/images", files={"file": ("a.jpg", huge, "image/jpeg")}).status_code + == 413 + ) + assert ( + client.post(f"/api/persons/{person_id}/images", files={"file": ("a.jpg", b"", "image/jpeg")}).status_code == 400 + ) + + +def test_low_disk_space_refuses_uploads(client: TestClient) -> None: + claim(client) + person_id = client.post("/api/persons", json={"display_name": "Ana"}).json()["id"] + client.app.state.blob_store.min_free_bytes = 10**18 # more than any disk has + assert ( + client.post( + f"/api/persons/{person_id}/images", files={"file": ("a.jpg", jpeg_with_metadata(), "image/jpeg")} + ).status_code + == 507 + ) + + +def test_png_chunk_parser_stops_at_iend_and_keeps_critical_chunks() -> None: + def chunk(kind: bytes, data: bytes) -> bytes: + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF) + + raw = ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", b"\x00" * 13) + + chunk(b"tEXt", b"k\x00v") + + chunk(b"IDAT", b"x") + + chunk(b"IEND", b"") + ) + cleaned = strip_png(raw) + assert b"tEXt" not in cleaned and b"IHDR" in cleaned and cleaned.endswith(chunk(b"IEND", b""))