1 bis 8 Party: mobile Web-App (Regeln, Karten, Spielleiter) + Docker-Deploy
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
# 1 bis 8 Party – Web-Stack auf hocks.eu
|
||||
# Muster: Mieterportal-Stack (/opt/mieterportal + /opt/mp-mail)
|
||||
# TLS + Subdomain liefert der Nginx Proxy Manager (npm_default-Netz).
|
||||
#
|
||||
# Dienste:
|
||||
# onebis8-web nginx, statisches dist/ (Mobile-App), proxiet
|
||||
# POST /api/mail/send → onebis8-mail-api (Token aus app.env)
|
||||
# onebis8-mail Postfix-Relay (nur intern im Docker-Netz, kein -p)
|
||||
# onebis8-mail-api schlanke Mail-HTTP-API mit Outbox/Retry (Muster: mp-mail-api)
|
||||
#
|
||||
# Konfiguration:
|
||||
# app.env MAIL_API_TOKEN (Token für den nginx-Proxy, gitignored)
|
||||
# mail/mail.env Relay, Absender, Token (gitignored)
|
||||
|
||||
services:
|
||||
onebis8-web:
|
||||
build: ./web
|
||||
image: onebis8-web:latest
|
||||
container_name: onebis8-web
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./web/docker/templates/default.conf.template:/etc/nginx/templates/default.conf.template:ro
|
||||
# envsubst ersetzt ${MAIL_API_TOKEN} beim Start aus app.env
|
||||
env_file:
|
||||
- app.env
|
||||
depends_on:
|
||||
- onebis8-mail-api
|
||||
networks:
|
||||
- npm_default
|
||||
|
||||
# Postfix-Relay (nur intern)
|
||||
onebis8-mail:
|
||||
build: ./mail/postfix
|
||||
image: onebis8-postfix:latest
|
||||
container_name: onebis8-mail
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- mail/mail.env
|
||||
expose:
|
||||
- "25"
|
||||
networks:
|
||||
- npm_default
|
||||
|
||||
# Kleine HTTP-API, über die die Webseite Mails an den Relay übergibt.
|
||||
onebis8-mail-api:
|
||||
image: python:3.12-alpine
|
||||
container_name: onebis8-mail-api
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- mail/mail.env
|
||||
environment:
|
||||
RELAY_HOST: onebis8-mail
|
||||
RELAY_PORT: "25"
|
||||
PORT: "8080"
|
||||
ALLOWED_ORIGIN: https://1bis.hocks.eu
|
||||
DATA_DIR: /data
|
||||
command: ["python", "/app/server.py"]
|
||||
volumes:
|
||||
- ./mail/api/server.py:/app/server.py:ro
|
||||
- onebis8-mail-data:/data
|
||||
depends_on:
|
||||
- onebis8-mail
|
||||
networks:
|
||||
- npm_default
|
||||
|
||||
networks:
|
||||
npm_default:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
onebis8-mail-data:
|
||||
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hocks Mieterportal – schlanke Mail-API (Muster: Schulportal).
|
||||
|
||||
Nimmt Mails vom Portal entgegen (POST /send, Token-Pflicht) und leitet sie
|
||||
über den internen Postfix-Relay (mp-mail:25) aus. Versand läuft über eine
|
||||
Outbox mit Retry-Worker (Backoff bis 60 min, max. 10 Versuche) – fällt der
|
||||
Versand aus (Rate-Limit etc.), wird automatisch erneut versucht.
|
||||
|
||||
Reine Standardbibliothek (python:3.12-alpine, keine pip-Abhängigkeiten).
|
||||
|
||||
Endpunkte:
|
||||
GET /healthz → {"ok": true}
|
||||
GET /outbox → aktuelle Outbox (Demo/Debug)
|
||||
POST /send → {"ok": true, "id": ..., "versandt": true/false}
|
||||
Body: {"to": "...", "subject": "...", "text": "...", "html": "...",
|
||||
optional "cc": "a@b.de, c@d.de", "from_address"/"from_name" (nur @hocks.eu),
|
||||
optional "attachment_b64" (base64), "attachment_filename", "attachment_mime"}
|
||||
Auth: Header X-Api-Token oder Body-Feld "token" == MAIL_API_TOKEN
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import smtplib
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from email.message import EmailMessage
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
# ── Konfiguration ─────────────────────────────────────────────
|
||||
RELAY_HOST = os.environ.get("RELAY_HOST", "mp-mail")
|
||||
RELAY_PORT = int(os.environ.get("RELAY_PORT", "25"))
|
||||
PORT = int(os.environ.get("PORT", "8080"))
|
||||
ALLOWED_ORIGIN = os.environ.get("ALLOWED_ORIGIN", "*")
|
||||
DATA_DIR = os.environ.get("DATA_DIR", "/data")
|
||||
SENDER_ADDRESS = os.environ.get("SENDER_ADDRESS", "noreply@hocks.eu")
|
||||
SENDER_NAME = os.environ.get("SENDER_NAME", "Hinterstein Immobilien")
|
||||
MAIL_API_TOKEN = os.environ.get("MAIL_API_TOKEN", "")
|
||||
RATE_LIMIT = int(os.environ.get("RATE_LIMIT", "500")) # Mails pro Stunde
|
||||
|
||||
MAX_VERSUCHE = 10
|
||||
BACKOFF_MAX_SEK = 3600
|
||||
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
OUTBOX_DATEI = os.path.join(DATA_DIR, "outbox.json")
|
||||
|
||||
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
_lock = threading.Lock()
|
||||
_outbox_lock = threading.Lock() # serialisiert Outbox-Laden/Speichern
|
||||
_send_log: list[float] = [] # Timestamps für Rate-Limit
|
||||
|
||||
|
||||
# ── Outbox ────────────────────────────────────────────────────
|
||||
def outbox_laden() -> list[dict]:
|
||||
try:
|
||||
with open(OUTBOX_DATEI, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return []
|
||||
|
||||
|
||||
def outbox_speichern(eintraege: list[dict]) -> None:
|
||||
tmp = OUTBOX_DATEI + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(eintraege, f, ensure_ascii=False, indent=1)
|
||||
os.replace(tmp, OUTBOX_DATEI)
|
||||
|
||||
|
||||
def rate_limit_ok() -> bool:
|
||||
jetzt = time.time()
|
||||
global _send_log
|
||||
with _lock:
|
||||
_send_log = [t for t in _send_log if jetzt - t < 3600]
|
||||
if len(_send_log) >= RATE_LIMIT:
|
||||
return False
|
||||
_send_log.append(jetzt)
|
||||
return True
|
||||
|
||||
|
||||
def versenden(e: dict) -> bool:
|
||||
msg = EmailMessage()
|
||||
msg["From"] = f"{e.get('from_name') or SENDER_NAME} <{e.get('from_address') or SENDER_ADDRESS}>"
|
||||
msg["To"] = e["to"]
|
||||
if e.get("cc"):
|
||||
msg["Cc"] = e["cc"] # Komma-getrennte CC-Empfaenger (v1.22)
|
||||
msg["Subject"] = e["subject"]
|
||||
msg.set_content(e.get("text") or "")
|
||||
if e.get("html"):
|
||||
msg.add_alternative(e["html"], subtype="html")
|
||||
# Optionaler Anhang (base64, v1.31) – z. B. erzeugte PDF-Abrechnungen
|
||||
if e.get("anhang_b64"):
|
||||
import base64
|
||||
daten = base64.b64decode(e["anhang_b64"])
|
||||
mime = e.get("anhang_mime") or "application/pdf"
|
||||
haupt, _, unter = mime.partition("/")
|
||||
msg.add_attachment(
|
||||
daten, maintype=haupt or "application", subtype=unter or "octet-stream",
|
||||
filename=e.get("anhang_dateiname") or "anhang.pdf",
|
||||
)
|
||||
with smtplib.SMTP(RELAY_HOST, RELAY_PORT, timeout=20) as smtp:
|
||||
smtp.send_message(msg)
|
||||
return True
|
||||
|
||||
|
||||
def outbox_eintrag(e: dict) -> None:
|
||||
with _outbox_lock:
|
||||
eintraege = outbox_laden()
|
||||
eintraege.append(e)
|
||||
outbox_speichern(eintraege)
|
||||
|
||||
|
||||
def worker() -> None:
|
||||
"""Retry-Worker: versucht offene Outbox-Einträge mit Backoff erneut.
|
||||
Erfolgreiche Mails werden aus der Outbox entfernt und geloggt; nach
|
||||
MAX_VERSUCHE erfolgt ein endgültiger Abbruch mit ALARM-Zeile (docker logs),
|
||||
damit verlorene Mails sichtbar statt stillschweigend verschwinden."""
|
||||
while True:
|
||||
time.sleep(30)
|
||||
jetzt = time.time()
|
||||
with _outbox_lock:
|
||||
eintraege = outbox_laden()
|
||||
for e in list(eintraege):
|
||||
if e.get("versandt"):
|
||||
with _outbox_lock:
|
||||
alle = outbox_laden()
|
||||
alle = [x for x in alle if x.get("id") != e.get("id")]
|
||||
outbox_speichern(alle)
|
||||
continue
|
||||
if e.get("versuche", 0) >= MAX_VERSUCHE:
|
||||
print(f"[{time.strftime('%H:%M:%S')}] ALARM: Versand an {e.get('to')} "
|
||||
f"nach {MAX_VERSUCHE} Versuchen endgültig abgebrochen: "
|
||||
f"{e.get('subject')}", flush=True)
|
||||
with _outbox_lock:
|
||||
alle = outbox_laden()
|
||||
alle = [x for x in alle if x.get("id") != e.get("id")]
|
||||
outbox_speichern(alle)
|
||||
continue
|
||||
if jetzt - e.get("letzterVersuch", 0) < min(60 * 2 ** e.get("versuche", 0), BACKOFF_MAX_SEK):
|
||||
continue
|
||||
try:
|
||||
e["versuche"] = e.get("versuche", 0) + 1
|
||||
e["letzterVersuch"] = jetzt
|
||||
versenden(e)
|
||||
print(f"[{time.strftime('%H:%M:%S')}] VERSANDT OK an {e.get('to')}: "
|
||||
f"{e.get('subject')}", flush=True)
|
||||
with _outbox_lock:
|
||||
alle = outbox_laden()
|
||||
alle = [x for x in alle if x.get("id") != e.get("id")]
|
||||
outbox_speichern(alle)
|
||||
except Exception as exc:
|
||||
print(f"[{time.strftime('%H:%M:%S')}] Versand an {e.get('to')} fehlgeschlagen "
|
||||
f"(Versuch {e['versuche']}/{MAX_VERSUCHE}): {exc}", flush=True)
|
||||
if e["versuche"] >= MAX_VERSUCHE:
|
||||
print(f"[{time.strftime('%H:%M:%S')}] ALARM: Versand an {e.get('to')} "
|
||||
f"nach {MAX_VERSUCHE} Versuchen endgültig abgebrochen: "
|
||||
f"{e.get('subject')}", flush=True)
|
||||
with _outbox_lock:
|
||||
alle = outbox_laden()
|
||||
alle = [x for x in alle if x.get("id") != e.get("id")]
|
||||
outbox_speichern(alle)
|
||||
else:
|
||||
with _outbox_lock:
|
||||
alle = outbox_laden()
|
||||
for x in alle:
|
||||
if x.get("id") == e.get("id"):
|
||||
x["versuche"] = e["versuche"]
|
||||
x["letzterVersuch"] = e["letzterVersuch"]
|
||||
outbox_speichern(alle)
|
||||
|
||||
|
||||
# ── HTTP ──────────────────────────────────────────────────────
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "mp-mail-api/1.0"
|
||||
|
||||
def _antwort(self, code: int, body: dict) -> None:
|
||||
daten = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(daten)))
|
||||
if ALLOWED_ORIGIN != "*":
|
||||
self.send_header("Access-Control-Allow-Origin", ALLOWED_ORIGIN)
|
||||
self.end_headers()
|
||||
self.wfile.write(daten)
|
||||
|
||||
def _body(self) -> dict:
|
||||
try:
|
||||
laenge = int(self.headers.get("Content-Length", "0"))
|
||||
return json.loads(self.rfile.read(laenge) or b"{}")
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
def do_OPTIONS(self) -> None: # noqa: N802
|
||||
self.send_response(204)
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Api-Token")
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
if self.path.split("?")[0] == "/healthz":
|
||||
self._antwort(200, {"ok": True, "service": "mp-mail-api"})
|
||||
elif self.path.split("?")[0] == "/outbox":
|
||||
self._antwort(200, {"eintraege": outbox_laden()})
|
||||
else:
|
||||
self._antwort(404, {"fehler": "nicht gefunden"})
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
if self.path.split("?")[0] != "/send":
|
||||
self._antwort(404, {"fehler": "nicht gefunden"})
|
||||
return
|
||||
body = self._body()
|
||||
token = self.headers.get("X-Api-Token") or body.get("token") or ""
|
||||
if not MAIL_API_TOKEN or token != MAIL_API_TOKEN:
|
||||
self._antwort(401, {"fehler": "ungueltiger Token"})
|
||||
return
|
||||
an = str(body.get("to", "")).strip()
|
||||
betreff = str(body.get("subject", "")).strip()
|
||||
if not EMAIL_RE.match(an) or not betreff:
|
||||
self._antwort(400, {"fehler": "Empfaenger oder Betreff ungueltig"})
|
||||
return
|
||||
if not rate_limit_ok():
|
||||
self._antwort(429, {"fehler": "Rate-Limit erreicht"})
|
||||
return
|
||||
# Optionaler eigener Absender (nur hocks.eu-Domain, sonst Fallback)
|
||||
absender = str(body.get("from_address", "")).strip().lower()
|
||||
absender_name = str(body.get("from_name", "")).strip()
|
||||
if (not EMAIL_RE.match(absender) or not absender.endswith("@hocks.eu")
|
||||
or len(absender_name) > 60 or any(c in absender_name for c in "<>\r\n")):
|
||||
absender, absender_name = SENDER_ADDRESS, SENDER_NAME
|
||||
# Optional CC (Komma-getrennt, einzeln validiert) – v1.22
|
||||
cc = ','.join(
|
||||
a for a in (x.strip() for x in str(body.get("cc", "")).split(","))
|
||||
if a and EMAIL_RE.match(a)
|
||||
)
|
||||
eintrag = {
|
||||
"id": uuid.uuid4().hex[:12],
|
||||
"to": an,
|
||||
"subject": betreff,
|
||||
"text": str(body.get("text", "")),
|
||||
"html": str(body.get("html", "")),
|
||||
"cc": cc,
|
||||
"from_address": absender,
|
||||
"from_name": absender_name,
|
||||
"versuche": 0,
|
||||
"versandt": False,
|
||||
"angenommenAm": time.strftime("%d.%m.%Y %H:%M:%S"),
|
||||
}
|
||||
# Optionaler Anhang (v1.31): base64 + Dateiname + MIME, max. ca. 20 MB
|
||||
anhang_b64 = str(body.get("attachment_b64", ""))
|
||||
if anhang_b64:
|
||||
if len(anhang_b64) > 28_000_000:
|
||||
self._antwort(413, {"fehler": "Anhang zu gross (max. ca. 20 MB)"})
|
||||
return
|
||||
dateiname = os.path.basename(str(body.get("attachment_filename", "anhang.pdf"))) or "anhang.pdf"
|
||||
eintrag["anhang_b64"] = anhang_b64
|
||||
eintrag["anhang_dateiname"] = dateiname
|
||||
eintrag["anhang_mime"] = str(body.get("attachment_mime", "application/pdf"))
|
||||
try:
|
||||
versenden(eintrag)
|
||||
eintrag["versandt"] = True
|
||||
eintrag["versandtAm"] = eintrag["angenommenAm"]
|
||||
eintrag["letzterVersuch"] = time.time()
|
||||
except Exception:
|
||||
# Jeder Fehler (Relay-Timeout, RCPT-Ablehnung 4xx/5xx, DNS ...) wird
|
||||
# in die Outbox gelegt; der Worker wiederholt mit exponentiellem Backoff.
|
||||
outbox_eintrag(eintrag)
|
||||
self._antwort(200, {"ok": True, "id": eintrag["id"], "versandt": eintrag["versandt"]})
|
||||
|
||||
def log_message(self, fmt: str, *args) -> None:
|
||||
print(f"[{time.strftime('%H:%M:%S')}] {self.address_string()} {fmt % args}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
print(f"mp-mail-api lauscht auf :{PORT} (Relay {RELAY_HOST}:{RELAY_PORT}, Limit {RATE_LIMIT}/h)")
|
||||
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
|
||||
@@ -0,0 +1,15 @@
|
||||
FROM alpine:3.20
|
||||
|
||||
RUN apk add --no-cache postfix ca-certificates
|
||||
|
||||
COPY main.cf /etc/postfix/main.cf
|
||||
COPY virtual /etc/postfix/virtual
|
||||
RUN postmap /etc/postfix/virtual
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh && \
|
||||
mkdir -p /var/spool/postfix /etc/postfix
|
||||
|
||||
# Port 25 nur intern im Docker-Netz (kein -p beim Start!)
|
||||
EXPOSE 25
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Absender-Umschreibung auf die Nicht-Antworten-Adresse des hocks.eu-Tenants.
|
||||
# Bei Direktzustellung an den M365-Eingang vertraut der Konnektor der
|
||||
# Absender-IP – Absenderadresse wird zentral umgeschrieben, damit keine
|
||||
# fremden Absenderdomains entstehen.
|
||||
CANONICAL_ADDRESS="${CANONICAL_ADDRESS:-noreply@hocks.eu}"
|
||||
CANONICAL_NAME="${CANONICAL_NAME:-Hinterstein Immobilien}"
|
||||
FAMILIE_ADDRESS="${FAMILIE_ADDRESS:-familienportal@hocks.eu}"
|
||||
FAMILIE_NAME="${FAMILIE_NAME:-Hocks Familienportal}"
|
||||
{
|
||||
echo "/^${FAMILIE_ADDRESS}$/ ${FAMILIE_NAME} <${FAMILIE_ADDRESS}>"
|
||||
echo "/.+/ ${CANONICAL_NAME} <${CANONICAL_ADDRESS}>"
|
||||
} > /etc/postfix/sender_canonical
|
||||
|
||||
# Optionalen Relayhost aus der Umgebung setzen (z. B. [mx-von-hocks.eu]:25)
|
||||
if [ -n "${RELAY_HOST:-}" ]; then
|
||||
postconf -e "relayhost = ${RELAY_HOST}"
|
||||
fi
|
||||
|
||||
# Chroot in Containern deaktivieren (sonst lokale Unix-Sockets nicht erreichbar)
|
||||
postconf -F '*/*/chroot=n'
|
||||
|
||||
echo "MP-Mail-Relay startet (Relayhost: ${RELAY_HOST:-direkt}, Absender -> ${CANONICAL_NAME} <${CANONICAL_ADDRESS}>)"
|
||||
exec postfix start-fg
|
||||
@@ -0,0 +1,36 @@
|
||||
# Hocks Mail-Relay (1 bis 8 Party): liefert intern angenommene Mails über den
|
||||
# konfigurierten Relayhost aus (O365-MX-Direktzustellung wie im Mieterportal,
|
||||
# oder smarthost mit AUTH-Erweiterung möglich).
|
||||
# KEIN SMTP AUTH noetig bei Direktzustellung – wichtig: Container ist nicht
|
||||
# oeffentlich exponiert, Relaying nur aus dem Docker-Netz (mynetworks).
|
||||
compatibility_level = 3.6
|
||||
|
||||
myhostname = mail.1bis.hocks.eu
|
||||
mydomain = hocks.eu
|
||||
myorigin = $myhostname
|
||||
|
||||
# relayhost wird zur Laufzeit vom Entrypoint aus $RELAY_HOST gesetzt
|
||||
# (Direktzustellung: [mx-von-hocks.eu]:25). Leer = direkte Auslieferung.
|
||||
smtp_tls_security_level = encrypt
|
||||
smtp_tls_CApath = /etc/ssl/certs
|
||||
smtp_tls_loglevel = 1
|
||||
|
||||
# Alle Absender (Envelope + Header) auf die Nicht-Antworten-Adresse umschreiben
|
||||
sender_canonical_classes = envelope_sender, header_sender
|
||||
sender_canonical_maps = regexp:/etc/postfix/sender_canonical
|
||||
|
||||
# NDRs an die nicht existente Absenderadresse noreply@ werden auf das echte
|
||||
# Postfach axel@ umgeleitet – verhindert den Bounce-Loop ueber M365 (550).
|
||||
virtual_alias_maps = lmdb:/etc/postfix/virtual
|
||||
|
||||
# Nur intern erreichbar – Relaying nur aus dem Docker-Netz erlaubt (kein offenes Relay!)
|
||||
inet_interfaces = all
|
||||
inet_protocols = ipv4
|
||||
mynetworks = 127.0.0.0/8 172.16.0.0/12
|
||||
smtpd_relay_restrictions = permit_mynetworks, defer
|
||||
smtpd_recipient_restrictions = permit_mynetworks, reject_unauth_destination
|
||||
smtpd_client_restrictions = permit_mynetworks, reject
|
||||
|
||||
maillog_file = /dev/stdout
|
||||
biff = no
|
||||
append_dot_mydomain = no
|
||||
@@ -0,0 +1 @@
|
||||
noreply@hocks.eu axel@hocks.eu
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
# Statischer Build der 1-bis-8-Party-Mobile-App
|
||||
COPY dist/ /usr/share/nginx/html/
|
||||
|
||||
# nginx-Site (envsubst ersetzt ${MAIL_API_TOKEN} aus der Container-Umgebung)
|
||||
COPY docker/templates/default.conf.template /etc/nginx/templates/default.conf.template
|
||||
@@ -0,0 +1,55 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name 1bis.hocks.eu localhost _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript image/svg+xml application/json;
|
||||
gzip_min_length 1024;
|
||||
|
||||
# Docker-eigener DNS: Upstream-Namen bei jeder Anfrage neu auflösen.
|
||||
# Ohne das puffert nginx die Container-IP vom Start – nach einem Recreate
|
||||
# der Mail-API (neue IP) liefert jede Anfrage 502, bis nginx neu startet.
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
|
||||
# HTML nie cachen (Assets sind hash-benannt → ein Reload genügt)
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache" always;
|
||||
}
|
||||
|
||||
# Cache für gehashte Build-Assets
|
||||
location ^~ /assets/ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Statische Dateien im Root (Favicons – nicht hash-benannt, kurz cachen)
|
||||
location ~* \.(png|jpg|jpeg|webp|ico|svg|woff2?)$ {
|
||||
expires 1d;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
# Mail-API (Container onebis8-mail-api im selben Docker-Netz npm_default).
|
||||
# Das Token kommt aus app.env (envsubst beim Container-Start) und liegt
|
||||
# NIE im Browser – der Endpunkt ist nur vom Server erreichbar.
|
||||
location = /api/mail/send {
|
||||
limit_except POST { deny all; }
|
||||
set $mail_api http://onebis8-mail-api:8080;
|
||||
proxy_pass $mail_api/send;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Api-Token "${MAIL_API_TOKEN}";
|
||||
proxy_set_header X-Request-Source onebis8-nginx;
|
||||
client_max_body_size 64k;
|
||||
proxy_read_timeout 15s;
|
||||
}
|
||||
|
||||
# SPA-Fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
}
|
||||
Reference in New Issue
Block a user