From 5970172f88f9f83b72287f1844d30824e3c44b2f Mon Sep 17 00:00:00 2001 From: akira69 Date: Wed, 1 Apr 2026 20:26:30 -0500 Subject: [PATCH 1/3] feat: add manufacturer logo storage and API --- ...a1e6f4b2d0_add_manufacturer_logo_fields.py | 31 ++ backend/app/api/v1/filaments.py | 425 +++++++++++++++++- backend/app/api/v1/schemas_filament.py | 10 + backend/app/core/config.py | 2 + backend/app/main.py | 5 +- backend/app/models/filament.py | 17 + .../app/services/manufacturer_logo_service.py | 103 +++++ backend/tests/test_filaments.py | 96 ++++ 8 files changed, 686 insertions(+), 3 deletions(-) create mode 100644 backend/alembic/versions/c9a1e6f4b2d0_add_manufacturer_logo_fields.py create mode 100644 backend/app/services/manufacturer_logo_service.py diff --git a/backend/alembic/versions/c9a1e6f4b2d0_add_manufacturer_logo_fields.py b/backend/alembic/versions/c9a1e6f4b2d0_add_manufacturer_logo_fields.py new file mode 100644 index 00000000..ea215b0a --- /dev/null +++ b/backend/alembic/versions/c9a1e6f4b2d0_add_manufacturer_logo_fields.py @@ -0,0 +1,31 @@ +"""add_manufacturer_logo_fields + +Revision ID: c9a1e6f4b2d0 +Revises: b37af859a415 +Create Date: 2026-04-01 16:30:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "c9a1e6f4b2d0" +down_revision: Union[str, Sequence[str], None] = "b37af859a415" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("manufacturers") as batch_op: + batch_op.add_column(sa.Column("logo_url", sa.String(length=500), nullable=True)) + batch_op.add_column(sa.Column("logo_file_path", sa.String(length=500), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("manufacturers") as batch_op: + batch_op.drop_column("logo_file_path") + batch_op.drop_column("logo_url") \ No newline at end of file diff --git a/backend/app/api/v1/filaments.py b/backend/app/api/v1/filaments.py index b1641c8e..1823c331 100644 --- a/backend/app/api/v1/filaments.py +++ b/backend/app/api/v1/filaments.py @@ -1,6 +1,14 @@ +import asyncio +import ipaddress +import logging +import mimetypes +from pathlib import Path +import socket from typing import Any -from fastapi import APIRouter, Depends, HTTPException, Query, status +import httpx +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status +from fastapi.responses import FileResponse from sqlalchemy import delete, func, select, literal_column from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -23,13 +31,301 @@ FilamentResponse, FilamentUpdate, ManufacturerCreate, + ManufacturerLogoImportRequest, ManufacturerResponse, ManufacturerUpdate, ) from app.core.event_bus import event_bus from app.models import Color, Filament, FilamentColor, Manufacturer, Spool, SpoolStatus +from app.services.manufacturer_logo_service import ( + CONTENT_TYPE_SUFFIXES, + MAX_LOGO_SIZE_BYTES, + delete_manufacturer_logo, + resolve_logo_file_path, + save_manufacturer_logo, + sniff_logo_suffix, +) router = APIRouter(prefix="/manufacturers", tags=["manufacturers"]) +logger = logging.getLogger(__name__) + +_SAFE_LOGO_IMPORT_SCHEMES = {"http", "https"} +_MAX_LOGO_REDIRECTS = 5 +_SUPPORTED_LOGO_TYPES = "PNG, JPEG, GIF, or WebP" + + +async def _resolve_hostname_ips(host: str) -> set[str]: + def _lookup() -> set[str]: + resolved_ips: set[str] = set() + for family, _, _, _, sockaddr in socket.getaddrinfo(host, None, type=socket.SOCK_STREAM): + if family == socket.AF_INET: + resolved_ips.add(sockaddr[0]) + elif family == socket.AF_INET6: + resolved_ips.add(sockaddr[0]) + return resolved_ips + + try: + return await asyncio.to_thread(_lookup) + except socket.gaierror as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={"code": "invalid_logo_url", "message": f"Unable to resolve logo host: {exc}"}, + ) from exc + + +def _build_host_header(url: httpx.URL) -> str: + if url.port and url.port not in {80, 443}: + return f"{url.host}:{url.port}" + return url.host + + +def _validate_logo_bytes(file_bytes: bytes, content_type: str | None) -> str: + normalized_content_type = (content_type or "").split(";", 1)[0].strip().lower() + if normalized_content_type not in CONTENT_TYPE_SUFFIXES: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "code": "invalid_content_type", + "message": f"Expected a {_SUPPORTED_LOGO_TYPES} image", + }, + ) + + if not file_bytes: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={"code": "empty_file", "message": "Empty file"}, + ) + + if len(file_bytes) > MAX_LOGO_SIZE_BYTES: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail={ + "code": "file_too_large", + "message": "Logo must be 5 MB or smaller", + }, + ) + + detected_suffix = sniff_logo_suffix(file_bytes) + if detected_suffix is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "code": "invalid_image", + "message": f"File is not a valid {_SUPPORTED_LOGO_TYPES} image", + }, + ) + + expected_suffix = CONTENT_TYPE_SUFFIXES[normalized_content_type] + if expected_suffix != detected_suffix: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "code": "invalid_image", + "message": "File contents do not match the reported image type", + }, + ) + + return detected_suffix + + +async def _validate_logo_source_url(url: httpx.URL) -> None: + if url.scheme not in _SAFE_LOGO_IMPORT_SCHEMES or not url.host: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={"code": "invalid_logo_url", "message": "Logo URL must use http or https"}, + ) + + if url.userinfo: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={"code": "invalid_logo_url", "message": "Logo URL must not include credentials"}, + ) + + host = url.host.lower() + if host == "localhost" or host.endswith(".localhost"): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={"code": "unsafe_logo_url", "message": "Logo URL must not target local or private hosts"}, + ) + + resolved_ips = await _resolve_hostname_ips(host) + if not resolved_ips: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={"code": "invalid_logo_url", "message": "Unable to resolve logo host"}, + ) + + for resolved_ip in resolved_ips: + ip = ipaddress.ip_address(resolved_ip) + if ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_multicast + or ip.is_reserved + or ip.is_unspecified + ): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={"code": "unsafe_logo_url", "message": "Logo URL must not target local or private hosts"}, + ) + + +async def _read_logo_response_bytes(response: httpx.Response) -> bytes: + content_length = response.headers.get("content-length") + if content_length: + try: + if int(content_length) > MAX_LOGO_SIZE_BYTES: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail={ + "code": "file_too_large", + "message": "Logo must be 5 MB or smaller", + }, + ) + except ValueError: + pass + + file_bytes = bytearray() + async for chunk in response.aiter_bytes(chunk_size=8192): + file_bytes.extend(chunk) + if len(file_bytes) > MAX_LOGO_SIZE_BYTES: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail={ + "code": "file_too_large", + "message": "Logo must be 5 MB or smaller", + }, + ) + + return bytes(file_bytes) + + +async def _fetch_logo_from_url(logo_url: str) -> tuple[bytes, str, str | None, str]: + current_url = httpx.URL(logo_url) + + async with httpx.AsyncClient( + follow_redirects=False, + timeout=httpx.Timeout(20.0, connect=5.0, read=10.0, write=10.0, pool=5.0), + trust_env=False, + headers={ + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0 Safari/537.36" + ), + "Accept": "image/png,image/jpeg,image/gif,image/webp,image/*;q=0.8,*/*;q=0.5", + }, + ) as client: + for _ in range(_MAX_LOGO_REDIRECTS + 1): + await _validate_logo_source_url(current_url) + resolved_ips = sorted(await _resolve_hostname_ips(current_url.host)) + request_url = current_url.copy_with(host=resolved_ips[0]) + host_header = _build_host_header(current_url) + request = client.build_request( + "GET", + request_url, + headers={ + "Host": host_header, + "Referer": f"{current_url.scheme}://{host_header}/", + }, + extensions=( + {"sni_hostname": current_url.host} + if current_url.scheme == "https" + else None + ), + ) + response = await client.send(request, stream=True) + + try: + if response.is_redirect: + location = response.headers.get("location") + if not location: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "code": "logo_fetch_failed", + "message": "Logo URL redirect was missing a location header", + }, + ) + current_url = current_url.join(location) + continue + + response.raise_for_status() + content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower() + file_bytes = await _read_logo_response_bytes(response) + detected_suffix = _validate_logo_bytes(file_bytes, content_type) + fallback_name = f"logo{detected_suffix}" + return file_bytes, content_type, Path(current_url.path).name or fallback_name, detected_suffix + except httpx.HTTPStatusError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "code": "logo_fetch_failed", + "message": f"Failed to fetch logo from URL: {exc}", + }, + ) from exc + finally: + await response.aclose() + + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={"code": "logo_fetch_failed", "message": "Logo URL redirected too many times"}, + ) + + +async def _commit_manufacturer_logo_change( + *, + db: AsyncSession, + manufacturer: Manufacturer, + new_logo_path: str | None, +) -> Manufacturer: + previous_logo_path = manufacturer.logo_file_path + manufacturer.logo_file_path = new_logo_path + + try: + await db.commit() + except Exception: + await db.rollback() + if new_logo_path and new_logo_path != previous_logo_path: + try: + delete_manufacturer_logo(new_logo_path) + except (OSError, ValueError) as cleanup_exc: + logger.warning("Failed to clean up new manufacturer logo %s: %s", new_logo_path, cleanup_exc) + raise + + await db.refresh(manufacturer) + + if previous_logo_path and previous_logo_path != new_logo_path: + try: + delete_manufacturer_logo(previous_logo_path) + except (OSError, ValueError) as cleanup_exc: + logger.warning("Failed to delete previous manufacturer logo %s: %s", previous_logo_path, cleanup_exc) + + return manufacturer + + +@router.get("/logo-files/{filename}") +async def get_manufacturer_logo_file(filename: str, principal: PrincipalDep): + stored_path = f"manufacturer-logos/{Path(filename).name}" + try: + file_path = resolve_logo_file_path(stored_path) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "not_found", "message": "Manufacturer logo not found"}, + ) from exc + + if not file_path.exists() or not file_path.is_file(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "not_found", "message": "Manufacturer logo not found"}, + ) + media_type, _ = mimetypes.guess_type(file_path.name) + return FileResponse( + file_path, + media_type=media_type or "application/octet-stream", + headers={"X-Content-Type-Options": "nosniff"}, + ) @router.get("", response_model=PaginatedResponse[ManufacturerResponse]) @@ -112,6 +408,7 @@ async def list_manufacturers( ManufacturerResponse.model_validate( { **m.__dict__, + "resolved_logo_url": m.resolved_logo_url, "filament_count": fil_counts.get(m.id, 0), "spool_count": active_spool_counts.get(m.id, 0), "archived_spool_count": archived_spool_counts.get(m.id, 0), @@ -160,6 +457,99 @@ async def create_manufacturer( return manufacturer +@router.post("/{manufacturer_id}/logo-from-url", response_model=ManufacturerResponse) +async def import_manufacturer_logo_from_url( + manufacturer_id: int, + data: ManufacturerLogoImportRequest, + db: DBSession, + principal=RequirePermission("manufacturers:update"), +): + result = await db.execute( + select(Manufacturer).where(Manufacturer.id == manufacturer_id) + ) + manufacturer = result.scalar_one_or_none() + if not manufacturer: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "not_found", "message": "Manufacturer not found"}, + ) + + source_url = (data.url or "").strip() + if not source_url: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={"code": "invalid_logo_url", "message": "Logo URL is required"}, + ) + + try: + file_bytes, content_type, fetched_filename, detected_suffix = await _fetch_logo_from_url(source_url) + except HTTPException: + raise + except httpx.HTTPError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={"code": "logo_fetch_failed", "message": f"Failed to fetch logo from URL: {exc}"}, + ) from exc + + new_logo_path = save_manufacturer_logo( + manufacturer_name=manufacturer.name, + file_bytes=file_bytes, + filename=fetched_filename, + content_type=content_type, + detected_suffix=detected_suffix, + ) + + manufacturer = await _commit_manufacturer_logo_change( + db=db, + manufacturer=manufacturer, + new_logo_path=new_logo_path, + ) + + await event_bus.publish({"event": "manufacturers_changed"}) + return manufacturer + + +@router.post("/{manufacturer_id}/logo", response_model=ManufacturerResponse) +async def upload_manufacturer_logo( + manufacturer_id: int, + db: DBSession, + file: UploadFile = File(...), + principal=RequirePermission("manufacturers:update"), +): + result = await db.execute( + select(Manufacturer).where(Manufacturer.id == manufacturer_id) + ) + manufacturer = result.scalar_one_or_none() + if not manufacturer: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "not_found", "message": "Manufacturer not found"}, + ) + + try: + file_bytes = await file.read(MAX_LOGO_SIZE_BYTES + 1) + finally: + await file.close() + + detected_suffix = _validate_logo_bytes(file_bytes, file.content_type) + new_logo_path = save_manufacturer_logo( + manufacturer_name=manufacturer.name, + file_bytes=file_bytes, + filename=file.filename, + content_type=file.content_type, + detected_suffix=detected_suffix, + ) + + manufacturer = await _commit_manufacturer_logo_change( + db=db, + manufacturer=manufacturer, + new_logo_path=new_logo_path, + ) + + await event_bus.publish({"event": "manufacturers_changed"}) + return manufacturer + + @router.get("/{manufacturer_id}", response_model=ManufacturerResponse) async def get_manufacturer( manufacturer_id: int, db: DBSession, principal: PrincipalDep @@ -176,6 +566,31 @@ async def get_manufacturer( return manufacturer +@router.delete("/{manufacturer_id}/logo", response_model=ManufacturerResponse) +async def delete_manufacturer_logo_file( + manufacturer_id: int, + db: DBSession, + principal=RequirePermission("manufacturers:update"), +): + result = await db.execute( + select(Manufacturer).where(Manufacturer.id == manufacturer_id) + ) + manufacturer = result.scalar_one_or_none() + if not manufacturer: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "not_found", "message": "Manufacturer not found"}, + ) + + manufacturer = await _commit_manufacturer_logo_change( + db=db, + manufacturer=manufacturer, + new_logo_path=None, + ) + await event_bus.publish({"event": "manufacturers_changed"}) + return manufacturer + + @router.patch("/{manufacturer_id}", response_model=ManufacturerResponse) async def update_manufacturer( manufacturer_id: int, @@ -266,8 +681,16 @@ async def delete_manufacturer( await db.delete(s) await db.delete(f) + logo_to_delete = manufacturer.logo_file_path await db.delete(manufacturer) await db.commit() + + if logo_to_delete: + try: + delete_manufacturer_logo(logo_to_delete) + except (OSError, ValueError) as cleanup_exc: + logger.warning("Failed to delete manufacturer logo %s: %s", logo_to_delete, cleanup_exc) + await event_bus.publish({"event": "manufacturers_changed"}) diff --git a/backend/app/api/v1/schemas_filament.py b/backend/app/api/v1/schemas_filament.py index b8eac636..4c87e66d 100644 --- a/backend/app/api/v1/schemas_filament.py +++ b/backend/app/api/v1/schemas_filament.py @@ -6,6 +6,7 @@ class ManufacturerCreate(BaseModel): name: str url: str | None = None + logo_url: str | None = None empty_spool_weight_g: float | None = None spool_outer_diameter_mm: float | None = None spool_width_mm: float | None = None @@ -16,6 +17,7 @@ class ManufacturerCreate(BaseModel): class ManufacturerUpdate(BaseModel): name: str | None = None url: str | None = None + logo_url: str | None = None empty_spool_weight_g: float | None = None spool_outer_diameter_mm: float | None = None spool_width_mm: float | None = None @@ -23,10 +25,18 @@ class ManufacturerUpdate(BaseModel): custom_fields: dict[str, Any] | None = None +class ManufacturerLogoImportRequest(BaseModel): + url: str | None = None + logo_url: str | None = None + + class ManufacturerResponse(BaseModel): id: int name: str url: str | None + logo_url: str | None = None + logo_file_path: str | None = None + resolved_logo_url: str | None = None empty_spool_weight_g: float | None = None spool_outer_diameter_mm: float | None = None spool_width_mm: float | None = None diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 143a62fd..ee87bd69 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -53,5 +53,7 @@ def resolve_relative_db_path(cls, v: str) -> str: # User-installed plugins directory (auto-detected if empty) plugins_dir: str = "" + manufacturer_logos_dir: str = str(PROJECT_ROOT / "data" / "manufacturer-logos") + settings = Settings() diff --git a/backend/app/main.py b/backend/app/main.py index e9802097..2f02f440 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -48,10 +48,11 @@ def run_migrations() -> None: from alembic import command from alembic.config import Config - alembic_cfg = Config("alembic.ini") + backend_dir = Path(__file__).resolve().parent.parent + alembic_cfg = Config(str(backend_dir / "alembic.ini")) alembic_cfg.set_main_option( "script_location", - str(__import__("pathlib").Path(__file__).resolve().parent.parent / "alembic"), + str(backend_dir / "alembic"), ) # Wir muessen hier nichts mehr an der URL drehen, das macht env.py jetzt selbst. diff --git a/backend/app/models/filament.py b/backend/app/models/filament.py index 59a0e0de..14dfac7b 100644 --- a/backend/app/models/filament.py +++ b/backend/app/models/filament.py @@ -1,4 +1,5 @@ from datetime import datetime +from pathlib import Path from typing import Any from sqlalchemy import Float, ForeignKey, Integer, String, Text, UniqueConstraint, func @@ -13,6 +14,7 @@ class Manufacturer(Base, TimestampMixin): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True) url: Mapped[str | None] = mapped_column(String(500), nullable=True) + logo_file_path: Mapped[str | None] = mapped_column(String(500), nullable=True) empty_spool_weight_g: Mapped[float | None] = mapped_column(Float, nullable=True) spool_outer_diameter_mm: Mapped[float | None] = mapped_column(Float, nullable=True) @@ -23,6 +25,21 @@ class Manufacturer(Base, TimestampMixin): filaments: Mapped[list["Filament"]] = relationship(back_populates="manufacturer") + @property + def resolved_logo_url(self) -> str | None: + if not isinstance(self.logo_file_path, str): + return None + + normalized = self.logo_file_path.strip().lstrip("/") + if not normalized.startswith("manufacturer-logos/"): + return None + + filename = Path(normalized).name + if not filename or normalized != f"manufacturer-logos/{filename}": + return None + + return f"/api/v1/manufacturers/logo-files/{filename}" + class Color(Base, TimestampMixin): __tablename__ = "colors" diff --git a/backend/app/services/manufacturer_logo_service.py b/backend/app/services/manufacturer_logo_service.py new file mode 100644 index 00000000..79fc7554 --- /dev/null +++ b/backend/app/services/manufacturer_logo_service.py @@ -0,0 +1,103 @@ +import re +import secrets +from pathlib import Path + +from app.core.config import settings + +LOGO_PATH_PREFIX = "manufacturer-logos/" +MAX_LOGO_SIZE_BYTES = 5 * 1024 * 1024 + +ALLOWED_LOGO_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".webp"} +CONTENT_TYPE_SUFFIXES = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", +} + + +def get_manufacturer_logos_dir() -> Path: + logo_dir = Path(settings.manufacturer_logos_dir) + logo_dir.mkdir(parents=True, exist_ok=True) + return logo_dir + + +def slugify_manufacturer_name(name: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + return slug or "manufacturer-logo" + + +def sniff_logo_suffix(file_bytes: bytes) -> str | None: + if file_bytes.startswith(b"\x89PNG\r\n\x1a\n"): + return ".png" + if file_bytes.startswith(b"\xff\xd8\xff"): + return ".jpg" + if file_bytes.startswith((b"GIF87a", b"GIF89a")): + return ".gif" + if len(file_bytes) >= 12 and file_bytes.startswith(b"RIFF") and file_bytes[8:12] == b"WEBP": + return ".webp" + return None + + +def determine_logo_suffix( + filename: str | None, + content_type: str | None, + detected_suffix: str | None = None, +) -> str: + if detected_suffix in ALLOWED_LOGO_SUFFIXES: + return detected_suffix + if filename: + suffix = Path(filename).suffix.lower() + if suffix in ALLOWED_LOGO_SUFFIXES: + return suffix + if content_type in CONTENT_TYPE_SUFFIXES: + return CONTENT_TYPE_SUFFIXES[content_type] + return ".png" + + +def save_manufacturer_logo( + *, + manufacturer_name: str, + file_bytes: bytes, + filename: str | None, + content_type: str | None, + detected_suffix: str | None = None, +) -> str: + logo_dir = get_manufacturer_logos_dir() + safe_name = slugify_manufacturer_name(manufacturer_name) + suffix = determine_logo_suffix(filename, content_type, detected_suffix) + stored_name = f"{safe_name}-{secrets.token_hex(4)}{suffix}" + stored_path = logo_dir / stored_name + stored_path.write_bytes(file_bytes) + return f"{LOGO_PATH_PREFIX}{stored_name}" + + +def validate_stored_logo_path(stored_path: str) -> str: + normalized = (stored_path or "").strip().lstrip("/") + if not normalized.startswith(LOGO_PATH_PREFIX): + raise ValueError(f"Invalid manufacturer logo path: {stored_path!r}") + + filename = Path(normalized).name + if not filename or filename in {".", ".."}: + raise ValueError(f"Invalid manufacturer logo filename: {stored_path!r}") + + if normalized != f"{LOGO_PATH_PREFIX}{filename}": + raise ValueError(f"Unexpected nested manufacturer logo path: {stored_path!r}") + + if Path(filename).suffix.lower() not in ALLOWED_LOGO_SUFFIXES: + raise ValueError(f"Unsupported manufacturer logo suffix: {stored_path!r}") + + return filename + + +def resolve_logo_file_path(stored_path: str) -> Path: + filename = validate_stored_logo_path(stored_path) + return get_manufacturer_logos_dir() / filename + + +def delete_manufacturer_logo(stored_path: str | None) -> None: + if not stored_path: + return + path = resolve_logo_file_path(stored_path) + if path.exists(): + path.unlink() \ No newline at end of file diff --git a/backend/tests/test_filaments.py b/backend/tests/test_filaments.py index ca6f1466..0641f558 100644 --- a/backend/tests/test_filaments.py +++ b/backend/tests/test_filaments.py @@ -1,9 +1,21 @@ +from pathlib import Path + import pytest from sqlalchemy import select +from app.core.config import settings from app.models import Color, Filament, FilamentColor, Manufacturer, Spool, SpoolStatus +_MINIMAL_PNG_BYTES = ( + b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR" + b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89" + b"\x00\x00\x00\x0cIDATx\x9cc``\x00\x00\x00\x02\x00\x01\xe2!\xbc3" + b"\x00\x00\x00\x00IEND\xaeB`\x82" +) + + async def _create_manufacturer(db_session, name: str = "Test Manufacturer", **kwargs) -> Manufacturer: manufacturer = Manufacturer(name=name, **kwargs) db_session.add(manufacturer) @@ -223,6 +235,90 @@ async def test_delete_manufacturer_with_filaments_force(self, auth_client, db_se spool_result = await db_session.execute(select(Spool).where(Spool.id == spool.id)) assert spool_result.scalar_one_or_none() is None + @pytest.mark.asyncio + async def test_upload_manufacturer_logo_persists_and_serves_file(self, auth_client, db_session, monkeypatch, tmp_path): + client, csrf_token = auth_client + manufacturer = await _create_manufacturer(db_session, name="LogoMaker") + monkeypatch.setattr(settings, "manufacturer_logos_dir", str(tmp_path)) + + response = await client.post( + f"/api/v1/manufacturers/{manufacturer.id}/logo", + files={"file": ("logo.png", _MINIMAL_PNG_BYTES, "image/png")}, + headers={"X-CSRF-Token": csrf_token}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["logo_file_path"].startswith("manufacturer-logos/") + assert data["resolved_logo_url"].endswith(Path(data["logo_file_path"]).name) + + stored_file = Path(settings.manufacturer_logos_dir) / Path(data["logo_file_path"]).name + assert stored_file.exists() + assert stored_file.read_bytes() == _MINIMAL_PNG_BYTES + + logo_response = await client.get(data["resolved_logo_url"]) + assert logo_response.status_code == 200 + assert logo_response.content == _MINIMAL_PNG_BYTES + + @pytest.mark.asyncio + async def test_upload_manufacturer_logo_rejects_invalid_image_bytes(self, auth_client, db_session, monkeypatch, tmp_path): + client, csrf_token = auth_client + manufacturer = await _create_manufacturer(db_session, name="BadLogoMaker") + monkeypatch.setattr(settings, "manufacturer_logos_dir", str(tmp_path)) + + response = await client.post( + f"/api/v1/manufacturers/{manufacturer.id}/logo", + files={"file": ("fake.png", b"not-a-real-image", "image/png")}, + headers={"X-CSRF-Token": csrf_token}, + ) + + assert response.status_code == 422 + assert response.json()["detail"]["code"] == "invalid_image" + + @pytest.mark.asyncio + async def test_upload_manufacturer_logo_rejects_oversized_files(self, auth_client, db_session, monkeypatch, tmp_path): + client, csrf_token = auth_client + manufacturer = await _create_manufacturer(db_session, name="HugeLogoMaker") + monkeypatch.setattr(settings, "manufacturer_logos_dir", str(tmp_path)) + oversized_png = _MINIMAL_PNG_BYTES + (b"0" * (5 * 1024 * 1024)) + + response = await client.post( + f"/api/v1/manufacturers/{manufacturer.id}/logo", + files={"file": ("huge.png", oversized_png, "image/png")}, + headers={"X-CSRF-Token": csrf_token}, + ) + + assert response.status_code == 413 + assert response.json()["detail"]["code"] == "file_too_large" + + @pytest.mark.asyncio + async def test_import_manufacturer_logo_rejects_local_urls(self, auth_client, db_session): + client, csrf_token = auth_client + manufacturer = await _create_manufacturer(db_session, name="UnsafeImportMaker") + + response = await client.post( + f"/api/v1/manufacturers/{manufacturer.id}/logo-from-url", + json={"url": "http://localhost/logo.png"}, + headers={"X-CSRF-Token": csrf_token}, + ) + + assert response.status_code == 422 + assert response.json()["detail"]["code"] == "unsafe_logo_url" + + @pytest.mark.asyncio + async def test_import_manufacturer_logo_rejects_urls_with_credentials(self, auth_client, db_session): + client, csrf_token = auth_client + manufacturer = await _create_manufacturer(db_session, name="CredentialImportMaker") + + response = await client.post( + f"/api/v1/manufacturers/{manufacturer.id}/logo-from-url", + json={"url": "https://user:secret@example.com/logo.png"}, + headers={"X-CSRF-Token": csrf_token}, + ) + + assert response.status_code == 422 + assert response.json()["detail"]["code"] == "invalid_logo_url" + class TestColorCRUD: @pytest.mark.asyncio From 33db9bbc815cd7b5eaef3472d25e0bb86f0c863b Mon Sep 17 00:00:00 2001 From: akira69 Date: Fri, 3 Apr 2026 19:16:03 -0500 Subject: [PATCH 2/3] feat: add manufacturer logo management UI --- ...a1e6f4b2d0_add_manufacturer_logo_fields.py | 4 +- backend/app/api/v1/schemas_filament.py | 4 - frontend/src/i18n/de.json | 18 + frontend/src/i18n/en.json | 18 + frontend/src/lib/api.ts | 11 +- frontend/src/lib/manufacturer-logo.ts | 84 ++++ frontend/src/pages/filaments/[id]/edit.astro | 24 ++ frontend/src/pages/manufacturers/index.astro | 358 +++++++++++++++++- frontend/src/pages/spools/[id]/edit.astro | 27 ++ 9 files changed, 524 insertions(+), 24 deletions(-) create mode 100644 frontend/src/lib/manufacturer-logo.ts diff --git a/backend/alembic/versions/c9a1e6f4b2d0_add_manufacturer_logo_fields.py b/backend/alembic/versions/c9a1e6f4b2d0_add_manufacturer_logo_fields.py index ea215b0a..76e5bce3 100644 --- a/backend/alembic/versions/c9a1e6f4b2d0_add_manufacturer_logo_fields.py +++ b/backend/alembic/versions/c9a1e6f4b2d0_add_manufacturer_logo_fields.py @@ -21,11 +21,9 @@ def upgrade() -> None: with op.batch_alter_table("manufacturers") as batch_op: - batch_op.add_column(sa.Column("logo_url", sa.String(length=500), nullable=True)) batch_op.add_column(sa.Column("logo_file_path", sa.String(length=500), nullable=True)) def downgrade() -> None: with op.batch_alter_table("manufacturers") as batch_op: - batch_op.drop_column("logo_file_path") - batch_op.drop_column("logo_url") \ No newline at end of file + batch_op.drop_column("logo_file_path") \ No newline at end of file diff --git a/backend/app/api/v1/schemas_filament.py b/backend/app/api/v1/schemas_filament.py index 4c87e66d..f120b32d 100644 --- a/backend/app/api/v1/schemas_filament.py +++ b/backend/app/api/v1/schemas_filament.py @@ -6,7 +6,6 @@ class ManufacturerCreate(BaseModel): name: str url: str | None = None - logo_url: str | None = None empty_spool_weight_g: float | None = None spool_outer_diameter_mm: float | None = None spool_width_mm: float | None = None @@ -17,7 +16,6 @@ class ManufacturerCreate(BaseModel): class ManufacturerUpdate(BaseModel): name: str | None = None url: str | None = None - logo_url: str | None = None empty_spool_weight_g: float | None = None spool_outer_diameter_mm: float | None = None spool_width_mm: float | None = None @@ -27,14 +25,12 @@ class ManufacturerUpdate(BaseModel): class ManufacturerLogoImportRequest(BaseModel): url: str | None = None - logo_url: str | None = None class ManufacturerResponse(BaseModel): id: int name: str url: str | None - logo_url: str | None = None logo_file_path: str | None = None resolved_logo_url: str | None = None empty_spool_weight_g: float | None = None diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index 84271b21..e4e3ed74 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -540,6 +540,24 @@ "title": "Hersteller", "addManufacturer": "Hersteller hinzufügen", "editManufacturer": "Hersteller bearbeiten", + "logo": "Logo", + "logoSection": "Logo", + "uploadLogoFromUrl": "Logo von URL importieren", + "logoUrlPlaceholder": "z.B. https://example.com/logo.png", + "uploadLogo": "Logo-Datei hochladen", + "uploadLogoHelp": "Ein Logo von einer Web-URL importieren oder eine lokale Datei hochladen.", + "logoFilePath": "Gespeicherter Dateipfad", + "logoPreview": "Logo-Vorschau", + "noLogoConfigured": "Kein Logo konfiguriert", + "pendingUpload": "Ausstehender Upload: {filename}", + "clearLogo": "Logo entfernen", + "clearLogoConfirm": "Dieses Logo entfernen? Falls eine lokale Datei gespeichert ist, wird sie ebenfalls gelöscht.", + "failedUpload": "Herstellerlogo konnte nicht hochgeladen werden", + "failedImportFromUrl": "Herstellerlogo konnte nicht von der URL importiert werden", + "failedClearLogo": "Hochgeladenes Logo konnte nicht entfernt werden", + "savedWithoutLogo": "Die Herstellerdaten wurden gespeichert, aber der Logo-Schritt ist fehlgeschlagen. Du kannst ihn jetzt erneut versuchen.", + "selectedLogoFile": "Ausgewählte Datei: {filename}", + "uploadLogoFileButton": "Logo-Datei auswählen", "name": "Name", "namePlaceholder": "z.B. Bambu Lab", "url": "URL", diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 9f916e95..2c4e1236 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -538,6 +538,24 @@ "title": "Manufacturers", "addManufacturer": "Add Manufacturer", "editManufacturer": "Edit Manufacturer", + "logo": "Logo", + "logoSection": "Logo", + "uploadLogoFromUrl": "Upload logo from URL", + "logoUrlPlaceholder": "e.g. https://example.com/logo.png", + "uploadLogo": "Upload logo file from disk", + "uploadLogoHelp": "Import a logo from a web URL or upload a local file.", + "logoFilePath": "Stored file path", + "logoPreview": "Logo Preview", + "noLogoConfigured": "No logo configured", + "pendingUpload": "Pending upload: {filename}", + "clearLogo": "Clear Logo", + "clearLogoConfirm": "Clear this logo? If a stored local file exists it will also be deleted.", + "failedUpload": "Failed to upload manufacturer logo", + "failedImportFromUrl": "Failed to import manufacturer logo from URL", + "failedClearLogo": "Failed to clear uploaded logo", + "savedWithoutLogo": "Manufacturer details were saved, but the logo step failed. You can retry it now.", + "selectedLogoFile": "Selected file: {filename}", + "uploadLogoFileButton": "Choose logo file", "name": "Name", "namePlaceholder": "e.g. Bambu Lab", "url": "URL", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6778f016..d87f39a9 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -36,11 +36,15 @@ async function request(path: string, options: RequestInit = {}): Promise { url = API_BASE + path } + const isFormDataBody = options.body instanceof FormData const headers: Record = { - 'Content-Type': 'application/json', ...options.headers as Record, } + if (!isFormDataBody && !headers['Content-Type']) { + headers['Content-Type'] = 'application/json' + } + const csrfToken = getCsrfToken() if (csrfToken) { headers['X-CSRF-Token'] = csrfToken @@ -84,6 +88,11 @@ export const api = { method: 'PATCH', body: body ? JSON.stringify(body) : undefined, }), + postFormData: (path: string, body: FormData) => + request(path, { + method: 'POST', + body, + }), delete: (path: string) => request(path, { method: 'DELETE' }), } diff --git a/frontend/src/lib/manufacturer-logo.ts b/frontend/src/lib/manufacturer-logo.ts new file mode 100644 index 00000000..4f26fcca --- /dev/null +++ b/frontend/src/lib/manufacturer-logo.ts @@ -0,0 +1,84 @@ +export interface ManufacturerLogoLike { + name?: string | null + logo_file_path?: string | null + resolved_logo_url?: string | null +} + +interface RenderManufacturerLogoOptions { + size?: number + width?: number + height?: number + borderRadius?: string + previewUrl?: string | null + tooltipText?: string | null + flexibleWidth?: boolean + multilineFallback?: boolean + withPill?: boolean + pillWidth?: number | string + pillPadding?: string + pillBorderRadius?: string + pillJustify?: 'flex-start' | 'center' | 'flex-end' +} + +export function escapeHtml(value: string | number | null | undefined): string { + const div = document.createElement('div') + div.textContent = value == null ? '' : String(value) + return div.innerHTML +} + +export function normalizeOptionalString(value: string | null | undefined): string | null { + if (typeof value !== 'string') return null + const trimmed = value.trim() + return trimmed ? trimmed : null +} + +export function getManufacturerLogoUrl(manufacturer?: ManufacturerLogoLike | null): string | null { + return normalizeOptionalString(manufacturer?.resolved_logo_url) +} + +export function renderManufacturerLogo( + manufacturer?: ManufacturerLogoLike | null, + options: RenderManufacturerLogoOptions = {}, +): string { + const size = options.size ?? 40 + const width = options.width ?? size + const height = options.height ?? size + const borderRadius = options.borderRadius ?? '10px' + const previewUrl = normalizeOptionalString(options.previewUrl) + const tooltipText = normalizeOptionalString(options.tooltipText) + const flexibleWidth = options.flexibleWidth ?? false + const multilineFallback = options.multilineFallback ?? false + const withPill = options.withPill ?? false + const pillWidth = options.pillWidth + const pillPadding = options.pillPadding ?? '4px 10px' + const pillBorderRadius = options.pillBorderRadius ?? '8px' + const pillJustify = options.pillJustify ?? (multilineFallback ? 'flex-end' : 'center') + const imageUrl = previewUrl ?? getManufacturerLogoUrl(manufacturer) + const altText = escapeHtml(`${manufacturer?.name || 'Manufacturer'} logo`) + const fallbackText = escapeHtml(manufacturer?.name?.trim() || 'Logo') + const fallbackFontSize = multilineFallback + ? Math.max(11, Math.floor(Math.min(width / 10, height * 0.32))) + : Math.max(12, Math.floor(Math.min(width, height) * 0.34)) + const titleAttr = tooltipText ? ` title="${escapeHtml(tooltipText)}"` : '' + const pillWidthStyle = typeof pillWidth === 'number' + ? `width: ${pillWidth}px;` + : pillWidth + ? `width: ${pillWidth};` + : '' + + const wrapWithPill = (content: string) => { + if (!withPill) return content + return `${content}` + } + + if (imageUrl) { + const widthStyle = flexibleWidth ? `max-width: ${width}px; width: auto;` : `width: ${width}px;` + return wrapWithPill(`${altText}`) + } + + const fallbackLayout = multilineFallback + ? `max-width: ${width}px; width: ${width}px; line-height: 1.05; text-align: right; white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;` + : `width: ${width}px; white-space: nowrap; text-overflow: ellipsis;` + + return wrapWithPill(``) +} \ No newline at end of file diff --git a/frontend/src/pages/filaments/[id]/edit.astro b/frontend/src/pages/filaments/[id]/edit.astro index 654e720f..2b8c8ee2 100644 --- a/frontend/src/pages/filaments/[id]/edit.astro +++ b/frontend/src/pages/filaments/[id]/edit.astro @@ -42,6 +42,7 @@ const backHref = id === 'detail' ? '/filaments' : `/filaments/${id}` +
@@ -355,6 +356,7 @@ const backHref = id === 'detail' ? '/filaments' : `/filaments/${id}`