diff --git a/.env.example b/.env.example index 029e508..7ad4c2a 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,9 @@ SMTP_FROM=noreply@mindsift.local SMTP_TLS=true # Only for internal mail servers with self-signed certificates SMTP_SKIP_VERIFY=false + +# Optional legal notices: external link and/or Markdown (inline or as file path) +LEGAL_IMPRINT_URL= +LEGAL_PRIVACY_URL= +LEGAL_IMPRINT_CONTENT_FILE= +LEGAL_PRIVACY_CONTENT_FILE= diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 835a51d..cb29a6f 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -7,7 +7,8 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | Methode | Pfad | Zweck | |---|---|---| | GET | `/health` | Lebenszeichen | -| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode` | +| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`) | +| GET | `/legal/{imprint\|privacy}` | Hinterlegter Markdown-Text (`content`), ohne Anmeldung; `404`, wenn keiner gesetzt ist | | POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt | | POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben | | POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` | diff --git a/Doku/architecture.md b/Doku/architecture.md index 01ddc03..77464ac 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -65,6 +65,8 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/ | `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen | | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer / `587` / `true` | Mailversand, auch im Admin-UI einstellbar | | `SMTP_SKIP_VERIFY` | `false` | Zertifikat des Mailservers nicht prüfen (nur interne Server mit selbstsigniertem Zertifikat) | +| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) | +| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (oder `…_CONTENT_FILE` mit Dateipfad) | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. | Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert. diff --git a/README.md b/README.md index 7d85945..852c948 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,8 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d | `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging | | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) | | `SMTP_SKIP_VERIFY` | `false` | Zertifikatsprüfung für interne Mailserver abschalten | +| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) | +| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (oder `…_CONTENT_FILE` mit Dateipfad) | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. | | `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin` (mit Beispieldaten) und `user/user` an; eine Notiz und zwei Listen sind zwischen beiden geteilt, um das gemeinsame Bearbeiten auszuprobieren. **Nie auf öffentlichen Servern!** | * **Reverse-Proxy:** Am besten WebSocket-Upgrade und ungepufferte SSE-Streams durchreichen. Blockiert ein Proxy beides, funktioniert MindSift trotzdem über Long-Polling bzw. regelmäßige Abfragen, nur etwas träger. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md). diff --git a/compose.yaml b/compose.yaml index 8c45312..ff56f61 100644 --- a/compose.yaml +++ b/compose.yaml @@ -27,3 +27,8 @@ services: - SMTP_FROM=${SMTP_FROM:-} - SMTP_TLS=${SMTP_TLS:-true} - SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false} + # Optional: Impressum / Datenschutz (URL oder Markdown-Datei, z. B. per Volume eingebunden) + - LEGAL_IMPRINT_URL=${LEGAL_IMPRINT_URL:-} + - LEGAL_PRIVACY_URL=${LEGAL_PRIVACY_URL:-} + - LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-} + - LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_CONTENT_FILE:-} diff --git a/docker-compose.yml b/docker-compose.yml index 8c45312..ff56f61 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,3 +27,8 @@ services: - SMTP_FROM=${SMTP_FROM:-} - SMTP_TLS=${SMTP_TLS:-true} - SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false} + # Optional: Impressum / Datenschutz (URL oder Markdown-Datei, z. B. per Volume eingebunden) + - LEGAL_IMPRINT_URL=${LEGAL_IMPRINT_URL:-} + - LEGAL_PRIVACY_URL=${LEGAL_PRIVACY_URL:-} + - LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-} + - LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_CONTENT_FILE:-} diff --git a/frontend/src/components/AboutModal.tsx b/frontend/src/components/AboutModal.tsx new file mode 100644 index 0000000..f1b24d5 --- /dev/null +++ b/frontend/src/components/AboutModal.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Brain, ExternalLink } from 'lucide-react'; +import { APP_VERSION } from '../version'; +import { ReadingModal } from './ReadingModal'; + +// Project facts shown in "Über MindSift"; change here when the repository or license changes +export const PROJECT_REPO_URL = 'https://git.dsit.me/sandrodach/MindSift'; +export const PROJECT_LICENSE = 'Noch nicht festgelegt (keine LICENSE-Datei im Repository)'; + +export const AboutModal: React.FC<{ onClose: () => void }> = ({ onClose }) => ( + +
+
+
+ +
+
+

