· 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.