M-Bus Gateway
← Tilbage til blog
· Hetzner· Object Storage· S3· boto3· Python· GDPR· EU· fillagring· PDF

Hetzner Object Storage med Python — S3-kompatibel fillagring

Hetzner Object Storage til SaaS: boto3 S3-kompatibel API, upload/download af PDF'er og billeder, pre-signed URLs, GDPR EU-hosting og bucket-politikker.

Af M-Bus Gateway

M-Bus Gateway platformen gemmer PDF-afregninger, installatørfotos og backups i Hetzner Object Storage. Her er implementeringen med boto3 og S3-kompatibel API.


Hetzner Object Storage: Fordele vs. AWS S3

Hetzner Object Storage (Falkenstein):
  → S3-kompatibel API (boto3 virker uden ændringer)
  → Pris: 0,019 €/GB/md (væsentligt billigere end S3)
  → EU-hosted (GDPR-compliant — ingen data til USA)
  → Bandwidth: Gratis udgående til Hetzner-netværk
  → Endpoint: https://fsn1.your-objectstorage.com (Falkenstein)

GDPR-relevans:
  → Persondata (afregninger, lejerinfo) skal være EU-hosted
  → Hetzner Falkenstein = Tyskland = EU GDPR-scope
  → AWS S3 (us-east-1) = Potentielt GDPR-problem

Pris-eksempel (100 GB PDF-afregninger):
  Hetzner: 100 GB × 0,019 = 1,90 €/md (~15 kr.)
  AWS S3:  100 GB × 0,023 = 2,30 €/md + $0.09/GB udgående

Boto3 konfiguration

# server/src/storage/s3.py
import boto3
from botocore.config import Config
from server.src.core.config import settings
import structlog

logger = structlog.get_logger()

def get_s3_client():
    """
    Boto3 klient konfigureret til Hetzner Object Storage.
    S3-kompatibel: Kun endpoint_url ændres fra standard AWS.
    """
    return boto3.client(
        "s3",
        endpoint_url=settings.OBJECT_STORAGE_ENDPOINT,  # Hetzner URL
        aws_access_key_id=settings.OBJECT_STORAGE_KEY_ID,
        aws_secret_access_key=settings.OBJECT_STORAGE_SECRET,
        region_name="eu-central",
        config=Config(
            signature_version="s3v4",
            retries={"max_attempts": 3, "mode": "adaptive"},
        ),
    )

BUCKET_NAME = settings.OBJECT_STORAGE_BUCKET

def upload_to_object_storage(
    key: str,
    data: bytes,
    content_type: str,
    metadata: dict | None = None,
) -> str:
    """
    Upload bytes til Object Storage.
    Returnerer public URL til filen.
    """
    client = get_s3_client()

    client.put_object(
        Bucket=BUCKET_NAME,
        Key=key,
        Body=data,
        ContentType=content_type,
        Metadata=metadata or {},
        # Server-side encryption (AES256):
        ServerSideEncryption="AES256",
    )

    url = f"{settings.OBJECT_STORAGE_ENDPOINT}/{BUCKET_NAME}/{key}"
    logger.info("object_uploaded", key=key, size_bytes=len(data))
    return url

Pre-signed URLs: Tidsbegrænset adgang

# Pre-signed URL: Giv tidsbegrænset adgang til privat fil

def generate_presigned_url(
    key: str,
    expires_seconds: int = 3600,
) -> str:
    """
    Generer pre-signed URL til privat PDF.
    Brugeren kan downloade uden permanent adgang.
    Bruges til: Lejer-download af afregnings-PDF.
    """
    client = get_s3_client()
    url = client.generate_presigned_url(
        "get_object",
        Params={"Bucket": BUCKET_NAME, "Key": key},
        ExpiresIn=expires_seconds,
    )
    return url


# I FastAPI endpoint:
@router.get("/{settlement_id}/download")
async def download_settlement_pdf(
    settlement_id: str,
    user: TokenPayload = Depends(get_current_user),
    session: AsyncSession = Depends(get_session),
):
    settlement = await session.get(Settlement, settlement_id)
    if not settlement or settlement.tenant_id != user.tenant_id:
        raise HTTPException(404)

    if not settlement.pdf_url:
        raise HTTPException(404, "PDF ikke genereret endnu")

    # Generér pre-signed URL (1 time gyldig):
    presigned = generate_presigned_url(
        key=f"settlements/{settlement_id}/afregning.pdf",
        expires_seconds=3600,
    )

    # Redirect til pre-signed URL:
    return RedirectResponse(url=presigned)

Bucket-struktur og adgangspolitikker

Bucket-struktur:
  mbus-gateway-prod/
  ├── settlements/
  │   └── {settlement_id}/
  │       └── afregning.pdf
  ├── photos/
  │   └── {installation_id}/
  │       └── {timestamp}.jpg
  ├── backups/
  │   └── {date}/
  │       └── db-backup.sql.gz
  └── templates/
      └── fonts/
          └── Inter-Regular.woff2

Adgangspolitik (Hetzner console):
  → Bucket: Privat (ingen public access)
  → Adgang: Kun via API-nøgle (service account)
  → Pre-signed URLs: Tidsbegrænset offentlig adgang
  → Cross-Origin: Ikke aktiveret (server-side download)

Backup til Object Storage

# server/src/workers/tasks/backup.py

@shared_task(name="backup.daily_db_backup")
def daily_db_backup():
    """
    Daglig PostgreSQL-backup til Object Storage.
    Immutable backup — aldrig slettet (GDPR bogføringspligt 5 år).
    """
    import subprocess
    import gzip
    from datetime import date

    today = date.today().isoformat()

    # pg_dump til gzip:
    dump = subprocess.run(
        ["pg_dump", settings.DATABASE_URL.replace("+asyncpg", "")],
        capture_output=True,
        check=True,
    )
    compressed = gzip.compress(dump.stdout)

    # Upload:
    key = f"backups/{today}/db-backup-{today}.sql.gz"
    upload_to_object_storage(
        key=key,
        data=compressed,
        content_type="application/gzip",
        metadata={"backup_date": today, "type": "postgresql"},
    )

    logger.info("backup_complete", date=today, size_mb=len(compressed) // 1024 // 1024)

Installatørfoto-upload

# server/src/meter_installation/router.py

@router.post("/{installation_id}/photo")
async def upload_installation_photo(
    installation_id: str,
    file: UploadFile = File(...),
    user: TokenPayload = Depends(require_role("installer", "technician")),
):
    """Upload installatørfoto fra Tech PWA."""
    # Valider filtype:
    if file.content_type not in ["image/jpeg", "image/png"]:
        raise HTTPException(400, "Kun JPEG og PNG understøttes")

    # Begræns størrelse (5 MB):
    content = await file.read()
    if len(content) > 5 * 1024 * 1024:
        raise HTTPException(400, "Fil må maks. være 5 MB")

    key = f"photos/{installation_id}/{int(time.time())}.jpg"
    url = upload_to_object_storage(
        key=key,
        data=content,
        content_type=file.content_type,
        metadata={
            "installation_id": installation_id,
            "uploaded_by": str(user.sub),
        },
    )

    return {"photo_url": url}

Konklusion

Hetzner Object Storage med boto3 er drop-in kompatibelt med AWS S3 — kun endpoint_url ændres. Pre-signed URLs giver tidsbegrænset adgang til private filer (PDF-afregninger) uden at eksponere API-nøgler. EU-hosting er GDPR-korrekt for persondata. Backup med immutable retention sikrer overholdelse af bogføringspligtens 5-årsregel.

Se Hetzner Docker guide eller WeasyPrint PDF guide.