MindSift

+

Der Kopf frei in drei Sekunden.

+
+
+ +

+ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment. MindSift trennt deshalb{' '} + Erfassen und Organisieren: Gedanken + landen sofort im Quick Dump und werden später mit einem Tastendruck zu Aufgabe, Notiz oder Listeneintrag. Alles läuft + selbst gehostet in einem Container mit einer SQLite-Datei – deine Daten bleiben bei dir. +

+ +
+
Version
+
{APP_VERSION}
+
Lizenz
+
{PROJECT_LICENSE}
+
Quellcode
+
+ + {PROJECT_REPO_URL.replace(/^https:\/\//, '')} + + +
+
+ +

+ Impressum und Datenschutz dieser Instanz verantwortet ihr Betreiber; die Links stehen in den Kontoeinstellungen und auf der Anmeldeseite. +

+
+
+); diff --git a/frontend/src/components/LegalLinks.tsx b/frontend/src/components/LegalLinks.tsx new file mode 100644 index 0000000..f464fac --- /dev/null +++ b/frontend/src/components/LegalLinks.tsx @@ -0,0 +1,63 @@ +import React, { useState } from 'react'; +import { AuthStatus } from '../lib/types'; +import { api } from '../lib/api'; +import { MarkdownViewer } from '../lib/markdown'; +import { ReadingModal } from './ReadingModal'; + +type LegalType = 'imprint' | 'privacy'; +const TITLES: Record = { imprint: 'Impressum', privacy: 'Datenschutz' }; + +interface LegalLinksProps { + legal?: AuthStatus['legal']; + className?: string; + children?: React.ReactNode; // further footer links, e.g. "Über MindSift" +} + +// Impressum / Datenschutz links: an external URL opens in a new tab, Markdown in a reading modal. +// Links without URL or text are not shown at all. +export const LegalLinks: React.FC = ({ legal, className = '', children }) => { + const [open, setOpen] = useState<{ type: LegalType; content: string } | null>(null); + const [error, setError] = useState(null); + + const entries = (['imprint', 'privacy'] as LegalType[]) + .map((type) => ({ + type, + url: type === 'imprint' ? legal?.imprintUrl : legal?.privacyUrl, + hasContent: type === 'imprint' ? legal?.hasImprintContent : legal?.hasPrivacyContent, + })) + .filter((e) => e.url || e.hasContent); + + if (entries.length === 0 && !children) return null; + + const openText = async (type: LegalType) => { + setError(null); + try { + setOpen({ type, content: await api.getLegalText(type) }); + } catch (err: any) { + setError(err.message || 'Text konnte nicht geladen werden'); + } + }; + + return ( +
+ {entries.map((e) => + e.url ? ( + + {TITLES[e.type]} + + ) : ( + + ) + )} + {children} + {error && {error}} + {open && ( + setOpen(null)}> + + + )} +
+ ); +}; diff --git a/frontend/src/components/ReadingModal.tsx b/frontend/src/components/ReadingModal.tsx new file mode 100644 index 0000000..fd57830 --- /dev/null +++ b/frontend/src/components/ReadingModal.tsx @@ -0,0 +1,37 @@ +import React, { useEffect } from 'react'; +import { X } from 'lucide-react'; + +interface ReadingModalProps { + title: string; + onClose: () => void; + children: React.ReactNode; +} + +// Slim modal for reading text (legal notices, about); Escape or backdrop click closes it +export const ReadingModal: React.FC = ({ title, onClose, children }) => { + useEffect(() => { + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + return ( +
e.target === e.currentTarget && onClose()} + role="dialog" + aria-modal="true" + aria-label={title} + > +
+
+

{title}

