feat(aether): initial commit – self-hosted Cloud, Phasen 1–7
Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA. Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb, Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming, Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office (Collabora/OnlyOffice). Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort) sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -0,0 +1,160 @@
|
||||
# Aether – Environment Variables
|
||||
# Copy to .env and fill in values. Never commit .env!
|
||||
|
||||
# ── App ──────────────────────────────────────────────────────────────────────
|
||||
APP_ENV=production # development | production
|
||||
|
||||
# Internal listen address. Your external reverse proxy forwards here.
|
||||
APP_LISTEN_ADDR=:8080
|
||||
|
||||
# Public-facing URL (used for OIDC redirects, WOPI callbacks, share links).
|
||||
# Must match what the browser sees — include scheme + domain, no trailing slash.
|
||||
PUBLIC_URL=https://cloud.example.com
|
||||
|
||||
# Comma-separated IPs or CIDRs of reverse proxies allowed to set
|
||||
# X-Forwarded-For / X-Forwarded-Proto. Everyone else is untrusted.
|
||||
# Example: 172.18.0.1,10.0.0.0/8
|
||||
TRUSTED_PROXIES=172.18.0.1
|
||||
|
||||
# ── Database ─────────────────────────────────────────────────────────────────
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_NAME=aether
|
||||
DB_USER=aether
|
||||
DB_PASSWORD=changeme_strong_password
|
||||
|
||||
# ── Session ───────────────────────────────────────────────────────────────────
|
||||
# Generate with: openssl rand -hex 32
|
||||
SESSION_SECRET=changeme_generate_with_openssl_rand_hex_32
|
||||
SESSION_MAX_AGE=86400 # seconds (24h)
|
||||
|
||||
# ── Storage ───────────────────────────────────────────────────────────────────
|
||||
STORAGE_PATH=/data
|
||||
TMP_PATH=/data/tmp
|
||||
|
||||
# ── Admin seed ────────────────────────────────────────────────────────────────
|
||||
# Used only on first startup to create the initial admin account.
|
||||
# Remove or leave blank after first start.
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=changeme_strong_admin_password
|
||||
|
||||
# ── OIDC (optional, Phase 4) ─────────────────────────────────────────────────
|
||||
# Wenn OIDC_ISSUER leer ist, ist OIDC-Login deaktiviert (kein SSO-Button).
|
||||
# OIDC_REDIRECT_URL muss exakt <PUBLIC_URL>/api/v1/auth/oidc/callback sein
|
||||
# und beim IdP als erlaubte Redirect-URI registriert werden.
|
||||
# OIDC_ISSUER=https://your-idp.example.com
|
||||
# OIDC_CLIENT_ID=aether
|
||||
# OIDC_CLIENT_SECRET=
|
||||
# OIDC_REDIRECT_URL=https://cloud.example.com/api/v1/auth/oidc/callback
|
||||
|
||||
# ── WebDAV ────────────────────────────────────────────────────────────────────
|
||||
# Erreichbar unter /dav/ (Basic Auth, Username + Passwort). Kein Setting nötig.
|
||||
# Reverse Proxy muss PROPFIND/MKCOL/MOVE/COPY durchreichen.
|
||||
|
||||
# ── Office / WOPI ─────────────────────────────────────────────────────────────
|
||||
# Steht weiter unten, mit allen Adressen. (Hier stand bis zum 24.08.2026 ein
|
||||
# Rest aus der Planung mit OFFICE_SERVER_URL – den Schlüssel gibt es nicht.)
|
||||
|
||||
# ── Features ─────────────────────────────────────────────────────────────────
|
||||
# MEILISEARCH_URL=http://meilisearch:7700
|
||||
# MEILISEARCH_KEY=
|
||||
|
||||
# ── App-Umschalter ────────────────────────────────────────────────────────────
|
||||
# Andere selbst gehostete Apps, die im Menü oben in der Seitenleiste erscheinen.
|
||||
# Format: Name|URL|Emoji – mehrere durch Komma getrennt. Leer = kein Menü.
|
||||
# Beispiel: EXTERNAL_APPS=InkNote|https://notes.honeyy.duckdns.org|📓
|
||||
EXTERNAL_APPS=
|
||||
|
||||
# ── OIDC: bestehendes Konto verbinden ───────────────────────────────────────
|
||||
# Es gibt schon ein Konto mit demselben Benutzernamen, aber ohne Bindung an den
|
||||
# Identitätsanbieter – was soll passieren?
|
||||
#
|
||||
# false (Voreinstellung): Es entsteht ein ZWEITES Konto. Da der Name vergeben
|
||||
# ist, heißt es "name2" – leer, ohne Adminrechte, ohne deine Dateien.
|
||||
# true: Das bestehende Konto wird mit der Identität verbunden. Dateien,
|
||||
# Adminrecht und Freigaben bleiben; ab dann meldest du dich darüber an.
|
||||
#
|
||||
# Auf "true" nur, wenn du dem Anbieter glaubst, dass "marc" bei ihm derselbe
|
||||
# "marc" ist wie hier – also bei einem eigenen Anbieter (PocketID, Authentik),
|
||||
# dessen Konten du selbst anlegst. Bei einem Anbieter mit freier Registrierung
|
||||
# wäre es die Übernahme fremder Konten.
|
||||
#
|
||||
# Verbunden wird nur, was noch KEINE Bindung hat – ein Konto, das schon zu einer
|
||||
# anderen Identität gehört, wird nie überschrieben.
|
||||
# OIDC_LINK_BY_USERNAME=true
|
||||
|
||||
# ── Wo die Dateien liegen ────────────────────────────────────────────────────
|
||||
# Ein gewöhnliches Verzeichnis auf dem Wirt, kein Docker-Volume. Damit kommst
|
||||
# du per SFTP, rsync oder im Dateimanager an deine Dateien – sie liegen dort
|
||||
# als echte Ordner mit echten Namen (users/<name>/home/…).
|
||||
#
|
||||
# Nur lesend benutzen: Wer von Hand hineinschreibt, hat eine Datei, die die
|
||||
# Cloud nicht kennt. Zum Hineinlegen ist WebDAV der Weg.
|
||||
DATA_PATH=/pfad/zu/aether-data
|
||||
|
||||
# Der Container läuft unter dieser Kennung, damit die Dateien im Verzeichnis
|
||||
# oben **dir** gehören und nicht einem Container-Nutzer. `id -u` und `id -g`
|
||||
# verraten deine.
|
||||
APP_UID=1000
|
||||
APP_GID=1000
|
||||
|
||||
# Port auf dem Wirt – gebunden auf **loopback**, davor gehört dein Reverse
|
||||
# Proxy. Im Container bleibt es immer 8080; hier steht nur, unter welchem Port
|
||||
# der Wirt ihn anbietet.
|
||||
APP_PORT=8080
|
||||
|
||||
# ── Volltextsuche über den Inhalt (OCR) ──────────────────────────────────────
|
||||
# Holt den Text aus PDFs und Bildern, damit auch ein Scan namens
|
||||
# "scan_0042.pdf" über seinen Inhalt auffindbar ist. Das Original wird dabei
|
||||
# nicht verändert – der Text landet daneben in der Datenbank.
|
||||
#
|
||||
# Standardmäßig an. Fehlen ocrmypdf/tesseract im Image, schaltet sich der
|
||||
# Arbeiter beim Start von selbst ab und die Cloud läuft ohne Volltext weiter.
|
||||
# OCR_ENABLED=false
|
||||
|
||||
# Sprachmodelle für tesseract. Im Image liegen deu und eng; weitere brauchen
|
||||
# ein zusätzliches apk-Paket (tesseract-ocr-data-<sprache>) im Dockerfile.
|
||||
# OCR_LANGUAGES=deu+eng
|
||||
|
||||
# Höchstzahl erkannter Seiten je Datei. OCR kostet rund eine Minute je
|
||||
# gescannter Seite – ohne Grenze blockiert ein dickes Handbuch die
|
||||
# Warteschlange für alles andere. 0 = unbegrenzt.
|
||||
# OCR_MAX_PAGES=50
|
||||
|
||||
# Notbremse je Datei, falls eine einzelne Seite entgleist.
|
||||
# OCR_TIMEOUT=10m
|
||||
|
||||
# ── Office (optional) ────────────────────────────────────────────────────────
|
||||
# Leer = aus. Die Anwendung ist ohne Office vollständig.
|
||||
#
|
||||
# Beide Anbieter sprechen WOPI, es gibt also nur einen Adapter:
|
||||
# collabora – LibreOffice-Kern, ~1 GB Arbeitsspeicher, ODF nativ
|
||||
# onlyoffice – beste .docx/.xlsx-Treue, 2 GB Minimum / 4 GB empfohlen
|
||||
OFFICE_PROVIDER=
|
||||
OFFICE_URL=
|
||||
# Adresse, unter der der **Browser** den Office-Server erreicht. Leer = dieselbe
|
||||
# wie OFFICE_URL. Die beiden sind selten gleich: Im Compose-Netz heisst der
|
||||
# Server `collabora`, im Browser muss dort ein aufloesbarer Name stehen.
|
||||
OFFICE_PUBLIC_URL=
|
||||
# Adresse, unter der der **Office-Server** Aether erreicht. Leer = PUBLIC_URL.
|
||||
# Der am leichtesten uebersehene Weg: Der Office-Server holt die Datei selbst
|
||||
# ab, und aus seinem Container heraus ist „localhost" er selbst.
|
||||
# Laufen beide im selben Compose-Projekt, ist das der Dienstname: http://app:8080
|
||||
# Der veroeffentlichte Port (127.0.0.1:8090) taugt **nicht** – auf loopback
|
||||
# gebunden, aus einem Container also nicht erreichbar (ECONNREFUSED).
|
||||
OFFICE_WOPI_URL=
|
||||
# Adressen, die das Backend anfragen darf – gegen SSRF. Der Host aus OFFICE_URL
|
||||
# zählt immer dazu; hier kommt nur hinein, was zusätzlich erlaubt sein soll.
|
||||
OFFICE_ALLOWED_HOSTS=
|
||||
# Nur für den Testaufbau mit selbstsigniertem Zertifikat auf demselben Rechner.
|
||||
OFFICE_INSECURE_TLS=false
|
||||
# Für den mitgelieferten Collabora-Container (docker compose --profile office):
|
||||
# unter welcher Adresse er Aether erreicht. Punkte maskieren – Collabora liest
|
||||
# den Wert als regulären Ausdruck.
|
||||
OFFICE_ALIASGROUP=http://app:8080
|
||||
OFFICE_SSL=false
|
||||
# Endet HTTPS an deinem Reverse Proxy? Produktiv true, im reinen HTTP-Test
|
||||
# false – sonst verlangt Collabora vom Browser eine wss://-Verbindung, die es
|
||||
# nicht gibt, und der Editor bleibt leer.
|
||||
OFFICE_SSL_TERMINATION=false
|
||||
OFFICE_PORT=9980
|
||||
@@ -0,0 +1,58 @@
|
||||
# ── Stage 1: Frontend build ───────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 2: Backend build ────────────────────────────────────────────────────
|
||||
FROM golang:1.25-alpine AS backend-builder
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache git
|
||||
COPY backend/go.mod backend/go.sum ./
|
||||
RUN go mod download
|
||||
COPY backend/ ./
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o aether ./cmd/aether
|
||||
|
||||
# ── Stage 3: Final image ──────────────────────────────────────────────────────
|
||||
FROM alpine:3.20
|
||||
# ocrmypdf, tesseract und poppler machen den Inhalt von PDFs und Scans
|
||||
# durchsuchbar (internal/documents). Sie wiegen zusammen rund 330 MB – der
|
||||
# Löwenanteil des Images. Wer das nicht braucht, kann die zweite apk-Zeile
|
||||
# streichen und OCR_ENABLED=false setzen; der Server prüft beim Start selbst,
|
||||
# ob die Programme da sind, und läuft sonst ohne Volltextsuche weiter.
|
||||
#
|
||||
# tesseract-ocr-data-deu/-eng sind die Sprachmodelle. Ohne sie startet
|
||||
# tesseract zwar, erkennt aber nichts – das Paket tesseract-ocr allein bringt
|
||||
# keine mit.
|
||||
RUN apk add --no-cache ca-certificates tzdata && \
|
||||
apk add --no-cache ocrmypdf tesseract-ocr tesseract-ocr-data-deu \
|
||||
tesseract-ocr-data-eng poppler-utils && \
|
||||
addgroup -S aether && adduser -S aether -G aether && \
|
||||
mkdir -p /data/users /data/thumbs /data/tmp && \
|
||||
chown -R aether:aether /data
|
||||
WORKDIR /app
|
||||
|
||||
# Tesseract vermisst beim allerersten Aufruf die OpenCL-Geräte und schreibt
|
||||
# dabei einen langen Diagnoseblock nach stderr. ocrmypdf ruft davor
|
||||
# `tesseract --list-langs` auf und liest die Antwort – der Block bringt seinen
|
||||
# Parser aus dem Tritt, und der Aufruf endet mit „Tesseract failed to report
|
||||
# available languages" (Exit 3).
|
||||
#
|
||||
# Das Ergebnis der Vermessung landet als .dat im Arbeitsverzeichnis; ab dann ist
|
||||
# die Ausgabe kurz genug. Ohne diese Zeile scheitert deshalb genau das erste
|
||||
# Dokument nach jedem Neustart eines frischen Containers – gemessen, nicht
|
||||
# vermutet: Versuch 1 Exit 3, Versuch 2 erfolgreich. Der Wiederholungszähler
|
||||
# fängt es zwar auf, aber ein Fehlschlag, den man einbauen kann, gehört nicht
|
||||
# in den Log.
|
||||
RUN tesseract --list-langs >/dev/null 2>&1 || true
|
||||
|
||||
COPY --from=backend-builder /app/aether ./aether
|
||||
COPY --from=frontend-builder /app/frontend/dist ./static
|
||||
|
||||
RUN chown -R aether:aether /app
|
||||
USER aether
|
||||
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["./aether"]
|
||||
@@ -0,0 +1,137 @@
|
||||
services:
|
||||
app:
|
||||
container_name: aether-app
|
||||
# Fertiges Abbild aus der eigenen Gitea-Registry.
|
||||
#
|
||||
# Ein Tag mit Datum **und** `latest`: Zum Aufbauen nimmt man `latest`, zum
|
||||
# Zurückrollen den datierten. Wer nur `latest` fährt, hat im Fehlerfall
|
||||
# nichts, worauf er zurück kann – der Name zeigt dann schon auf die
|
||||
# Fassung, die gerade Ärger macht.
|
||||
#
|
||||
# Anmelden vor dem ersten Zug:
|
||||
# docker login gitea.honeyy.duckdns.org
|
||||
#
|
||||
# Aktualisieren:
|
||||
# docker compose pull app && docker compose up -d app
|
||||
image: gitea.honeyy.duckdns.org/honey/aether:latest
|
||||
# Selbst bauen statt ziehen? Dann die Zeile darüber auskommentieren und
|
||||
# diese drei einkommentieren. Der Kontext ist das **Wurzelverzeichnis** des
|
||||
# Projekts, nicht dieser Ordner – das Abbild braucht backend/ und frontend/.
|
||||
#
|
||||
# docker compose build app
|
||||
#
|
||||
# build:
|
||||
# context: ..
|
||||
# dockerfile: deploy/Dockerfile
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
# Als der Nutzer laufen, dem das Datenverzeichnis auf dem Wirt gehört.
|
||||
# Ohne das legt der Container seine Dateien als uid 100 an, und du kämst
|
||||
# per SFTP nur als root daran.
|
||||
user: "${APP_UID:-1000}:${APP_GID:-1000}"
|
||||
# ocrmypdf und ghostscript legen Arbeitsdateien unter $HOME ab. Bei einem
|
||||
# abweichenden Nutzer ist das Heimverzeichnis aus dem Image nicht mehr
|
||||
# beschreibbar – /tmp im Container ist es immer.
|
||||
environment:
|
||||
HOME: /tmp
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
# Bind-Mount statt eines benannten Volumes: Deine Dateien liegen damit in
|
||||
# einem gewöhnlichen Verzeichnis auf dem Wirt, mit echten Ordnern und
|
||||
# echten Namen (users/<name>/home/…). Du kommst per SFTP, rsync oder
|
||||
# einfach im Dateimanager daran – auch dann noch, wenn diese Anwendung
|
||||
# nie wieder startet. Bei einem benannten Volume läge alles unter
|
||||
# /var/lib/docker/volumes/ und gehörte root.
|
||||
#
|
||||
# Nur lesen. Wer hier hineinschreibt, hat eine Datei auf der Platte, die
|
||||
# in der Datenbank nicht existiert – die Cloud sieht sie nicht, und der
|
||||
# GC räumt sie irgendwann weg. Zum Hineinlegen ist WebDAV der Weg.
|
||||
- ${DATA_PATH:-./data}:/data
|
||||
networks:
|
||||
- internal
|
||||
ports:
|
||||
# Expose on loopback only — your external reverse proxy connects here.
|
||||
# Host port via APP_PORT in .env (default 8080), container port stays 8080.
|
||||
- "127.0.0.1:${APP_PORT:-8080}:8080"
|
||||
|
||||
postgres:
|
||||
container_name: aether-db
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- internal
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
# ── Office (optional) ──────────────────────────────────────────────────────
|
||||
# Laeuft nur mit `--profile office`. Ohne das Profil startet der Container
|
||||
# nicht – wer kein Office will, soll nicht 1 GB Arbeitsspeicher dafuer
|
||||
# abgeben, nur weil die Zeilen hier stehen.
|
||||
#
|
||||
# docker compose --profile office up -d
|
||||
#
|
||||
# Der Office-Server holt die Datei **selbst** beim Backend ab. Er muss Aether
|
||||
# also unter PUBLIC_URL erreichen, nicht unter localhost. Im selben
|
||||
# Compose-Netz erreicht er es unter `app:8080` – dafuer steht `extra_hosts`
|
||||
# bzw. ein PUBLIC_URL, das auf den Wirt zeigt.
|
||||
collabora:
|
||||
container_name: aether-collabora
|
||||
image: collabora/code:latest
|
||||
profiles: ["office"]
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Welche Hosts ihre Dateien hier oeffnen duerfen. Punkte maskiert, weil
|
||||
# Collabora den Wert als regulaeren Ausdruck liest.
|
||||
aliasgroup1: ${OFFICE_ALIASGROUP:-http://host.docker.internal:8090}
|
||||
# Zwei getrennte Schalter, und beide zaehlen:
|
||||
#
|
||||
# ssl.enable – spricht Collabora selbst HTTPS?
|
||||
# ssl.termination – endet HTTPS davor, an einem Reverse Proxy?
|
||||
#
|
||||
# Steht termination auf true, sagt Collabora dem Browser „verbinde dich
|
||||
# per wss://" – auch dann, wenn davor gar kein HTTPS liegt. Im reinen
|
||||
# HTTP-Testaufbau scheitert damit die WebSocket-Verbindung, und der
|
||||
# Editor bleibt leer stehen, obwohl alles andere laedt (gemessen).
|
||||
#
|
||||
# Produktiv hinter deinem Reverse Proxy: OFFICE_SSL_TERMINATION=true.
|
||||
#
|
||||
# mount_jail_tree=false: Collabora sperrt jedes Dokument sonst in einen
|
||||
# eigenen Einhaengepunkt und braucht dafuer CAP_SYS_ADMIN. Die Rechte
|
||||
# dem Container zu geben waere der teurere Weg – ohne den Schalter
|
||||
# scheitert der Start eines Dokuments mit „Failed to exec coolmount".
|
||||
extra_params: >-
|
||||
--o:ssl.enable=${OFFICE_SSL:-false}
|
||||
--o:ssl.termination=${OFFICE_SSL_TERMINATION:-false}
|
||||
--o:mount_jail_tree=false
|
||||
dictionaries: de_DE en_US
|
||||
cap_add:
|
||||
- MKNOD
|
||||
extra_hosts:
|
||||
# Damit der Container den Wirt erreicht – dort haengt der Reverse Proxy
|
||||
# bzw. der veroeffentlichte Port von Aether.
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
- internal
|
||||
ports:
|
||||
- "127.0.0.1:${OFFICE_PORT:-9980}:9980"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
# "data" ist bewusst weg – die Dateien liegen jetzt unter DATA_PATH auf dem
|
||||
# Wirt (siehe oben). Die Datenbank bleibt ein benanntes Volume: An ihre
|
||||
# Dateien geht man nicht von Hand, und Postgres mag fremde Besitzer nicht.
|
||||
|
||||
networks:
|
||||
internal:
|
||||
driver: bridge
|
||||
Reference in new issue
Block a user