feat(legal): imprint & privacy notices, "Über MindSift"
- LEGAL_IMPRINT_/LEGAL_PRIVACY_ URL and Markdown (inline or *_FILE);
only http(s) URLs accepted
- /auth/status exposes legal info; public GET /legal/{imprint|privacy}
- Links on login/setup screen and in the settings footer: URL opens a new
tab, Markdown a reading modal; hidden when nothing is configured
- "Über MindSift" modal (version, license, repository) in settings and help
- Docs and compose/env examples
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a62b204e14
commit
d6c70d7a8e
17 files changed
+318
-2
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user