diff --git a/.env.example b/.env.example index 7ab6a39..1fdf050 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,10 @@ APP_PORT=8080 # davor gehört der Proxy). 0.0.0.0 = das ganze Netz – dann unverschlüsselt. APP_BIND=127.0.0.1 +# Wie viele frühere Fassungen einer Datei aufgehoben werden. Gilt nur für +# Dateien, an denen „Keep versions" eingeschaltet ist – Standard ist aus. +MAX_VERSIONS_KEPT=5 + # ── Erster Start ────────────────────────────────────────────────────────────── # Legt den Admin an. Danach ADMIN_PASSWORD leeren. # Auch hier: ein $ muss $$ heißen (siehe DB_PASSWORD). diff --git a/README.md b/README.md index 6523164..de97b26 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,26 @@ Datenbank nicht kennt; die Cloud sieht sie nicht, und der GC räumt sie irgendwann weg. Zum Hineinlegen ist WebDAV der Weg. Dazu `APP_UID`/`APP_GID` aus `id -u` und `id -g`, sonst gehören die Dateien einem Container-Nutzer. +### Versionen + +Eine Datei kann frühere Fassungen aufheben. **Standard ist aus**, eingeschaltet +wird es je Datei über Rechtsklick → „Keep versions". + +Warum nicht für alle: Eine 50-MB-Datei, die täglich per WebDAV synchronisiert +wird, läge sonst nach einer Woche sechsmal auf der Platte. Der Schalter macht +sichtbar, wo das gewollt ist. + +`MAX_VERSIONS_KEPT=5` bestimmt, wie viele Vorgänger bleiben – zusammen mit der +aktuellen Fassung also sechs Stände. Was darüber hinausgeht, wird beim +Speichern samt Blob gelöscht. Die Fassungen zählen auf dein Kontingent. + +Kleiner als 1 wird verworfen: Die Zahl geht als `OFFSET` in die Aufräumabfrage, +und `OFFSET 0` löschte jede Fassung – aus „keine Grenze" würde „kein Verlauf". +Wer keinen Verlauf will, lässt den Schalter an der Datei aus. + +Zu beachten: **Jedes Strg+S in Office legt eine Fassung an.** Beim Testen +sprang eine Datei dadurch von v1 auf v3, nicht auf v2. + ### Volltextsuche (OCR) Standardmäßig an. Fehlen `ocrmypdf`/`tesseract` im Abbild, schaltet sich der diff --git a/TESTING.md b/TESTING.md index 8f05788..d148528 100644 --- a/TESTING.md +++ b/TESTING.md @@ -706,6 +706,25 @@ ist die falsche – die Schnipsel haben deshalb **kein** Profil mehr. --- +## Anzahl aufgehobener Fassungen (24.08.2026) + +`MAX_VERSIONS_KEPT`, Vorgabe 5. Vorher eine Konstante – und zwar **zwei**: +eine im Backend, eine im Verlaufs-Dialog, verbunden nur durch den Kommentar +„muss zusammenpassen". Ein Satz ist keine Zusicherung. Der Dialog holt die +Zahl jetzt aus `/api/v1/public/config`. + +- [ ] `MAX_VERSIONS_KEPT=3` → `/public/config` meldet 3 🔎 +- [ ] Datei mit „Keep versions" siebenmal überschreiben → genau 3 Fassungen + im Verlauf, und zwar die neuesten 🔎 (gemessen: v6, v5, v4 bei + aktueller v7) +- [ ] Der Dialog nennt dieselbe Zahl wie der Server +- [ ] `MAX_VERSIONS_KEPT=0` wird verworfen, die Vorgabe greift – `OFFSET 0` + löschte sonst **jede** Fassung +- [ ] Unbrauchbarer Wert (`abc`) → Vorgabe, kein Startabbruch +- [ ] Ohne „Keep versions" an der Datei entsteht weiterhin kein Verlauf + +--- + ## Erreichbarkeit von außen (24.08.2026) `ERR_CONNECTION_REFUSED` beim Zugriff über die IP ist meist kein Fehler, diff --git a/backend/cmd/aether/main.go b/backend/cmd/aether/main.go index adeb591..95b6883 100644 --- a/backend/cmd/aether/main.go +++ b/backend/cmd/aether/main.go @@ -180,6 +180,10 @@ func main() { // fileHandler bekommt docStore mit: Der PDF-Betrachter holt sich darueber // den erkannten Text eines Scans, in Seiten zerlegt. + // Wie viele frühere Fassungen aufgehoben werden. Einmal hier gesetzt, + // danach nur gelesen – siehe files.SetMaxVersionsKept. + files.SetMaxVersionsKept(cfg.MaxVersionsKept) + fileHandler := files.NewHandler(fileStore, blobStore, auditor, aclStore, tagStore, docStore) shareStore := sharing.NewStore(pool) @@ -373,6 +377,10 @@ func main() { // Damit die Oberflaeche den Menuepunkt „Edit in Office" gar nicht // erst anbietet, wenn es keinen Office-Server gibt. "office_enabled": officeHandler.Enabled(), + // Damit der Verlaufs-Dialog dieselbe Zahl nennt, die der Server + // anwendet. Vorher stand sie zweimal im Quelltext, mit einem + // Kommentar „muss zusammenpassen" – das ist keine Zusicherung. + "max_versions_kept": cfg.MaxVersionsKept, }) }) diff --git a/backend/internal/files/handler.go b/backend/internal/files/handler.go index 7f3dced..68d41ba 100644 --- a/backend/internal/files/handler.go +++ b/backend/internal/files/handler.go @@ -33,7 +33,26 @@ const maxUploadSize = 5 << 30 // 5 GB // maxVersionsKept begrenzt den Verlauf einer Datei mit aktivierter // Versionierung. Ältere Fassungen werden samt Blob entfernt. -const maxVersionsKept = 5 +// +// Eine Paketvariable, kein Feld an Handler: Der Wert wird beim Start einmal +// aus MAX_VERSIONS_KEPT gesetzt und danach nur gelesen. Ihn stattdessen +// durchzureichen hieße, vier Signaturen zu ändern – auch die von +// ReplaceContent, die der Office-Weg benutzt –, für eine Zahl, die sich zur +// Laufzeit nie ändert. +var maxVersionsKept = 5 + +// SetMaxVersionsKept wird einmal beim Start aufgerufen, bevor der erste +// Request läuft. +// +// Kleiner als 1 wird verworfen: Die Zahl geht als OFFSET in das DELETE der +// Aufräumabfrage, und `OFFSET 0` löschte **jede** Fassung – aus „keine Grenze" +// würde „kein Verlauf". Wer keinen Verlauf will, lässt die Versionierung an +// der Datei aus; dafür gibt es den Schalter schon. +func SetMaxVersionsKept(n int) { + if n >= 1 { + maxVersionsKept = n + } +} type Handler struct { store *Store diff --git a/backend/pkg/config/config.go b/backend/pkg/config/config.go index 640906a..098ed36 100644 --- a/backend/pkg/config/config.go +++ b/backend/pkg/config/config.go @@ -18,11 +18,14 @@ type Config struct { // TrustedProxies is the list of IPs/CIDRs whose X-Forwarded-* headers are trusted. TrustedProxies []*net.IPNet - DBHost string - DBPort string - DBName string - DBUser string - DBPassword string + DBHost string + // MaxVersionsKept: Wie viele frühere Fassungen einer Datei aufgehoben + // werden, wenn die Versionierung für sie eingeschaltet ist. + MaxVersionsKept int + DBPort string + DBName string + DBUser string + DBPassword string SessionSecret string SessionMaxAge int @@ -151,11 +154,12 @@ func Load() (*Config, error) { ListenAddr: getEnv("APP_LISTEN_ADDR", ":8080"), PublicURL: getEnv("PUBLIC_URL", "http://localhost:8080"), - DBHost: getEnv("DB_HOST", "localhost"), - DBPort: getEnv("DB_PORT", "5432"), - DBName: getEnv("DB_NAME", "aether"), - DBUser: getEnv("DB_USER", "aether"), - DBPassword: getEnv("DB_PASSWORD", ""), + DBHost: getEnv("DB_HOST", "localhost"), + MaxVersionsKept: getEnvInt("MAX_VERSIONS_KEPT", 5), + DBPort: getEnv("DB_PORT", "5432"), + DBName: getEnv("DB_NAME", "aether"), + DBUser: getEnv("DB_USER", "aether"), + DBPassword: getEnv("DB_PASSWORD", ""), SessionSecret: getEnv("SESSION_SECRET", ""), StoragePath: getEnv("STORAGE_PATH", "./data"), @@ -290,6 +294,21 @@ func parseCIDRList(raw string) ([]*net.IPNet, error) { return networks, nil } +// getEnvInt liest eine Zahl. Ein unbrauchbarer Wert nimmt die Vorgabe, statt +// den Start zu verhindern: Eine Tippfehler in einer Nebensache soll nicht +// dazu führen, dass die Cloud gar nicht erst hochkommt. +func getEnvInt(key string, fallback int) int { + v := os.Getenv(key) + if v == "" { + return fallback + } + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil || n < 0 { + return fallback + } + return n +} + func getEnv(key, fallback string) string { if v := os.Getenv(key); v != "" { return v diff --git a/deploy/.env.example b/deploy/.env.example index 4f24a42..c29d219 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -33,6 +33,8 @@ DATA_PATH=/pfad/zu/aether-data # `id -u` / `id -g` – sonst gehören die Dateien einem Container-Nutzer. APP_UID=1000 APP_GID=1000 +# Frühere Fassungen je Datei. Greift nur bei eingeschaltetem „Keep versions". +MAX_VERSIONS_KEPT=5 # Port auf dem Wirt. Im Container bleibt es 8080. APP_PORT=8080 # Wer den Port erreichen darf. 127.0.0.1 = nur dieser Rechner (Vorgabe). diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 8da5a2c..c7e3b13 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -212,6 +212,8 @@ export interface PublicConfig { /** Gibt es einen Office-Server? Sonst bietet das Menue das Bearbeiten gar * nicht erst an – ein Punkt, der immer scheitert, ist schlimmer als keiner. */ office_enabled?: boolean + /** Wie viele frühere Fassungen der Server aufhebt (MAX_VERSIONS_KEPT). */ + max_versions_kept?: number primary_color?: string accent_color?: string external_apps: ExternalApp[] diff --git a/frontend/src/components/VersionsDialog.tsx b/frontend/src/components/VersionsDialog.tsx index 8be4a9c..8da3a10 100644 --- a/frontend/src/components/VersionsDialog.tsx +++ b/frontend/src/components/VersionsDialog.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react' import { versionsApi, type FileItem, type FileVersion } from '../api/client' import { formatBytes } from '../pages/FileBrowser' import { useOverlayDismiss } from '../hooks/useOverlayDismiss' +import { useAppSettings } from '../hooks/useAppSettings' interface Props { file: FileItem @@ -9,8 +10,10 @@ interface Props { onRestored: (updated: FileItem) => void } -// Muss zu maxVersionsKept im Backend passen (internal/files/handler.go). -const MAX_VERSIONS_KEPT = 5 +// Die Zahl kommt vom Server (MAX_VERSIONS_KEPT). Vorher stand sie hier fest +// mit dem Kommentar „muss zu maxVersionsKept im Backend passen" – ein Satz +// ist keine Zusicherung, und beim ersten Ändern nennt der Dialog sonst eine +// andere Zahl, als der Server anwendet. function formatDate(iso: string): string { return new Date(iso).toLocaleString(undefined, { @@ -20,6 +23,7 @@ function formatDate(iso: string): string { } export default function VersionsDialog({ file, onClose, onRestored }: Props) { + const { maxVersionsKept: MAX_VERSIONS_KEPT } = useAppSettings() const [versions, setVersions] = useState([]) const [loading, setLoading] = useState(true) const [restoring, setRestoring] = useState(null) diff --git a/frontend/src/hooks/useAppSettings.tsx b/frontend/src/hooks/useAppSettings.tsx index 046d776..45837d5 100644 --- a/frontend/src/hooks/useAppSettings.tsx +++ b/frontend/src/hooks/useAppSettings.tsx @@ -24,12 +24,16 @@ interface AppSettingsValue { globalColors: { primary?: string; accent?: string } /** Gibt es einen Office-Server? Steuert nur, ob der Menuepunkt erscheint. */ officeEnabled: boolean + /** Wie viele frühere Fassungen der Server aufhebt. Kommt vom Server, damit + * der Dialog nicht eine andere Zahl nennt als die, die angewendet wird. */ + maxVersionsKept: number refresh: () => Promise } const Ctx = createContext({ cloudName: 'Aether', officeEnabled: false, + maxVersionsKept: 5, logoUrl: '', faviconUrl: '', appIconUrl: '', @@ -84,6 +88,7 @@ export function AppSettingsProvider({ children }: { children: ReactNode }) { const [appIconUrl, setAppIconUrl] = useState('') const [globalColors, setGlobalColors] = useState<{ primary?: string; accent?: string }>({}) const [officeEnabled, setOfficeEnabled] = useState(false) + const [maxVersionsKept, setMaxVersionsKept] = useState(5) const refresh = useCallback(async () => { try { @@ -95,6 +100,9 @@ export function AppSettingsProvider({ children }: { children: ReactNode }) { applyFavicon(cfg.favicon_url ?? '', cfg.apple_touch_icon_url ?? '') setGlobalColors({ primary: cfg.primary_color, accent: cfg.accent_color }) setOfficeEnabled(!!cfg.office_enabled) + if (typeof cfg.max_versions_kept === 'number' && cfg.max_versions_kept > 0) { + setMaxVersionsKept(cfg.max_versions_kept) + } applyTheme(cfg.primary_color, cfg.accent_color) applyThemeColor(cfg.primary_color) document.title = cfg.cloud_name || 'Aether' @@ -106,7 +114,7 @@ export function AppSettingsProvider({ children }: { children: ReactNode }) { useEffect(() => { void refresh() }, [refresh]) return ( - {children} + {children} ) }