+ +
+
{children}
+
+
+ ); +}; diff --git a/frontend/src/features/auth/SetupAndAuthView.tsx b/frontend/src/features/auth/SetupAndAuthView.tsx index 07f28b8..ed54997 100644 --- a/frontend/src/features/auth/SetupAndAuthView.tsx +++ b/frontend/src/features/auth/SetupAndAuthView.tsx @@ -14,6 +14,7 @@ import { import { AuthStatus, SharingMode, User } from '../../lib/types'; import { api, triggerHaptic } from '../../lib/api'; import { ToggleSwitch } from '../../components/ToggleSwitch'; +import { LegalLinks } from '../../components/LegalLinks'; interface SetupAndAuthViewProps { authStatus: AuthStatus; @@ -330,6 +331,7 @@ export const SetupAndAuthView: React.FC = ({ + ); } @@ -574,6 +576,7 @@ export const SetupAndAuthView: React.FC = ({ )} + ); }; diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index 31d92f4..4e14b14 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -28,6 +28,7 @@ import { Flame, Check, } from 'lucide-react'; +import { AboutModal } from '../../components/AboutModal'; interface HelpViewProps { onBack?: () => void; @@ -55,6 +56,7 @@ interface DocCard { export const HelpView: React.FC = ({ onBack }) => { const [activeCategory, setActiveCategory] = useState('all'); const [searchQuery, setSearchQuery] = useState(''); + const [showAbout, setShowAbout] = useState(false); // Documentation sections & cards data const cards: DocCard[] = useMemo( @@ -876,6 +878,13 @@ export const HelpView: React.FC = ({ onBack }) => { )} + +
+ +
+ {showAbout && setShowAbout(false)} />} ); }; diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index 745e7b4..daf5e0a 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -20,11 +20,13 @@ import { Pencil, Copy, } from 'lucide-react'; -import { APIKey, User, UserTheme } from '../../lib/types'; +import { APIKey, AuthStatus, User, UserTheme } from '../../lib/types'; import { api, triggerHaptic } from '../../lib/api'; import { customThemeId, ThemeId, ThemeOption } from '../../lib/theme'; import themeTemplateCss from '../../themes/theme-template.css?raw'; import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar'; +import { LegalLinks } from '../../components/LegalLinks'; +import { AboutModal } from '../../components/AboutModal'; interface UserSettingsViewProps { currentUser: User; @@ -50,6 +52,11 @@ export const UserSettingsView: React.FC = ({ onBack, }) => { const [section, setSection] = useState('profile'); + const [legal, setLegal] = useState(); + const [showAbout, setShowAbout] = useState(false); + useEffect(() => { + api.getAuthStatus().then((st) => setLegal(st.legal)).catch(() => {}); + }, []); // --- Custom Theme Editor State --- const [themeEditId, setThemeEditId] = useState(null); // null = new theme @@ -1227,6 +1234,14 @@ export const UserSettingsView: React.FC = ({ )} + + {/* Footer: legal notices of this instance and project info */} + + + + {showAbout && setShowAbout(false)} />} ); }; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 36e0b0d..a51c502 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -141,6 +141,12 @@ export const api = { return st; }, + // Public, works before login + async getLegalText(type: 'imprint' | 'privacy'): Promise { + const res = await request<{ content: string }>(`/api/v1/legal/${type}`); + return res.content || ''; + }, + async logout(): Promise { try { await request<{ success: boolean }>('/api/v1/auth/logout', { method: 'POST' }); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index bc54175..9488eff 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -8,6 +8,13 @@ export interface AuthStatus { instanceName: string; demoMode?: boolean; sharingMode?: SharingMode; + // Impressum / Datenschutz of this instance (LEGAL_* environment variables) + legal?: { + imprintUrl?: string; + hasImprintContent?: boolean; + privacyUrl?: string; + hasPrivacyContent?: boolean; + }; } // Who can see whom: nobody, members of the same organization, or everybody (Doku/sharing-and-permissions.md) diff --git a/internal/api/server.go b/internal/api/server.go index 146d355..620ab93 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -83,6 +83,7 @@ func (s *Server) Router() http.Handler { // Public Health, Setup & Auth Routes r.Get("/api/v1/health", s.handleHealth) r.Get("/api/v1/auth/status", s.handleAuthStatus) + r.Get("/api/v1/legal/{type}", s.handleLegalText) r.Post("/api/v1/auth/setup", s.handleFirstBootSetup) r.Post("/api/v1/auth/register", s.handleRegister) r.Post("/api/v1/auth/login", s.handleLogin) @@ -458,9 +459,32 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) { "instanceName": instanceName, "demoMode": s.cfg.DemoMode, "sharingMode": s.store.SharingMode(), + // Public so the login screen can link Impressum and Datenschutz + "legal": map[string]interface{}{ + "imprintUrl": s.cfg.Legal.ImprintURL, + "hasImprintContent": s.cfg.Legal.ImprintContent != "", + "privacyUrl": s.cfg.Legal.PrivacyURL, + "hasPrivacyContent": s.cfg.Legal.PrivacyContent != "", + }, }) } +// handleLegalText serves the configured Markdown of the imprint or privacy notice (public, no login). +func (s *Server) handleLegalText(w http.ResponseWriter, r *http.Request) { + content := "" + switch chi.URLParam(r, "type") { + case "imprint": + content = s.cfg.Legal.ImprintContent + case "privacy": + content = s.cfg.Legal.PrivacyContent + } + if content == "" { + writeError(w, http.StatusNotFound, "Kein Text hinterlegt") + return + } + writeJSON(w, http.StatusOK, map[string]string{"content": content}) +} + func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) { count, err := s.store.CountUsers() if err != nil || count > 0 { diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 7242ac6..9c4df1b 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -1366,3 +1366,42 @@ func TestFirstBootSetupWithSampleData(t *testing.T) { t.Fatalf("expected sample notes and task lists, got %d notes, %d task lists", len(docs), len(lists)) } } + +// Impressum and Datenschutz must be reachable without login; unset texts stay hidden. +func TestLegalTextsArePublic(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "legal.db") + store, err := storage.NewStore(dbPath) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + defer store.Close() + cfg := &config.Config{Port: "8080", DatabasePath: dbPath, JWTSecret: "test-jwt-secret-key", + Legal: config.LegalTexts{ImprintContent: "# Impressum\nMax Muster", PrivacyURL: "https://example.org/datenschutz"}} + router := NewServer(cfg, store, ws.NewHub(), nil).Router() + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/auth/status", nil)) + var st struct { + Legal struct { + ImprintURL string `json:"imprintUrl"` + HasImprintContent bool `json:"hasImprintContent"` + PrivacyURL string `json:"privacyUrl"` + HasPrivacyContent bool `json:"hasPrivacyContent"` + } `json:"legal"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &st) + if !st.Legal.HasImprintContent || st.Legal.HasPrivacyContent || st.Legal.PrivacyURL != "https://example.org/datenschutz" { + t.Fatalf("unexpected legal status: %+v", st.Legal) + } + + rec = httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/legal/imprint", nil)) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Max Muster") { + t.Fatalf("imprint without login: %d %s", rec.Code, rec.Body.String()) + } + rec = httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/legal/privacy", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("privacy without text should be 404, got %d", rec.Code) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 915ce29..ebfedc5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,6 +27,43 @@ type Config struct { SMTPTLS bool // SMTPSkipVerify accepts self-signed or mismatching certificates (internal mail servers) SMTPSkipVerify bool + + // Legal notices (Impressum, Datenschutz): an external URL and/or Markdown text, shown even before login + Legal LegalTexts +} + +type LegalTexts struct { + ImprintURL, ImprintContent string + PrivacyURL, PrivacyContent string +} + +// legalContent reads Markdown from NAME or, for longer texts, from the file named in NAME_FILE. +func legalContent(name string) string { + if v := strings.TrimSpace(os.Getenv(name)); v != "" { + return v + } + if path := strings.TrimSpace(os.Getenv(name + "_FILE")); path != "" { + data, err := os.ReadFile(path) + if err != nil { + slog.Warn("Rechtstext konnte nicht gelesen werden", "variable", name+"_FILE", "error", err) + return "" + } + return strings.TrimSpace(string(data)) + } + return "" +} + +// legalURL accepts only http(s) links, so a misconfiguration cannot inject javascript: URLs. +func legalURL(name string) string { + v := strings.TrimSpace(os.Getenv(name)) + if v == "" { + return "" + } + if !strings.HasPrefix(v, "https://") && !strings.HasPrefix(v, "http://") { + slog.Warn("Rechtstext-URL ignoriert: nur http(s) erlaubt", "variable", name) + return "" + } + return v } func Load() *Config { @@ -81,6 +118,12 @@ func Load() *Config { SMTPFrom: smtpFrom, SMTPTLS: smtpTLS, SMTPSkipVerify: smtpSkipVerify, + Legal: LegalTexts{ + ImprintURL: legalURL("LEGAL_IMPRINT_URL"), + ImprintContent: legalContent("LEGAL_IMPRINT_CONTENT"), + PrivacyURL: legalURL("LEGAL_PRIVACY_URL"), + PrivacyContent: legalContent("LEGAL_PRIVACY_CONTENT"), + }, } }