feat(ui): add desktop footer, legal custom link and about dialog
This commit is contained in:
1 parent
bc7bdc481e
commit
c181ffea54
12 files changed
+173
-7
No files matched your search
@@ -86,6 +86,9 @@ LEGAL_PRIVACY_CONTENT=
|
|||||||
# Pfad im Container, z. B. /legal/impressum.md
|
# Pfad im Container, z. B. /legal/impressum.md
|
||||||
LEGAL_IMPRINT_CONTENT_FILE=
|
LEGAL_IMPRINT_CONTENT_FILE=
|
||||||
LEGAL_PRIVACY_CONTENT_FILE=
|
LEGAL_PRIVACY_CONTENT_FILE=
|
||||||
|
# Eigener Link im Footer (nur https://)
|
||||||
|
LEGAL_CUSTOM_LINK_LABEL=
|
||||||
|
LEGAL_CUSTOM_LINK_URL=
|
||||||
|
|
||||||
# ─── Bilder und Audio in Notizen ───────────────────────────────────────────────────────────────────
|
# ─── Bilder und Audio in Notizen ───────────────────────────────────────────────────────────────────
|
||||||
# Dateien liegen in data/media/ und sind nur für Personen sichtbar, die die Notiz lesen dürfen. Nicht mehr
|
# Dateien liegen in data/media/ und sind nur für Personen sichtbar, die die Notiz lesen dürfen. Nicht mehr
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Globaler Desktop-App-Footer mit Versionsnummer, „Über MindSift“, dynamischen Rechtstexten (Impressum, Datenschutz) sowie optional konfigurierbarem Custom-Link (`LEGAL_CUSTOM_LINK_LABEL`, `LEGAL_CUSTOM_LINK_URL`).
|
||||||
|
- „Über MindSift“-Dialog und Versionsanzeige im mobilen Profil-Menü (Avatar-Menü oben rechts).
|
||||||
|
|
||||||
## [0.10.0] - 2026-10-30
|
## [0.10.0] - 2026-10-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
|||||||
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
|
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
|
||||||
| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster |
|
| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster |
|
||||||
| `LEGAL_IMPRINT_CONTENT_FILE`, `LEGAL_PRIVACY_CONTENT_FILE` | leer | Pfad zu einer Datei im Container mit Markdown-Inhalt für Impressum bzw. Datenschutz. Ohne URL und Text bleibt der Link unsichtbar. |
|
| `LEGAL_IMPRINT_CONTENT_FILE`, `LEGAL_PRIVACY_CONTENT_FILE` | leer | Pfad zu einer Datei im Container mit Markdown-Inhalt für Impressum bzw. Datenschutz. Ohne URL und Text bleibt der Link unsichtbar. |
|
||||||
|
| `LEGAL_CUSTOM_LINK_LABEL`, `LEGAL_CUSTOM_LINK_URL` | leer | Eigener Link im globalen Desktop-Footer (nur `https://`, sonst ignoriert) |
|
||||||
|
|
||||||
Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert.
|
Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert.
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ services:
|
|||||||
- LEGAL_PRIVACY_CONTENT=${LEGAL_PRIVACY_CONTENT:-}
|
- LEGAL_PRIVACY_CONTENT=${LEGAL_PRIVACY_CONTENT:-}
|
||||||
- LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-}
|
- LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-}
|
||||||
- LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_CONTENT_FILE:-}
|
- LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_CONTENT_FILE:-}
|
||||||
|
# Eigener Link im Footer (nur https://)
|
||||||
|
# - LEGAL_CUSTOM_LINK_LABEL=${LEGAL_CUSTOM_LINK_LABEL:-}
|
||||||
|
# - LEGAL_CUSTOM_LINK_URL=${LEGAL_CUSTOM_LINK_URL:-}
|
||||||
# Bilder und Audio in Notizen
|
# Bilder und Audio in Notizen
|
||||||
- MEDIA_MAX_IMAGE_MB=${MEDIA_MAX_IMAGE_MB:-15}
|
- MEDIA_MAX_IMAGE_MB=${MEDIA_MAX_IMAGE_MB:-15}
|
||||||
- MEDIA_MAX_AUDIO_MB=${MEDIA_MAX_AUDIO_MB:-50}
|
- MEDIA_MAX_AUDIO_MB=${MEDIA_MAX_AUDIO_MB:-50}
|
||||||
|
|||||||
+30
-2
@@ -65,6 +65,8 @@ import { useMobileActions } from './context/MobileActionContext';
|
|||||||
import { MobileHeaderMenu } from './components/MobileHeaderMenu';
|
import { MobileHeaderMenu } from './components/MobileHeaderMenu';
|
||||||
import { UserAvatar } from './components/UserAvatar';
|
import { UserAvatar } from './components/UserAvatar';
|
||||||
import { CommandPalette } from './components/CommandPalette';
|
import { CommandPalette } from './components/CommandPalette';
|
||||||
|
import { AppFooter } from './components/AppFooter';
|
||||||
|
import { AboutModal } from './components/AboutModal';
|
||||||
import { APP_VERSION } from './version';
|
import { APP_VERSION } from './version';
|
||||||
|
|
||||||
type ActiveTab =
|
type ActiveTab =
|
||||||
@@ -170,6 +172,7 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
// Profile Dropdown State
|
// Profile Dropdown State
|
||||||
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
||||||
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// Mobile Navigation & Quick-Action State
|
// Mobile Navigation & Quick-Action State
|
||||||
@@ -1119,8 +1122,8 @@ export const App: React.FC = () => {
|
|||||||
{/* Main Container */}
|
{/* Main Container */}
|
||||||
{/* The workspace fills the window exactly and scrolls only inside the note, never the page */}
|
{/* The workspace fills the window exactly and scrolls only inside the note, never the page */}
|
||||||
<main
|
<main
|
||||||
className={`flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 sm:pb-6 ${
|
className={`flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 ${
|
||||||
activeTab === 'workspace' ? 'min-h-0 flex flex-col' : ''
|
activeTab === 'workspace' ? 'min-h-0 flex flex-col sm:pb-0' : 'sm:pb-6'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
|
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
|
||||||
@@ -1202,6 +1205,11 @@ export const App: React.FC = () => {
|
|||||||
</ViewErrorBoundary>
|
</ViewErrorBoundary>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<AppFooter
|
||||||
|
legal={authStatus.legal}
|
||||||
|
onOpenAbout={() => setShowAbout(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* MOBILE BOTTOM NAVIGATION BAR (5 equal columns -> + FAB is dead-center) */}
|
{/* MOBILE BOTTOM NAVIGATION BAR (5 equal columns -> + FAB is dead-center) */}
|
||||||
<nav className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-panel/95 backdrop-blur-md border-t border-border px-2 pt-1.5 pb-[calc(0.5rem+env(safe-area-inset-bottom))] grid grid-cols-5 items-center shadow-panel">
|
<nav className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-panel/95 backdrop-blur-md border-t border-border px-2 pt-1.5 pb-[calc(0.5rem+env(safe-area-inset-bottom))] grid grid-cols-5 items-center shadow-panel">
|
||||||
{/* Col 1: Triage */}
|
{/* Col 1: Triage */}
|
||||||
@@ -1594,6 +1602,19 @@ export const App: React.FC = () => {
|
|||||||
<span>Hilfe & Anleitung</span>
|
<span>Hilfe & Anleitung</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
triggerHaptic(10);
|
||||||
|
setShowMobileProfile(false);
|
||||||
|
setShowAbout(true);
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 hover:bg-panel-elevated transition-colors text-xs cursor-pointer text-main"
|
||||||
|
>
|
||||||
|
<Brain className="w-4 h-4 text-accent" />
|
||||||
|
<span>Über MindSift</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
{isServerAdmin && (
|
{isServerAdmin && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1626,6 +1647,11 @@ export const App: React.FC = () => {
|
|||||||
<span>Abmelden</span>
|
<span>Abmelden</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Version & About Hint on Mobile */}
|
||||||
|
<div className="pt-2 text-center text-[10px] text-faint font-mono">
|
||||||
|
MindSift {APP_VERSION}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1725,6 +1751,8 @@ export const App: React.FC = () => {
|
|||||||
onOpenTask={handleOpenTask}
|
onOpenTask={handleOpenTask}
|
||||||
onOpenList={handleOpenList}
|
onOpenList={handleOpenList}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { AuthStatus } from '../lib/types';
|
||||||
|
import { APP_VERSION } from '../version';
|
||||||
|
import { LegalLinks } from './LegalLinks';
|
||||||
|
|
||||||
|
interface AppFooterProps {
|
||||||
|
legal?: AuthStatus['legal'];
|
||||||
|
onOpenAbout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppFooter: React.FC<AppFooterProps> = ({ legal, onOpenAbout }) => {
|
||||||
|
return (
|
||||||
|
<footer className="hidden sm:flex shrink-0 w-full max-w-6xl mx-auto px-4 py-2 items-center justify-between text-[11px] text-faint border-t border-border/40 select-none">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenAbout}
|
||||||
|
className="font-semibold text-muted hover:text-main transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
MindSift
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenAbout}
|
||||||
|
className="font-mono hover:text-main transition-colors cursor-pointer"
|
||||||
|
title="Über MindSift & Versionsdetails"
|
||||||
|
>
|
||||||
|
{APP_VERSION}
|
||||||
|
</button>
|
||||||
|
<span className="text-border">·</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenAbout}
|
||||||
|
className="hover:text-main transition-colors cursor-pointer underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
Über MindSift
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<LegalLinks legal={legal} className="!text-faint gap-3" />
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -28,7 +28,9 @@ export const LegalLinks: React.FC<LegalLinksProps> = ({ legal, className = '', c
|
|||||||
}))
|
}))
|
||||||
.filter((e) => e.url || e.hasContent);
|
.filter((e) => e.url || e.hasContent);
|
||||||
|
|
||||||
if (entries.length === 0 && !children) return null;
|
const customHref = legal?.customLinkUrl && safeHref(legal.customLinkUrl);
|
||||||
|
|
||||||
|
if (entries.length === 0 && !children && !customHref) return null;
|
||||||
|
|
||||||
const openText = async (type: LegalType) => {
|
const openText = async (type: LegalType) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -52,6 +54,16 @@ export const LegalLinks: React.FC<LegalLinksProps> = ({ legal, className = '', c
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
{customHref && (
|
||||||
|
<a
|
||||||
|
href={customHref as string}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:text-main underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
{legal?.customLinkLabel || legal?.customLinkUrl}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
{children}
|
{children}
|
||||||
{error && <span className="text-status-err">{error}</span>}
|
{error && <span className="text-status-err">{error}</span>}
|
||||||
{open && (
|
{open && (
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface AuthStatus {
|
|||||||
hasImprintContent?: boolean;
|
hasImprintContent?: boolean;
|
||||||
privacyUrl?: string;
|
privacyUrl?: string;
|
||||||
hasPrivacyContent?: boolean;
|
hasPrivacyContent?: boolean;
|
||||||
|
customLinkLabel?: string;
|
||||||
|
customLinkUrl?: string;
|
||||||
};
|
};
|
||||||
// Voice input: own Whisper server, the browser's recognition, or switched off (STT_* environment)
|
// Voice input: own Whisper server, the browser's recognition, or switched off (STT_* environment)
|
||||||
voice?: { mode: VoiceMode; maxSeconds: number };
|
voice?: { mode: VoiceMode; maxSeconds: number };
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
"hasImprintContent": s.cfg.Legal.ImprintContent != "",
|
"hasImprintContent": s.cfg.Legal.ImprintContent != "",
|
||||||
"privacyUrl": s.cfg.Legal.PrivacyURL,
|
"privacyUrl": s.cfg.Legal.PrivacyURL,
|
||||||
"hasPrivacyContent": s.cfg.Legal.PrivacyContent != "",
|
"hasPrivacyContent": s.cfg.Legal.PrivacyContent != "",
|
||||||
|
"customLinkLabel": s.cfg.Legal.CustomLinkLabel,
|
||||||
|
"customLinkUrl": s.cfg.Legal.CustomLinkURL,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
// Countdown to the next demo reset; seconds as well, so client clocks do not matter
|
// Countdown to the next demo reset; seconds as well, so client clocks do not matter
|
||||||
|
|||||||
@@ -1388,7 +1388,12 @@ func TestLegalTextsArePublic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer store.Close()
|
defer store.Close()
|
||||||
cfg := &config.Config{Port: "8080", DatabasePath: dbPath, JWTSecret: "test-jwt-secret-key",
|
cfg := &config.Config{Port: "8080", DatabasePath: dbPath, JWTSecret: "test-jwt-secret-key",
|
||||||
Legal: config.LegalTexts{ImprintContent: "# Impressum\nMax Muster", PrivacyURL: "https://example.org/datenschutz"}}
|
Legal: config.LegalTexts{
|
||||||
|
ImprintContent: "# Impressum\nMax Muster",
|
||||||
|
PrivacyURL: "https://example.org/datenschutz",
|
||||||
|
CustomLinkLabel: "Support",
|
||||||
|
CustomLinkURL: "https://support.example.org",
|
||||||
|
}}
|
||||||
router := NewServer(cfg, store, ws.NewHub(), nil).Router()
|
router := NewServer(cfg, store, ws.NewHub(), nil).Router()
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@@ -1399,10 +1404,12 @@ func TestLegalTextsArePublic(t *testing.T) {
|
|||||||
HasImprintContent bool `json:"hasImprintContent"`
|
HasImprintContent bool `json:"hasImprintContent"`
|
||||||
PrivacyURL string `json:"privacyUrl"`
|
PrivacyURL string `json:"privacyUrl"`
|
||||||
HasPrivacyContent bool `json:"hasPrivacyContent"`
|
HasPrivacyContent bool `json:"hasPrivacyContent"`
|
||||||
|
CustomLinkLabel string `json:"customLinkLabel"`
|
||||||
|
CustomLinkURL string `json:"customLinkUrl"`
|
||||||
} `json:"legal"`
|
} `json:"legal"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(rec.Body.Bytes(), &st)
|
_ = json.Unmarshal(rec.Body.Bytes(), &st)
|
||||||
if !st.Legal.HasImprintContent || st.Legal.HasPrivacyContent || st.Legal.PrivacyURL != "https://example.org/datenschutz" {
|
if !st.Legal.HasImprintContent || st.Legal.HasPrivacyContent || st.Legal.PrivacyURL != "https://example.org/datenschutz" || st.Legal.CustomLinkLabel != "Support" || st.Legal.CustomLinkURL != "https://support.example.org" {
|
||||||
t.Fatalf("unexpected legal status: %+v", st.Legal)
|
t.Fatalf("unexpected legal status: %+v", st.Legal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ func (s SpeechConfig) Mode(demo bool) string {
|
|||||||
type LegalTexts struct {
|
type LegalTexts struct {
|
||||||
ImprintURL, ImprintContent string
|
ImprintURL, ImprintContent string
|
||||||
PrivacyURL, PrivacyContent string
|
PrivacyURL, PrivacyContent string
|
||||||
|
CustomLinkLabel, CustomLinkURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publicly known JWT secrets (old code default and the placeholders from .env.example, compose and
|
// Publicly known JWT secrets (old code default and the placeholders from .env.example, compose and
|
||||||
@@ -171,6 +172,20 @@ func legalURL(name string) string {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// legalCustomLink accepts only https links for the custom footer link.
|
||||||
|
func legalCustomLink(labelVar, urlVar string) (string, string) {
|
||||||
|
u := strings.TrimSpace(os.Getenv(urlVar))
|
||||||
|
if u == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(u, "https://") {
|
||||||
|
slog.Warn("Custom-Link-URL ignoriert: nur https erlaubt", "variable", urlVar, "url", u)
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
label := strings.TrimSpace(os.Getenv(labelVar))
|
||||||
|
return label, u
|
||||||
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
port := getEnv("PORT", "8080")
|
port := getEnv("PORT", "8080")
|
||||||
dataDir := getEnv("DATA_DIR", "./data")
|
dataDir := getEnv("DATA_DIR", "./data")
|
||||||
@@ -247,12 +262,17 @@ func Load() *Config {
|
|||||||
QuotaMB: envInt("MEDIA_QUOTA_MB", 0, 0, 10000000),
|
QuotaMB: envInt("MEDIA_QUOTA_MB", 0, 0, 10000000),
|
||||||
DemoMaxMB: envInt("MEDIA_DEMO_MAX_MB", 3, 1, 100),
|
DemoMaxMB: envInt("MEDIA_DEMO_MAX_MB", 3, 1, 100),
|
||||||
},
|
},
|
||||||
Legal: LegalTexts{
|
Legal: func() LegalTexts {
|
||||||
|
customLabel, customURL := legalCustomLink("LEGAL_CUSTOM_LINK_LABEL", "LEGAL_CUSTOM_LINK_URL")
|
||||||
|
return LegalTexts{
|
||||||
ImprintURL: legalURL("LEGAL_IMPRINT_URL"),
|
ImprintURL: legalURL("LEGAL_IMPRINT_URL"),
|
||||||
ImprintContent: legalContent("LEGAL_IMPRINT_CONTENT"),
|
ImprintContent: legalContent("LEGAL_IMPRINT_CONTENT"),
|
||||||
PrivacyURL: legalURL("LEGAL_PRIVACY_URL"),
|
PrivacyURL: legalURL("LEGAL_PRIVACY_URL"),
|
||||||
PrivacyContent: legalContent("LEGAL_PRIVACY_CONTENT"),
|
PrivacyContent: legalContent("LEGAL_PRIVACY_CONTENT"),
|
||||||
},
|
CustomLinkLabel: customLabel,
|
||||||
|
CustomLinkURL: customURL,
|
||||||
|
}
|
||||||
|
}(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,3 +81,42 @@ func TestJWTSecretNeverUsesAKnownDefault(t *testing.T) {
|
|||||||
t.Fatalf("a real JWT_SECRET must win, got %q", got)
|
t.Fatalf("a real JWT_SECRET must win, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCustomLinkValidation(t *testing.T) {
|
||||||
|
t.Setenv("DATA_DIR", t.TempDir())
|
||||||
|
|
||||||
|
// 1. Valid https URL and label
|
||||||
|
t.Setenv("LEGAL_CUSTOM_LINK_LABEL", "Unternehmensseite")
|
||||||
|
t.Setenv("LEGAL_CUSTOM_LINK_URL", "https://example.com/company")
|
||||||
|
cfg := Load()
|
||||||
|
if cfg.Legal.CustomLinkLabel != "Unternehmensseite" {
|
||||||
|
t.Errorf("expected label 'Unternehmensseite', got %q", cfg.Legal.CustomLinkLabel)
|
||||||
|
}
|
||||||
|
if cfg.Legal.CustomLinkURL != "https://example.com/company" {
|
||||||
|
t.Errorf("expected url 'https://example.com/company', got %q", cfg.Legal.CustomLinkURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Insecure http URL -> ignored
|
||||||
|
t.Setenv("LEGAL_CUSTOM_LINK_LABEL", "Insecure Site")
|
||||||
|
t.Setenv("LEGAL_CUSTOM_LINK_URL", "http://insecure.example.com")
|
||||||
|
cfg = Load()
|
||||||
|
if cfg.Legal.CustomLinkURL != "" || cfg.Legal.CustomLinkLabel != "" {
|
||||||
|
t.Errorf("expected empty custom link for http, got url=%q label=%q", cfg.Legal.CustomLinkURL, cfg.Legal.CustomLinkLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Invalid scheme (javascript:) -> ignored
|
||||||
|
t.Setenv("LEGAL_CUSTOM_LINK_LABEL", "Exploit")
|
||||||
|
t.Setenv("LEGAL_CUSTOM_LINK_URL", "javascript:alert(1)")
|
||||||
|
cfg = Load()
|
||||||
|
if cfg.Legal.CustomLinkURL != "" || cfg.Legal.CustomLinkLabel != "" {
|
||||||
|
t.Errorf("expected empty custom link for javascript:, got url=%q label=%q", cfg.Legal.CustomLinkURL, cfg.Legal.CustomLinkLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Empty URL -> empty
|
||||||
|
t.Setenv("LEGAL_CUSTOM_LINK_LABEL", "Only Label")
|
||||||
|
t.Setenv("LEGAL_CUSTOM_LINK_URL", "")
|
||||||
|
cfg = Load()
|
||||||
|
if cfg.Legal.CustomLinkURL != "" || cfg.Legal.CustomLinkLabel != "" {
|
||||||
|
t.Errorf("expected empty custom link when url is empty, got url=%q label=%q", cfg.Legal.CustomLinkURL, cfg.Legal.CustomLinkLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user