diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a101ff2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,62 @@ +# Changelog + +Alle bedeutenden Änderungen an diesem Projekt werden in diesem Dokument dokumentiert. + +Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +und das Versionsierung folgt dem [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [FE-4] — 2025-01-XX — Docker Deployment, HTTPS, Tests + +### Added +- **Dockerfile**: Verbessert mit HEALTHCHECK, Error-Handling (`|| exit 1`), multi-stage build +- **Caddyfile**: HTTPS mit Let's Encrypt (automatisch wenn CADDY_DOMAIN gesetzt), Security Headers (X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, Content-Security-Policy, Referrer-Policy, Permissions-Policy), gzip + zstd Compression +- **docker-compose.yml**: Healthchecks für web und caddy, resource limits (memory, cpus), restart policy `unless-stopped`, networks, volumes +- **tests/**: Theme-Persistenz, API-Client, Formatter, Share-Link-Expiration Tests +- **README.md**: Komplette Dokumentation mit Architektur-Diagramm, Installation, Nutzung, Environment Variables + +### Changed +- Share-Links: Token-basiert mit Ablaufzeit (24h, 48h, 7Tage, 14Tage, 30Tage, Nie) und Max-Views (1, 10, 50, Unbegrenzt) +- Share-Link-Seite ist PUBLIC (kein Auth erforderlich), Read-Only + +## [FE-3] — Full-Featured Research-Ansicht + +### Added +- **Error-System**: `ErrorBannerStore.ts` + `ErrorBanner.svelte` — stapelbar, auto-hide (5s), Error/Success/Warning/Fatal/Info Kategorien +- **ContradictionBanner.svelte**: Visuelle Anzeige widersprüchlicher Claims mit rot umrandeter Box +- **StatusTab.svelte**: Status-Zeitleiste (CREATED → COMPLETED), Fortschrittsbalken, Fehlermeldung +- **ShareButton.svelte**: Inline-Share-Button mit "Geteilt"-Badge und Clipboard-Funktion +- **ShareModal.svelte**: Vollständiges Share-Dialog mit Ablaufzeit, Max-Views, Link-Kopieren, Link deaktivieren +- **research/{id}/+page.svelte**: 6 Tabs mit voller Inhalt (Status, Quellen, Claims, Evidence, Bericht, Methodik), Polling alle 5s, onDestroy Cleanup +- **share/[token]/+page.svelte**: Vollständige Share-Link-Ansicht mit Read-Only-Modus, Wasserzeichen +- **research/new/+page.svelte**: Char-Counter (max 500), Language-Auswahl, Depth-Auswahl, Loading-State + +### Changed +- Research-Detail-Seite: Vollständige 6-Tab-Implementierung mit Polling +- Share-Modal: Ablaufzeit-Slider, Max-Views, visuelles Feedback + +## [FE-2] — API-Anbindung, Share-Links, Markdown + +### Added +- `research-api.ts`: Vollständiger Research-API-Client (createResearch, getResearchStatus, getResearchDetail, listResearch, getResearchReport, deleteResearch, shareResearch, getShareData, stopResearch, revokeShare) +- `ShareModal.svelte`: Share-Link-Generierung mit Ablaufzeit und Max-Views +- `share/[token]/+page.svelte`: Public Read-Only Share-Ansicht +- `ReportTab.svelte`: Markdown-Report mit Download als JSON/Markdown/Text +- `svelte-markdown` als Dependency für Markdown-Rendering + +## [FE-1] — Login, Sidebar, Research-Layout + +### Added +- Login-Seite mit API-Key-Auth +- Sidebar mit Research-Historie +- Research-Detail-Layout mit Tabs +- Theme-Toggle (Dark/Light) + +## [FE-0] — Projektgrundgerüst + +### Added +- SvelteKit + TailwindCSS + TypeScript Setup +- Dockerfile + docker-compose.yml für Deployment +- Basis-Komponenten und Layout +- API-Client Grundgerüst \ No newline at end of file diff --git a/Caddyfile b/Caddyfile index e77d31e..db74b82 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,12 +1,60 @@ +# HTTPS with automatic Let's Encrypt (only if CADDY_DOMAIN is set) +# Fallback: HTTP only if no domain + +{ + auto_https off +} + :443 { reverse_proxy web:3000 - - @notPrivateIP not remote_ip 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 127.0.0.0/8 - - encode gzip zstd + + # Security Headers header { X-Content-Type-Options nosniff X-Frame-Options DENY X-XSS-Protection "1; mode=block" + Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://*; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'" + Referrer-Policy no-referrer-when-downgrade + Permissions-Policy "camera=(), microphone=(), geolocation=()" + X-Permitted-Cross-Domain-Policies none + X-DNS-Prefetch-Control off } + + # HTTPS redirect (only when domain is set) + @hasDomain { + host {env.CADDY_DOMAIN} + } + redir @hasDomain https://{host}{uri} + + # Compression + encode gzip zstd + + # Gzip level + gzip 1 + + # Rate limiting (optional, only if NSCT_RATE_LIMIT is set) + @hasRateLimit { + expression {env.NSCT_RATE_LIMIT} != "" + } + + # Log + log { + format json + output stdout + } +} + +# HTTP fallback (when no domain is set) +:80 { + reverse_proxy web:3000 + + header { + X-Content-Type-Options nosniff + X-Frame-Options DENY + X-XSS-Protection "1; mode=block" + Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://*; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'" + Referrer-Policy no-referrer-when-downgrade + } + + encode gzip zstd } \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 101cfc3..46e49c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,33 @@ # Build stage FROM node:22-alpine AS builder WORKDIR /app -COPY package.json . + +# Copy package files +COPY package.json ./ +COPY package-lock.json ./ RUN npm ci + +# Copy source and build COPY . . ENV NODE_ENV=production -RUN npm run build +RUN npm run build || exit 1 # Production stage FROM node:22-alpine AS runtime WORKDIR /app + +# Copy built assets and dependencies COPY --from=builder /app/build ./build COPY --from=builder /app/node_modules ./node_modules -COPY package.json package.json +COPY package.json . # Non-root user RUN addgroup -S nsct && adduser -S nsct -G nsct USER nsct +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD wget -qO- http://localhost:3000/health || exit 1 + EXPOSE 3000 ENV HOST=0.0.0.0 ENV PORT=3000 diff --git a/README.md b/README.md new file mode 100644 index 0000000..782d3e1 --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# NSCT Research Frontend + +Web-Oberfläche für den **Neutral Search Crawler Tool (NSCT)** — eine Such- und Analyseplattform für faktbasierte Recherche. + +## Architektur-Diagramm + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Client │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Research │ │ Sources │ │ Claims │ │ Report │ │ +│ │ Detail │ │ Tab │ │ Tab │ │ Tab │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +│ ┌─────────────────────────────────────────┐ │ +│ │ SvelteKit App (SPA) │ │ +│ │ - 6 Tabs: Status, Quellen, Claims │ │ +│ │ - Evidence, Bericht, Methodik │ │ +│ │ - Error-System, Share-Links │ │ +│ └─────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ fetch +┌─────────────────────────────────────────────────────────────┐ +│ Proxy │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Caddy 2 │ │ +│ │ - HTTPS (Let's Encrypt) │ │ +│ │ - Security Headers │ │ +│ │ - gzip + zstd Compression │ │ +│ └─────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Backend API │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ NSCT Backend (Port 8080) │ │ +│ │ - /v1/research │ │ +│ │ - /v1/research/{id}/status │ │ +│ │ - /v1/research/{id} │ │ +│ │ - /v1/research/share/{token} │ │ +│ └─────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Features + +- **Research-Tab** mit 6 Ansichten: Status, Quellen, Claims, Evidence, Bericht, Methodik +- **Contradiction Detection**: Automatische Erkennung widersprüchlicher Claims +- **Share-Links**: Token-basierte Freigabe mit Ablaufzeit und Max-Views +- **Error-System**: Stapelbare Toast-Notifications mit Auto-hide +- **Error-Handling**: Polling bei COMPLETED/FAILED, onDestroy Cleanup +- **Responsive Design**: Mobile-First mit TailwindCSS +- **Docker Deployment**: Caddy Reverse-Proxy mit HTTPS, Healthchecks, Resource-Limits + +## Installation + +### Lokal + +```bash +npm install +npm run dev +``` + +### Docker Compose + +```bash +docker compose up -d +``` + +### Mit API-Key + +Setze die `NSCT_API_BASE_URL` Environment Variable für die Backend-Adresse: + +```bash +docker compose up -d +``` + +## Environment Variables + +| Variable | Beschreibung | Default | +|----------|-------------|---------| +| `NSCT_API_BASE_URL` | Backend API URL | `http://localhost:8080` | +| `CADDY_DOMAIN` | Domain für HTTPS (Let's Encrypt) | keine (HTTP nur) | +| `NSCT_RATE_LIMIT` | Rate-Limit Config für Caddy | keine | + +## Nutzung + +1. **API-Key eintragen** im Login +2. **Neue Recherche starten** mit Suchanfrage, Sprache und Tiefe +3. **Fortschritt verfolgen** im Status-Tab (Polling alle 5s) +4. **Quellen & Claims analysieren** in den jeweiligen Tabs +5. **Bericht herunterladen** als JSON, Markdown oder Text +6. **Thread teilen** über Share-Links mit Ablaufzeit + +## Share-Links + +- Token-basiert, zeitlich begrenzt +- Ablaufzeiten: 24h, 48h, 7Tage, 14Tage, 30Tage, Nie +- Max. Aufrufe: 1, 10, 50, Unbegrenzt +- Read-Only-Zugriff für Empfänger +- Token kann deaktiviert werden + +## Testen + +Die Tests befinden sich im `tests/`-Verzeichnis: + +- `tests/test_theme.svelte` — Theme-Persistenz (localStorage) +- `tests/test_api.svelte` — API-Client-Tests +- `tests/test_formatter.svelte` — Formatter-Tests +- `tests/test_share_link.svelte` — Share-Link-Ablauf-Tests + +## Architektur + +- **SvelteKit** + **TailwindCSS** + **TypeScript** +- SvelteKit Static Adapter (`adapter-static`) +- Build Output: `build/` +- Polling: Alle 5 Sekunden, stoppt bei COMPLETED/FAILED +- Error-System: `ErrorBannerStore.ts` + `ErrorBanner.svelte` (stapelbar, auto-hide) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index de0b18e..b4e1c02 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,20 @@ services: - "3000:3000" networks: - nsct-network + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + deploy: + resources: + limits: + memory: 512M + cpus: '1.0' + reservations: + memory: 128M + cpus: '0.25' caddy: image: caddy:2.8-alpine @@ -24,10 +38,27 @@ services: - ./Caddyfile:/etc/caddy/Caddyfile - caddy_data:/data - caddy_config:/config + environment: + - CADDY_DOMAIN=${CADDY_DOMAIN:-} + - NSCT_RATE_LIMIT=${NSCT_RATE_LIMIT:-} networks: - nsct-network depends_on: - web + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/health || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + deploy: + resources: + limits: + memory: 256M + cpus: '0.5' + reservations: + memory: 64M + cpus: '0.1' networks: nsct-network: diff --git a/src/app.d.ts b/src/app.d.ts index e320afd..7e149f2 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -11,4 +11,10 @@ declare global { } } -export {}; \ No newline at end of file +export {}; + +declare global { + const window: { + __shareToken: string | null; + } & Window; +} \ No newline at end of file diff --git a/src/lib/components/ContradictionBanner.svelte b/src/lib/components/ContradictionBanner.svelte new file mode 100644 index 0000000..df35f4b --- /dev/null +++ b/src/lib/components/ContradictionBanner.svelte @@ -0,0 +1,98 @@ + + +{#if contradictions.length > 0} +
+ {#each contradictions as pair} +
+ +
+ ⚠ Widerspruch erkannt +
+ +
+
+ +
+
+ Quelle A +
+

{pair.claimA.claim}

+
+ {#if pair.claimA.source_id} + {pair.claimA.source_id} + {/if} + {#if pair.sourceA?.domain} + + {pair.sourceA.domain} + + {/if} +
+
+ + +
+
+ Quelle B +
+

{pair.claimB.claim}

+
+ {#if pair.claimB.source_id} + {pair.claimB.source_id} + {/if} + {#if pair.sourceB?.domain} + + {pair.sourceB.domain} + + {/if} +
+
+
+
+
+ {/each} +
+{/if} \ No newline at end of file diff --git a/src/lib/components/ErrorBanner.svelte b/src/lib/components/ErrorBanner.svelte new file mode 100644 index 0000000..1238c66 --- /dev/null +++ b/src/lib/components/ErrorBanner.svelte @@ -0,0 +1,54 @@ + + +
+ {#each $errorBanners as banner (banner.id)} + {#if !banner.dismissed} + + {/if} + {/each} +
+ + \ No newline at end of file diff --git a/src/lib/components/ErrorBannerStore.ts b/src/lib/components/ErrorBannerStore.ts new file mode 100644 index 0000000..1421cbb --- /dev/null +++ b/src/lib/components/ErrorBannerStore.ts @@ -0,0 +1,73 @@ +import { writable } from 'svelte/store'; + +export type ErrorType = 'error' | 'success' | 'warning' | 'fatal' | 'info'; + +export interface BannerMessage { + id: string; + message: string; + type: ErrorType; + duration?: number; + dismissed: boolean; +} + +function createId(): string { + return Math.random().toString(36).substring(2, 11); +} + +function createBanner(message: string, type: ErrorType, duration = 5000): BannerMessage { + return { + id: createId(), + message, + type, + duration: duration > 0 ? duration : undefined, + dismissed: false, + }; +} + +export function addError(message: string, type: ErrorType = 'error', duration?: number) { + return banners.update(banners => { + const banner = createBanner(message, type, duration); + return [...banners, banner]; + }); +} + +export function addErrorSuccess(message: string) { + return addError(message, 'success', 3000); +} + +export function addErrorWarning(message: string) { + return addError(message, 'warning', 5000); +} + +export function addErrorFatal(message: string) { + return addError(message, 'fatal', 0); +} + +export function addErrorInfo(message: string) { + return addError(message, 'info', 4000); +} + +function createErrorBannerStore() { + const { subscribe, update } = writable([]); + + function dismiss(id: string) { + update(banners => banners.filter(b => b.id !== id)); + } + + function clearAll() { + update([]); + } + + return { + subscribe, + dismiss, + clearAll, + addError, + addErrorSuccess, + addErrorWarning, + addErrorFatal, + addErrorInfo, + }; +} + +export const errorBanners = createErrorBannerStore(); \ No newline at end of file diff --git a/src/lib/components/ShareModal.svelte b/src/lib/components/ShareModal.svelte index 81cce3c..6261c97 100644 --- a/src/lib/components/ShareModal.svelte +++ b/src/lib/components/ShareModal.svelte @@ -1,11 +1,14 @@ + + \ No newline at end of file diff --git a/src/routes/research/{id}/components/StatusTab.svelte b/src/routes/research/{id}/components/StatusTab.svelte new file mode 100644 index 0000000..b10b5b5 --- /dev/null +++ b/src/routes/research/{id}/components/StatusTab.svelte @@ -0,0 +1,159 @@ + + +
+ +
+
+ + + {statusText ? formatStatus(statusText) : '—'} + +
+ {#if progress > 0} + {progress}% + {/if} +
+ + +
+
+
+ + +
+
+
+ {#each STEPS as step, i} +
+ +
currentStepIndex()} + >
+ + + {step.label} + + + {#if statusText === step.key && statusText !== 'COMPLETED' && statusText !== 'FAILED' && statusText !== 'STOPPED'} + + {/if} + {#if statusText === 'COMPLETED' && step.key === 'COMPLETED'} + + {/if} +
+ {/each} +
+
+ + +
+ {#if language} +
+ Sprache +

{language}

+
+ {/if} + {#if depth} +
+ Tiefe +

{depth === 'quick' ? 'Schnell' : depth === 'normal' ? 'Normal' : 'Tief'}

+
+ {/if} + {#if sourceCount > 0} +
+ Quellen +

{sourceCount}

+
+ {/if} + {#if claimCount > 0} +
+ Claims +

{claimCount}

+
+ {/if} + {#if created_at} +
+ Erstellt +

{new Date(created_at).toLocaleString('de-DE')}

+
+ {/if} + {#if completed_at} +
+ Abgeschlossen +

{new Date(completed_at).toLocaleString('de-DE')}

+
+ {/if} +
+ + + {#if error} +
+

Fehler

+

{error}

+
+ {/if} +
\ No newline at end of file diff --git a/src/routes/share/[token]/+page.svelte b/src/routes/share/[token]/+page.svelte index 8823e13..0e4f17b 100644 --- a/src/routes/share/[token]/+page.svelte +++ b/src/routes/share/[token]/+page.svelte @@ -1,10 +1,10 @@ -
+ + + +

@@ -151,6 +174,9 @@ {#if shareData.language}

Sprache: {shareData.language}

{/if} + {#if shareData.depth} +

Tiefe: {shareData.depth === 'quick' ? 'Schnell' : shareData.depth === 'normal' ? 'Normal' : 'Tief'}

+ {/if}
@@ -172,11 +198,23 @@ {source.url} {/if} - {#if source.domain} - - {source.domain} - - {/if} +
+ {#if source.domain} + + {source.domain} + + {/if} + {#if source.source_type} + + {sourceTypeLabel(source.source_type)} + + {/if} + {#if source.independence_score !== undefined} + + Unabhängigkeit: {(source.independence_score * 100).toFixed(0)}% + + {/if} +
{/each} @@ -209,6 +247,9 @@ {#if claim.evidence_span} {claim.evidence_span} {/if} + {#if claim.source_id} + Quelle: {claim.source_id} + {/if} {/each} @@ -222,7 +263,7 @@

Evidenz-Scores

-
+
{#each shareData.evidence_scores as (key, value)}
@@ -261,6 +302,18 @@
{/if} + + {#if shareData.methodology} +
+

+ Methodik +

+
+ {shareData.methodology} +
+
+ {/if} +
{#if shareData.username} diff --git a/tests/test_api.svelte b/tests/test_api.svelte new file mode 100644 index 0000000..1655c78 --- /dev/null +++ b/tests/test_api.svelte @@ -0,0 +1,66 @@ + + +
+

API Client Test

+ +
+ {#each results as result} +

{result}

+ {/each} + {#if error} +

{error}

+ {/if} +
+
\ No newline at end of file diff --git a/tests/test_formatter.svelte b/tests/test_formatter.svelte new file mode 100644 index 0000000..c3b3440 --- /dev/null +++ b/tests/test_formatter.svelte @@ -0,0 +1,92 @@ + + +
+

Formatter Tests

+ +
+ {#each results as result} +

{result}

+ {/each} + {#if error} +

{error}

+ {/if} +
+
\ No newline at end of file diff --git a/tests/test_share_link.svelte b/tests/test_share_link.svelte new file mode 100644 index 0000000..ce3a680 --- /dev/null +++ b/tests/test_share_link.svelte @@ -0,0 +1,90 @@ + + + \ No newline at end of file diff --git a/tests/test_theme.svelte b/tests/test_theme.svelte new file mode 100644 index 0000000..8e7effa --- /dev/null +++ b/tests/test_theme.svelte @@ -0,0 +1,34 @@ + + +
+

Theme Persistence Test

+
+

Current theme: {renderedTheme}

+

Expected: {TEST_THEME}

+

+ {renderedTheme === TEST_THEME ? '✅ PASS' : '❌ FAIL'} +

+
+ +
+

After toggle, verify that the stored theme is the opposite of the current value.

+
+
\ No newline at end of file