diff --git a/backend/app/main.py b/backend/app/main.py index 1f2a474..1c77946 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,6 +9,7 @@ from .database import Base, SessionLocal, engine from .routers import ( api_tokens, auth, + branding, groups, locations, products, @@ -98,3 +99,4 @@ app.include_router(views.router) app.include_router(transfer.router) app.include_router(api_tokens.router) app.include_router(settings_router.router) +app.include_router(branding.router) diff --git a/backend/app/models.py b/backend/app/models.py index d20bc0d..0503700 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -11,6 +11,7 @@ from sqlalchemy import ( Float, ForeignKey, Integer, + LargeBinary, String, Text, UniqueConstraint, @@ -248,6 +249,24 @@ class Movement(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) +class BrandingAsset(Base): + """Eigenes Logo bzw. Favicon der Installation. + + Bewusst in der Datenbank und nicht im Dateisystem: So landet das Bild + automatisch im Backup und das Deployment braucht kein zusätzliches Volume. + Es geht um wenige Kilobyte, die Größe ist beim Upload begrenzt. + """ + + __tablename__ = "branding_assets" + + kind: Mapped[str] = mapped_column(String(16), primary_key=True) # "logo" | "favicon" + content_type: Mapped[str] = mapped_column(String(64), nullable=False) + data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_now, onupdate=_now + ) + + class Setting(Base): __tablename__ = "settings" diff --git a/backend/app/routers/branding.py b/backend/app/routers/branding.py new file mode 100644 index 0000000..2d12d52 --- /dev/null +++ b/backend/app/routers/branding.py @@ -0,0 +1,113 @@ +"""Eigenes Logo und Favicon der Installation. + +Die Bilder liegen in der Datenbank (siehe models.BrandingAsset). Das Abrufen ist +bewusst ohne Anmeldung möglich – das Favicon wird vom Browser schon vor dem +Login geladen, und es handelt sich um nichts Schützenswertes. Hochladen und +Entfernen dürfen nur Administratoren. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status +from fastapi.responses import Response +from sqlalchemy.orm import Session + +from ..database import get_db +from ..deps import require_admin +from ..models import BrandingAsset, User + +router = APIRouter(prefix="/branding", tags=["branding"]) + +KINDS = ("logo", "favicon") + +# Bewusst eng: genau die Formate, die ein Browser als Logo/Favicon darstellt. +ALLOWED_TYPES = { + "image/png": "png", + "image/jpeg": "jpg", + "image/svg+xml": "svg", + "image/webp": "webp", + "image/x-icon": "ico", + "image/vnd.microsoft.icon": "ico", + "image/gif": "gif", +} + +MAX_BYTES = 512 * 1024 # 512 KB reichen für ein Logo deutlich aus + + +def _check_kind(kind: str) -> str: + if kind not in KINDS: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Unbekannte Bildart") + return kind + + +@router.get("/{kind}") +def get_branding(kind: str, db: Session = Depends(get_db)) -> Response: + """Liefert das hinterlegte Bild – oder 404, wenn keines gesetzt ist. + + Die Oberfläche fällt bei 404 auf ihr eingebautes Zeichen zurück. + """ + _check_kind(kind) + asset = db.get(BrandingAsset, kind) + if asset is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Kein eigenes Bild hinterlegt") + return Response( + content=asset.data, + media_type=asset.content_type, + headers={ + # Kurz cachen, damit ein Wechsel schnell sichtbar wird. + "Cache-Control": "public, max-age=60", + "ETag": f'"{kind}-{int(asset.updated_at.timestamp())}"', + }, + ) + + +@router.put("/{kind}") +async def put_branding( + kind: str, + file: UploadFile = File(...), + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> Response: + _check_kind(kind) + + if file.content_type not in ALLOWED_TYPES: + erlaubt = ", ".join(sorted(set(ALLOWED_TYPES.values()))) + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + f"Dieses Dateiformat wird nicht unterstützt. Erlaubt sind: {erlaubt}.", + ) + + data = await file.read() + if not data: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Die Datei ist leer.") + if len(data) > MAX_BYTES: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + f"Die Datei ist zu groß ({len(data) // 1024} KB). " + f"Erlaubt sind höchstens {MAX_BYTES // 1024} KB.", + ) + + asset = db.get(BrandingAsset, kind) + if asset is None: + asset = BrandingAsset(kind=kind, content_type=file.content_type, data=data) + db.add(asset) + else: + asset.content_type = file.content_type + asset.data = data + db.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.delete("/{kind}") +def delete_branding( + kind: str, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> Response: + """Zurück auf das eingebaute Zeichen.""" + _check_kind(kind) + asset = db.get(BrandingAsset, kind) + if asset is not None: + db.delete(asset) + db.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/web/index.html b/web/index.html index 5f54410..99d37e5 100644 --- a/web/index.html +++ b/web/index.html @@ -2,6 +2,7 @@ + Project-Good – Lagerverwaltung diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..07558be --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/App.jsx b/web/src/App.jsx index 25e1152..12b217e 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -1,6 +1,7 @@ import { NavLink, Navigate, Route, Routes, useNavigate } from "react-router-dom"; import { useAuth } from "./auth"; import Icon from "./components/Icon"; +import BrandMark from "./components/BrandMark"; import Login from "./pages/Login"; import Dashboard from "./pages/Dashboard"; import Products from "./pages/Products"; @@ -39,8 +40,7 @@ function Sidebar() { return (