diff --git a/consent-sdk/src/core/ConsentManager.ts b/consent-sdk/src/core/ConsentManager.ts index c7fedd0..0a1e3ba 100644 --- a/consent-sdk/src/core/ConsentManager.ts +++ b/consent-sdk/src/core/ConsentManager.ts @@ -1,14 +1,9 @@ -/** - * ConsentManager - Hauptklasse fuer das Consent Management - * - * DSGVO/TTDSG-konformes Consent Management fuer Web, PWA und Mobile. - */ +/** ConsentManager - DSGVO/TTDSG-konformes Consent Management fuer Web, PWA und Mobile. */ import type { ConsentConfig, ConsentState, ConsentCategory, - ConsentCategories, ConsentInput, ConsentEventType, ConsentEventCallback, @@ -20,15 +15,16 @@ import { ConsentAPI } from './ConsentAPI'; import { EventEmitter } from '../utils/EventEmitter'; import { generateFingerprint } from '../utils/fingerprint'; import { SDK_VERSION } from '../version'; -import { - DEFAULT_CONSENT, - mergeConsentConfig, -} from './consent-manager-config'; +import { mergeConsentConfig } from './consent-manager-config'; import { updateGoogleConsentMode as applyGoogleConsent } from './consent-manager-google'; +import { + normalizeConsentInput, + isConsentExpired, + needsConsent, + ALL_CATEGORIES, + MINIMAL_CATEGORIES, +} from './consent-manager-helpers'; -/** - * ConsentManager - Zentrale Klasse fuer Consent-Verwaltung - */ export class ConsentManager { private config: ConsentConfig; private storage: ConsentStorage; @@ -41,7 +37,7 @@ export class ConsentManager { private deviceFingerprint: string = ''; constructor(config: ConsentConfig) { - this.config = this.mergeConfig(config); + this.config = mergeConsentConfig(config); this.storage = new ConsentStorage(this.config); this.scriptBlocker = new ScriptBlocker(this.config); this.api = new ConsentAPI(this.config); @@ -72,7 +68,7 @@ export class ConsentManager { this.log('Loaded consent from storage:', this.currentConsent); // Pruefen ob Consent abgelaufen - if (this.isConsentExpired()) { + if (isConsentExpired(this.currentConsent, this.config)) { this.log('Consent expired, clearing'); this.storage.clear(); this.currentConsent = null; @@ -89,7 +85,7 @@ export class ConsentManager { this.emit('init', this.currentConsent); // Banner anzeigen falls noetig - if (this.needsConsent()) { + if (needsConsent(this.currentConsent, this.config)) { this.showBanner(); } @@ -100,9 +96,7 @@ export class ConsentManager { } } - // =========================================================================== - // Public API - // =========================================================================== + // --- Public API --- /** * Pruefen ob Consent fuer Kategorie vorhanden @@ -135,7 +129,7 @@ export class ConsentManager { * Consent setzen */ async setConsent(input: ConsentInput): Promise { - const categories = this.normalizeConsentInput(input); + const categories = normalizeConsentInput(input); // Essential ist immer aktiv categories.essential = true; @@ -184,15 +178,7 @@ export class ConsentManager { * Alle Kategorien akzeptieren */ async acceptAll(): Promise { - const allCategories: ConsentCategories = { - essential: true, - functional: true, - analytics: true, - marketing: true, - social: true, - }; - - await this.setConsent(allCategories); + await this.setConsent(ALL_CATEGORIES); this.emit('accept_all', this.currentConsent!); this.hideBanner(); } @@ -201,15 +187,7 @@ export class ConsentManager { * Alle nicht-essentiellen Kategorien ablehnen */ async rejectAll(): Promise { - const minimalCategories: ConsentCategories = { - essential: true, - functional: false, - analytics: false, - marketing: false, - social: false, - }; - - await this.setConsent(minimalCategories); + await this.setConsent(MINIMAL_CATEGORIES); this.emit('reject_all', this.currentConsent!); this.hideBanner(); } @@ -247,52 +225,23 @@ export class ConsentManager { return JSON.stringify(exportData, null, 2); } - // =========================================================================== - // Banner Control - // =========================================================================== + // --- Banner Control --- /** * Pruefen ob Consent-Abfrage noetig */ needsConsent(): boolean { - if (!this.currentConsent) { - return true; - } - - if (this.isConsentExpired()) { - return true; - } - - // Recheck nach X Tagen - if (this.config.consent?.recheckAfterDays) { - const consentDate = new Date(this.currentConsent.timestamp); - const recheckDate = new Date(consentDate); - recheckDate.setDate( - recheckDate.getDate() + this.config.consent.recheckAfterDays - ); - - if (new Date() > recheckDate) { - return true; - } - } - - return false; + return needsConsent(this.currentConsent, this.config); } /** * Banner anzeigen */ showBanner(): void { - if (this.bannerVisible) { - return; - } - + if (this.bannerVisible) return; this.bannerVisible = true; this.emit('banner_show', undefined); this.config.onBannerShow?.(); - - // Banner wird von UI-Komponente gerendert - // Hier nur Status setzen this.log('Banner shown'); } @@ -300,14 +249,10 @@ export class ConsentManager { * Banner verstecken */ hideBanner(): void { - if (!this.bannerVisible) { - return; - } - + if (!this.bannerVisible) return; this.bannerVisible = false; this.emit('banner_hide', undefined); this.config.onBannerHide?.(); - this.log('Banner hidden'); } @@ -326,9 +271,7 @@ export class ConsentManager { return this.bannerVisible; } - // =========================================================================== - // Event Handling - // =========================================================================== + // --- Event Handling --- /** * Event-Listener registrieren @@ -350,35 +293,13 @@ export class ConsentManager { this.events.off(event, callback); } - // =========================================================================== - // Internal Methods - // =========================================================================== - - /** - * Konfiguration zusammenfuehren — delegates to the extracted helper. - */ - private mergeConfig(config: ConsentConfig): ConsentConfig { - return mergeConsentConfig(config); - } - - /** - * Consent-Input normalisieren - */ - private normalizeConsentInput(input: ConsentInput): ConsentCategories { - if ('categories' in input && input.categories) { - return { ...DEFAULT_CONSENT, ...input.categories }; - } - - return { ...DEFAULT_CONSENT, ...(input as Partial) }; - } + // --- Internal Methods --- /** * Consent anwenden (Skripte aktivieren/blockieren) */ private applyConsent(): void { - if (!this.currentConsent) { - return; - } + if (!this.currentConsent) return; for (const [category, allowed] of Object.entries( this.currentConsent.categories @@ -391,69 +312,26 @@ export class ConsentManager { } // Google Consent Mode aktualisieren - this.updateGoogleConsentMode(); - } - - /** - * Google Consent Mode v2 aktualisieren — delegates to the extracted helper. - */ - private updateGoogleConsentMode(): void { if (applyGoogleConsent(this.currentConsent)) { this.log('Google Consent Mode updated'); } } - /** - * Pruefen ob Consent abgelaufen - */ - private isConsentExpired(): boolean { - if (!this.currentConsent?.expiresAt) { - // Fallback: Nach rememberDays ablaufen - if (this.currentConsent?.timestamp && this.config.consent?.rememberDays) { - const consentDate = new Date(this.currentConsent.timestamp); - const expiryDate = new Date(consentDate); - expiryDate.setDate( - expiryDate.getDate() + this.config.consent.rememberDays - ); - return new Date() > expiryDate; - } - return false; - } - - return new Date() > new Date(this.currentConsent.expiresAt); - } - - /** - * Event emittieren - */ - private emit( - event: T, - data: ConsentEventData[T] - ): void { + private emit(event: T, data: ConsentEventData[T]): void { this.events.emit(event, data); } - /** - * Fehler behandeln - */ private handleError(error: Error): void { this.log('Error:', error); this.emit('error', error); this.config.onError?.(error); } - /** - * Debug-Logging - */ private log(...args: unknown[]): void { - if (this.config.debug) { - console.log('[ConsentSDK]', ...args); - } + if (this.config.debug) console.log('[ConsentSDK]', ...args); } - // =========================================================================== - // Static Methods - // =========================================================================== + // --- Static Methods --- /** * SDK-Version abrufen diff --git a/consent-sdk/src/core/consent-manager-helpers.ts b/consent-sdk/src/core/consent-manager-helpers.ts new file mode 100644 index 0000000..3e262b5 --- /dev/null +++ b/consent-sdk/src/core/consent-manager-helpers.ts @@ -0,0 +1,88 @@ +/** + * consent-manager-helpers.ts + * + * Pure helper functions used by ConsentManager that have no dependency on + * class instance state. Extracted to keep ConsentManager.ts under the + * 350 LOC soft target. + */ + +import type { + ConsentState, + ConsentCategories, + ConsentInput, + ConsentConfig, +} from '../types'; +import { DEFAULT_CONSENT } from './consent-manager-config'; + +/** All categories accepted (used by acceptAll). */ +export const ALL_CATEGORIES: ConsentCategories = { + essential: true, + functional: true, + analytics: true, + marketing: true, + social: true, +}; + +/** Only essential consent (used by rejectAll). */ +export const MINIMAL_CATEGORIES: ConsentCategories = { + essential: true, + functional: false, + analytics: false, + marketing: false, + social: false, +}; + +/** + * Normalise a ConsentInput into a full ConsentCategories map. + * Always returns a shallow copy — never mutates the input. + */ +export function normalizeConsentInput(input: ConsentInput): ConsentCategories { + if ('categories' in input && input.categories) { + return { ...DEFAULT_CONSENT, ...input.categories }; + } + return { ...DEFAULT_CONSENT, ...(input as Partial) }; +} + +/** + * Return true when the stored consent record has passed its expiry date. + * Falls back to `rememberDays` from config when `expiresAt` is absent. + */ +export function isConsentExpired( + consent: ConsentState | null, + config: ConsentConfig +): boolean { + if (!consent) return false; + + if (!consent.expiresAt) { + if (consent.timestamp && config.consent?.rememberDays) { + const consentDate = new Date(consent.timestamp); + const expiryDate = new Date(consentDate); + expiryDate.setDate(expiryDate.getDate() + config.consent.rememberDays); + return new Date() > expiryDate; + } + return false; + } + + return new Date() > new Date(consent.expiresAt); +} + +/** + * Return true when the user needs to be shown the consent banner. + */ +export function needsConsent( + consent: ConsentState | null, + config: ConsentConfig +): boolean { + if (!consent) return true; + + if (isConsentExpired(consent, config)) return true; + + if (config.consent?.recheckAfterDays) { + const consentDate = new Date(consent.timestamp); + const recheckDate = new Date(consentDate); + recheckDate.setDate(recheckDate.getDate() + config.consent.recheckAfterDays); + if (new Date() > recheckDate) return true; + } + + return false; +} diff --git a/consent-sdk/src/types/api.ts b/consent-sdk/src/types/api.ts new file mode 100644 index 0000000..9432b1d --- /dev/null +++ b/consent-sdk/src/types/api.ts @@ -0,0 +1,56 @@ +/** + * Consent SDK Types — API request/response shapes + */ + +import type { ConsentCategory, ConsentState } from './core'; +import type { ConsentUIConfig, TCFConfig } from './config'; +import type { ConsentVendor } from './vendor'; + +// ============================================================================= +// API Types +// ============================================================================= + +/** + * API-Antwort fuer Consent-Erstellung + */ +export interface ConsentAPIResponse { + consentId: string; + timestamp: string; + expiresAt: string; + version: string; +} + +/** + * API-Antwort fuer Site-Konfiguration + */ +export interface SiteConfigResponse { + siteId: string; + siteName: string; + categories: CategoryConfig[]; + ui: ConsentUIConfig; + legal: LegalConfig; + tcf?: TCFConfig; +} + +/** + * Kategorie-Konfiguration vom Server + */ +export interface CategoryConfig { + id: ConsentCategory; + name: Record; + description: Record; + required: boolean; + vendors: ConsentVendor[]; +} + +/** + * Rechtliche Konfiguration + */ +export interface LegalConfig { + privacyPolicyUrl: string; + imprintUrl: string; + dpo?: { + name: string; + email: string; + }; +} diff --git a/consent-sdk/src/types/config.ts b/consent-sdk/src/types/config.ts new file mode 100644 index 0000000..d14a223 --- /dev/null +++ b/consent-sdk/src/types/config.ts @@ -0,0 +1,166 @@ +/** + * Consent SDK Types — Configuration + */ + +import type { ConsentCategory, ConsentState } from './core'; + +// ============================================================================= +// Configuration +// ============================================================================= + +/** + * UI-Position des Banners + */ +export type BannerPosition = 'bottom' | 'top' | 'center'; + +/** + * Banner-Layout + */ +export type BannerLayout = 'bar' | 'modal' | 'floating'; + +/** + * Farbschema + */ +export type BannerTheme = 'light' | 'dark' | 'auto'; + +/** + * UI-Konfiguration + */ +export interface ConsentUIConfig { + /** Position des Banners */ + position?: BannerPosition; + + /** Layout-Typ */ + layout?: BannerLayout; + + /** Farbschema */ + theme?: BannerTheme; + + /** Pfad zu Custom CSS */ + customCss?: string; + + /** z-index fuer Banner */ + zIndex?: number; + + /** Scroll blockieren bei Modal */ + blockScrollOnModal?: boolean; + + /** Custom Container-ID */ + containerId?: string; +} + +/** + * Consent-Verhaltens-Konfiguration + */ +export interface ConsentBehaviorConfig { + /** Muss Nutzer interagieren? */ + required?: boolean; + + /** "Alle ablehnen" Button sichtbar */ + rejectAllVisible?: boolean; + + /** "Alle akzeptieren" Button sichtbar */ + acceptAllVisible?: boolean; + + /** Einzelne Kategorien waehlbar */ + granularControl?: boolean; + + /** Einzelne Vendors waehlbar */ + vendorControl?: boolean; + + /** Auswahl speichern */ + rememberChoice?: boolean; + + /** Speicherdauer in Tagen */ + rememberDays?: number; + + /** Nur in EU anzeigen (Geo-Targeting) */ + geoTargeting?: boolean; + + /** Erneut nachfragen nach X Tagen */ + recheckAfterDays?: number; +} + +/** + * TCF 2.2 Konfiguration + */ +export interface TCFConfig { + /** TCF aktivieren */ + enabled?: boolean; + + /** CMP ID */ + cmpId?: number; + + /** CMP Version */ + cmpVersion?: number; +} + +/** + * PWA-spezifische Konfiguration + */ +export interface PWAConfig { + /** Offline-Unterstuetzung aktivieren */ + offlineSupport?: boolean; + + /** Bei Reconnect synchronisieren */ + syncOnReconnect?: boolean; + + /** Cache-Strategie */ + cacheStrategy?: 'stale-while-revalidate' | 'network-first' | 'cache-first'; +} + +/** + * Haupt-Konfiguration fuer ConsentManager + */ +export interface ConsentConfig { + // Pflichtfelder + /** API-Endpunkt fuer Consent-Backend */ + apiEndpoint: string; + + /** Site-ID */ + siteId: string; + + // Sprache + /** Sprache (ISO 639-1) */ + language?: string; + + /** Fallback-Sprache */ + fallbackLanguage?: string; + + // UI + /** UI-Konfiguration */ + ui?: ConsentUIConfig; + + // Verhalten + /** Consent-Verhaltens-Konfiguration */ + consent?: ConsentBehaviorConfig; + + // Kategorien + /** Aktive Kategorien */ + categories?: ConsentCategory[]; + + // TCF + /** TCF 2.2 Konfiguration */ + tcf?: TCFConfig; + + // PWA + /** PWA-Konfiguration */ + pwa?: PWAConfig; + + // Callbacks + /** Callback bei Consent-Aenderung */ + onConsentChange?: (consent: ConsentState) => void; + + /** Callback wenn Banner angezeigt wird */ + onBannerShow?: () => void; + + /** Callback wenn Banner geschlossen wird */ + onBannerHide?: () => void; + + /** Callback bei Fehler */ + onError?: (error: Error) => void; + + // Debug + /** Debug-Modus aktivieren */ + debug?: boolean; +} diff --git a/consent-sdk/src/types/core.ts b/consent-sdk/src/types/core.ts new file mode 100644 index 0000000..bea5adc --- /dev/null +++ b/consent-sdk/src/types/core.ts @@ -0,0 +1,65 @@ +/** + * Consent SDK Types — Core: categories, state, input + */ + +// ============================================================================= +// Consent Categories +// ============================================================================= + +/** + * Standard-Consent-Kategorien nach IAB TCF 2.2 + */ +export type ConsentCategory = + | 'essential' // Technisch notwendig (TTDSG § 25 Abs. 2) + | 'functional' // Personalisierung, Komfortfunktionen + | 'analytics' // Anonyme Nutzungsanalyse + | 'marketing' // Werbung, Retargeting + | 'social'; // Social Media Plugins + +/** + * Consent-Status pro Kategorie + */ +export type ConsentCategories = Record; + +/** + * Consent-Status pro Vendor + */ +export type ConsentVendors = Record; + +// ============================================================================= +// Consent State +// ============================================================================= + +/** + * Aktueller Consent-Zustand + */ +export interface ConsentState { + /** Consent pro Kategorie */ + categories: ConsentCategories; + + /** Consent pro Vendor (optional, für granulare Kontrolle) */ + vendors: ConsentVendors; + + /** Zeitstempel der letzten Aenderung */ + timestamp: string; + + /** SDK-Version bei Erstellung */ + version: string; + + /** Eindeutige Consent-ID vom Backend */ + consentId?: string; + + /** Ablaufdatum */ + expiresAt?: string; + + /** IAB TCF String (falls aktiviert) */ + tcfString?: string; +} + +/** + * Minimaler Consent-Input fuer setConsent() + */ +export type ConsentInput = Partial | { + categories?: Partial; + vendors?: ConsentVendors; +}; diff --git a/consent-sdk/src/types/events.ts b/consent-sdk/src/types/events.ts new file mode 100644 index 0000000..08fad42 --- /dev/null +++ b/consent-sdk/src/types/events.ts @@ -0,0 +1,49 @@ +/** + * Consent SDK Types — Event system + */ + +import type { ConsentState } from './core'; + +// ============================================================================= +// Events +// ============================================================================= + +/** + * Event-Typen + */ +export type ConsentEventType = + | 'init' + | 'change' + | 'accept_all' + | 'reject_all' + | 'save_selection' + | 'banner_show' + | 'banner_hide' + | 'settings_open' + | 'settings_close' + | 'vendor_enable' + | 'vendor_disable' + | 'error'; + +/** + * Event-Listener Callback + */ +export type ConsentEventCallback = (data: T) => void; + +/** + * Event-Daten fuer verschiedene Events + */ +export type ConsentEventData = { + init: ConsentState | null; + change: ConsentState; + accept_all: ConsentState; + reject_all: ConsentState; + save_selection: ConsentState; + banner_show: undefined; + banner_hide: undefined; + settings_open: undefined; + settings_close: undefined; + vendor_enable: string; + vendor_disable: string; + error: Error; +}; diff --git a/consent-sdk/src/types/index.ts b/consent-sdk/src/types/index.ts index f017c07..f7e2caa 100644 --- a/consent-sdk/src/types/index.ts +++ b/consent-sdk/src/types/index.ts @@ -1,438 +1,21 @@ /** - * Consent SDK Types + * Consent SDK Types — barrel re-export * - * DSGVO/TTDSG-konforme Typdefinitionen für das Consent Management System. + * Domain files: + * core.ts — ConsentCategory, ConsentCategories, ConsentVendors, ConsentState, ConsentInput + * config.ts — BannerPosition, BannerLayout, BannerTheme, ConsentUIConfig, + * ConsentBehaviorConfig, TCFConfig, PWAConfig, ConsentConfig + * vendor.ts — CookieInfo, ConsentVendor + * api.ts — ConsentAPIResponse, SiteConfigResponse, CategoryConfig, LegalConfig + * events.ts — ConsentEventType, ConsentEventCallback, ConsentEventData + * storage.ts — ConsentStorageAdapter + * translations.ts — ConsentTranslations, SupportedLanguage */ -// ============================================================================= -// Consent Categories -// ============================================================================= - -/** - * Standard-Consent-Kategorien nach IAB TCF 2.2 - */ -export type ConsentCategory = - | 'essential' // Technisch notwendig (TTDSG § 25 Abs. 2) - | 'functional' // Personalisierung, Komfortfunktionen - | 'analytics' // Anonyme Nutzungsanalyse - | 'marketing' // Werbung, Retargeting - | 'social'; // Social Media Plugins - -/** - * Consent-Status pro Kategorie - */ -export type ConsentCategories = Record; - -/** - * Consent-Status pro Vendor - */ -export type ConsentVendors = Record; - -// ============================================================================= -// Consent State -// ============================================================================= - -/** - * Aktueller Consent-Zustand - */ -export interface ConsentState { - /** Consent pro Kategorie */ - categories: ConsentCategories; - - /** Consent pro Vendor (optional, für granulare Kontrolle) */ - vendors: ConsentVendors; - - /** Zeitstempel der letzten Aenderung */ - timestamp: string; - - /** SDK-Version bei Erstellung */ - version: string; - - /** Eindeutige Consent-ID vom Backend */ - consentId?: string; - - /** Ablaufdatum */ - expiresAt?: string; - - /** IAB TCF String (falls aktiviert) */ - tcfString?: string; -} - -/** - * Minimaler Consent-Input fuer setConsent() - */ -export type ConsentInput = Partial | { - categories?: Partial; - vendors?: ConsentVendors; -}; - -// ============================================================================= -// Configuration -// ============================================================================= - -/** - * UI-Position des Banners - */ -export type BannerPosition = 'bottom' | 'top' | 'center'; - -/** - * Banner-Layout - */ -export type BannerLayout = 'bar' | 'modal' | 'floating'; - -/** - * Farbschema - */ -export type BannerTheme = 'light' | 'dark' | 'auto'; - -/** - * UI-Konfiguration - */ -export interface ConsentUIConfig { - /** Position des Banners */ - position?: BannerPosition; - - /** Layout-Typ */ - layout?: BannerLayout; - - /** Farbschema */ - theme?: BannerTheme; - - /** Pfad zu Custom CSS */ - customCss?: string; - - /** z-index fuer Banner */ - zIndex?: number; - - /** Scroll blockieren bei Modal */ - blockScrollOnModal?: boolean; - - /** Custom Container-ID */ - containerId?: string; -} - -/** - * Consent-Verhaltens-Konfiguration - */ -export interface ConsentBehaviorConfig { - /** Muss Nutzer interagieren? */ - required?: boolean; - - /** "Alle ablehnen" Button sichtbar */ - rejectAllVisible?: boolean; - - /** "Alle akzeptieren" Button sichtbar */ - acceptAllVisible?: boolean; - - /** Einzelne Kategorien waehlbar */ - granularControl?: boolean; - - /** Einzelne Vendors waehlbar */ - vendorControl?: boolean; - - /** Auswahl speichern */ - rememberChoice?: boolean; - - /** Speicherdauer in Tagen */ - rememberDays?: number; - - /** Nur in EU anzeigen (Geo-Targeting) */ - geoTargeting?: boolean; - - /** Erneut nachfragen nach X Tagen */ - recheckAfterDays?: number; -} - -/** - * TCF 2.2 Konfiguration - */ -export interface TCFConfig { - /** TCF aktivieren */ - enabled?: boolean; - - /** CMP ID */ - cmpId?: number; - - /** CMP Version */ - cmpVersion?: number; -} - -/** - * PWA-spezifische Konfiguration - */ -export interface PWAConfig { - /** Offline-Unterstuetzung aktivieren */ - offlineSupport?: boolean; - - /** Bei Reconnect synchronisieren */ - syncOnReconnect?: boolean; - - /** Cache-Strategie */ - cacheStrategy?: 'stale-while-revalidate' | 'network-first' | 'cache-first'; -} - -/** - * Haupt-Konfiguration fuer ConsentManager - */ -export interface ConsentConfig { - // Pflichtfelder - /** API-Endpunkt fuer Consent-Backend */ - apiEndpoint: string; - - /** Site-ID */ - siteId: string; - - // Sprache - /** Sprache (ISO 639-1) */ - language?: string; - - /** Fallback-Sprache */ - fallbackLanguage?: string; - - // UI - /** UI-Konfiguration */ - ui?: ConsentUIConfig; - - // Verhalten - /** Consent-Verhaltens-Konfiguration */ - consent?: ConsentBehaviorConfig; - - // Kategorien - /** Aktive Kategorien */ - categories?: ConsentCategory[]; - - // TCF - /** TCF 2.2 Konfiguration */ - tcf?: TCFConfig; - - // PWA - /** PWA-Konfiguration */ - pwa?: PWAConfig; - - // Callbacks - /** Callback bei Consent-Aenderung */ - onConsentChange?: (consent: ConsentState) => void; - - /** Callback wenn Banner angezeigt wird */ - onBannerShow?: () => void; - - /** Callback wenn Banner geschlossen wird */ - onBannerHide?: () => void; - - /** Callback bei Fehler */ - onError?: (error: Error) => void; - - // Debug - /** Debug-Modus aktivieren */ - debug?: boolean; -} - -// ============================================================================= -// Vendor Configuration -// ============================================================================= - -/** - * Cookie-Information - */ -export interface CookieInfo { - /** Cookie-Name */ - name: string; - - /** Cookie-Domain */ - domain: string; - - /** Ablaufzeit (z.B. "2 Jahre", "Session") */ - expiration: string; - - /** Speichertyp */ - type: 'http' | 'localStorage' | 'sessionStorage' | 'indexedDB'; - - /** Beschreibung */ - description: string; -} - -/** - * Vendor-Definition - */ -export interface ConsentVendor { - /** Eindeutige Vendor-ID */ - id: string; - - /** Anzeigename */ - name: string; - - /** Kategorie */ - category: ConsentCategory; - - /** IAB TCF Purposes (falls relevant) */ - purposes?: number[]; - - /** Legitimate Interests */ - legitimateInterests?: number[]; - - /** Cookie-Liste */ - cookies: CookieInfo[]; - - /** Link zur Datenschutzerklaerung */ - privacyPolicyUrl: string; - - /** Datenaufbewahrung */ - dataRetention?: string; - - /** Datentransfer (z.B. "USA (EU-US DPF)", "EU") */ - dataTransfer?: string; -} - -// ============================================================================= -// API Types -// ============================================================================= - -/** - * API-Antwort fuer Consent-Erstellung - */ -export interface ConsentAPIResponse { - consentId: string; - timestamp: string; - expiresAt: string; - version: string; -} - -/** - * API-Antwort fuer Site-Konfiguration - */ -export interface SiteConfigResponse { - siteId: string; - siteName: string; - categories: CategoryConfig[]; - ui: ConsentUIConfig; - legal: LegalConfig; - tcf?: TCFConfig; -} - -/** - * Kategorie-Konfiguration vom Server - */ -export interface CategoryConfig { - id: ConsentCategory; - name: Record; - description: Record; - required: boolean; - vendors: ConsentVendor[]; -} - -/** - * Rechtliche Konfiguration - */ -export interface LegalConfig { - privacyPolicyUrl: string; - imprintUrl: string; - dpo?: { - name: string; - email: string; - }; -} - -// ============================================================================= -// Events -// ============================================================================= - -/** - * Event-Typen - */ -export type ConsentEventType = - | 'init' - | 'change' - | 'accept_all' - | 'reject_all' - | 'save_selection' - | 'banner_show' - | 'banner_hide' - | 'settings_open' - | 'settings_close' - | 'vendor_enable' - | 'vendor_disable' - | 'error'; - -/** - * Event-Listener Callback - */ -export type ConsentEventCallback = (data: T) => void; - -/** - * Event-Daten fuer verschiedene Events - */ -export type ConsentEventData = { - init: ConsentState | null; - change: ConsentState; - accept_all: ConsentState; - reject_all: ConsentState; - save_selection: ConsentState; - banner_show: undefined; - banner_hide: undefined; - settings_open: undefined; - settings_close: undefined; - vendor_enable: string; - vendor_disable: string; - error: Error; -}; - -// ============================================================================= -// Storage -// ============================================================================= - -/** - * Storage-Adapter Interface - */ -export interface ConsentStorageAdapter { - /** Consent laden */ - get(): ConsentState | null; - - /** Consent speichern */ - set(consent: ConsentState): void; - - /** Consent loeschen */ - clear(): void; - - /** Pruefen ob Consent existiert */ - exists(): boolean; -} - -// ============================================================================= -// Translations -// ============================================================================= - -/** - * Uebersetzungsstruktur - */ -export interface ConsentTranslations { - title: string; - description: string; - acceptAll: string; - rejectAll: string; - settings: string; - saveSelection: string; - close: string; - categories: { - [K in ConsentCategory]: { - name: string; - description: string; - }; - }; - footer: { - privacyPolicy: string; - imprint: string; - cookieDetails: string; - }; - accessibility: { - closeButton: string; - categoryToggle: string; - requiredCategory: string; - }; -} - -/** - * Alle unterstuetzten Sprachen - */ -export type SupportedLanguage = - | 'de' | 'en' | 'fr' | 'es' | 'it' | 'nl' | 'pl' | 'pt' - | 'cs' | 'da' | 'el' | 'fi' | 'hu' | 'ro' | 'sk' | 'sl' | 'sv'; +export * from './core'; +export * from './config'; +export * from './vendor'; +export * from './api'; +export * from './events'; +export * from './storage'; +export * from './translations'; diff --git a/consent-sdk/src/types/storage.ts b/consent-sdk/src/types/storage.ts new file mode 100644 index 0000000..8dcdebb --- /dev/null +++ b/consent-sdk/src/types/storage.ts @@ -0,0 +1,26 @@ +/** + * Consent SDK Types — Storage adapter interface + */ + +import type { ConsentState } from './core'; + +// ============================================================================= +// Storage +// ============================================================================= + +/** + * Storage-Adapter Interface + */ +export interface ConsentStorageAdapter { + /** Consent laden */ + get(): ConsentState | null; + + /** Consent speichern */ + set(consent: ConsentState): void; + + /** Consent loeschen */ + clear(): void; + + /** Pruefen ob Consent existiert */ + exists(): boolean; +} diff --git a/consent-sdk/src/types/translations.ts b/consent-sdk/src/types/translations.ts new file mode 100644 index 0000000..a892c1f --- /dev/null +++ b/consent-sdk/src/types/translations.ts @@ -0,0 +1,45 @@ +/** + * Consent SDK Types — Translations and i18n + */ + +import type { ConsentCategory } from './core'; + +// ============================================================================= +// Translations +// ============================================================================= + +/** + * Uebersetzungsstruktur + */ +export interface ConsentTranslations { + title: string; + description: string; + acceptAll: string; + rejectAll: string; + settings: string; + saveSelection: string; + close: string; + categories: { + [K in ConsentCategory]: { + name: string; + description: string; + }; + }; + footer: { + privacyPolicy: string; + imprint: string; + cookieDetails: string; + }; + accessibility: { + closeButton: string; + categoryToggle: string; + requiredCategory: string; + }; +} + +/** + * Alle unterstuetzten Sprachen + */ +export type SupportedLanguage = + | 'de' | 'en' | 'fr' | 'es' | 'it' | 'nl' | 'pl' | 'pt' + | 'cs' | 'da' | 'el' | 'fi' | 'hu' | 'ro' | 'sk' | 'sl' | 'sv'; diff --git a/consent-sdk/src/types/vendor.ts b/consent-sdk/src/types/vendor.ts new file mode 100644 index 0000000..4b4e4da --- /dev/null +++ b/consent-sdk/src/types/vendor.ts @@ -0,0 +1,61 @@ +/** + * Consent SDK Types — Vendor definitions + */ + +import type { ConsentCategory } from './core'; + +// ============================================================================= +// Vendor Configuration +// ============================================================================= + +/** + * Cookie-Information + */ +export interface CookieInfo { + /** Cookie-Name */ + name: string; + + /** Cookie-Domain */ + domain: string; + + /** Ablaufzeit (z.B. "2 Jahre", "Session") */ + expiration: string; + + /** Speichertyp */ + type: 'http' | 'localStorage' | 'sessionStorage' | 'indexedDB'; + + /** Beschreibung */ + description: string; +} + +/** + * Vendor-Definition + */ +export interface ConsentVendor { + /** Eindeutige Vendor-ID */ + id: string; + + /** Anzeigename */ + name: string; + + /** Kategorie */ + category: ConsentCategory; + + /** IAB TCF Purposes (falls relevant) */ + purposes?: number[]; + + /** Legitimate Interests */ + legitimateInterests?: number[]; + + /** Cookie-Liste */ + cookies: CookieInfo[]; + + /** Link zur Datenschutzerklaerung */ + privacyPolicyUrl: string; + + /** Datenaufbewahrung */ + dataRetention?: string; + + /** Datentransfer (z.B. "USA (EU-US DPF)", "EU") */ + dataTransfer?: string; +} diff --git a/dsms-gateway/config.py b/dsms-gateway/config.py new file mode 100644 index 0000000..d468d39 --- /dev/null +++ b/dsms-gateway/config.py @@ -0,0 +1,9 @@ +""" +DSMS Gateway — runtime configuration from environment variables. +""" + +import os + +IPFS_API_URL = os.getenv("IPFS_API_URL", "http://dsms-node:5001") +IPFS_GATEWAY_URL = os.getenv("IPFS_GATEWAY_URL", "http://dsms-node:8080") +JWT_SECRET = os.getenv("JWT_SECRET", "your-super-secret-jwt-key-change-in-production") diff --git a/dsms-gateway/dependencies.py b/dsms-gateway/dependencies.py new file mode 100644 index 0000000..28cc1b8 --- /dev/null +++ b/dsms-gateway/dependencies.py @@ -0,0 +1,76 @@ +""" +DSMS Gateway — shared FastAPI dependencies and IPFS helper coroutines. +""" + +from typing import Optional + +import httpx +from fastapi import Header, HTTPException + +from config import IPFS_API_URL + + +async def verify_token(authorization: Optional[str] = Header(None)) -> dict: + """Verifiziert JWT Token (vereinfacht für MVP)""" + if not authorization: + raise HTTPException(status_code=401, detail="Authorization header fehlt") + + # In Produktion: JWT validieren + # Für MVP: Einfache Token-Prüfung + if not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Ungültiges Token-Format") + + return {"valid": True} + + +async def ipfs_add(content: bytes, pin: bool = True) -> dict: + """Fügt Inhalt zu IPFS hinzu""" + async with httpx.AsyncClient(timeout=60.0) as client: + files = {"file": ("document", content)} + params = {"pin": str(pin).lower()} + + response = await client.post( + f"{IPFS_API_URL}/api/v0/add", + files=files, + params=params + ) + + if response.status_code != 200: + raise HTTPException( + status_code=502, + detail=f"IPFS Fehler: {response.text}" + ) + + return response.json() + + +async def ipfs_cat(cid: str) -> bytes: + """Liest Inhalt von IPFS""" + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + f"{IPFS_API_URL}/api/v0/cat", + params={"arg": cid} + ) + + if response.status_code != 200: + raise HTTPException( + status_code=404, + detail=f"Dokument nicht gefunden: {cid}" + ) + + return response.content + + +async def ipfs_pin_ls() -> list: + """Listet alle gepinnten Objekte""" + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{IPFS_API_URL}/api/v0/pin/ls", + params={"type": "recursive"} + ) + + if response.status_code != 200: + return [] + + data = response.json() + return list(data.get("Keys", {}).keys()) diff --git a/dsms-gateway/main.py b/dsms-gateway/main.py index 0a2a390..a8d0a2f 100644 --- a/dsms-gateway/main.py +++ b/dsms-gateway/main.py @@ -3,17 +3,18 @@ DSMS Gateway - REST API für dezentrales Speichersystem Bietet eine vereinfachte API über IPFS für BreakPilot """ +import sys import os -import json -import httpx -import hashlib -from datetime import datetime -from typing import Optional -from fastapi import FastAPI, HTTPException, UploadFile, File, Header, Depends + +# Ensure the gateway directory itself is on the path so routers can use flat imports. +sys.path.insert(0, os.path.dirname(__file__)) + +from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import StreamingResponse -from pydantic import BaseModel -import io + +from models import DocumentMetadata, StoredDocument, DocumentList # noqa: F401 — re-exported for tests +from routers.documents import router as documents_router +from routers.node import router as node_router app = FastAPI( title="DSMS Gateway", @@ -30,436 +31,9 @@ app.add_middleware( allow_headers=["*"], ) -# Configuration -IPFS_API_URL = os.getenv("IPFS_API_URL", "http://dsms-node:5001") -IPFS_GATEWAY_URL = os.getenv("IPFS_GATEWAY_URL", "http://dsms-node:8080") -JWT_SECRET = os.getenv("JWT_SECRET", "your-super-secret-jwt-key-change-in-production") - - -# Models -class DocumentMetadata(BaseModel): - """Metadaten für gespeicherte Dokumente""" - document_type: str # 'legal_document', 'consent_record', 'audit_log' - document_id: Optional[str] = None - version: Optional[str] = None - language: Optional[str] = "de" - created_at: Optional[str] = None - checksum: Optional[str] = None - encrypted: bool = False - - -class StoredDocument(BaseModel): - """Antwort nach erfolgreichem Speichern""" - cid: str # Content Identifier (IPFS Hash) - size: int - metadata: DocumentMetadata - gateway_url: str - timestamp: str - - -class DocumentList(BaseModel): - """Liste der gespeicherten Dokumente""" - documents: list - total: int - - -# Helper Functions -async def verify_token(authorization: Optional[str] = Header(None)) -> dict: - """Verifiziert JWT Token (vereinfacht für MVP)""" - if not authorization: - raise HTTPException(status_code=401, detail="Authorization header fehlt") - - # In Produktion: JWT validieren - # Für MVP: Einfache Token-Prüfung - if not authorization.startswith("Bearer "): - raise HTTPException(status_code=401, detail="Ungültiges Token-Format") - - return {"valid": True} - - -async def ipfs_add(content: bytes, pin: bool = True) -> dict: - """Fügt Inhalt zu IPFS hinzu""" - async with httpx.AsyncClient(timeout=60.0) as client: - files = {"file": ("document", content)} - params = {"pin": str(pin).lower()} - - response = await client.post( - f"{IPFS_API_URL}/api/v0/add", - files=files, - params=params - ) - - if response.status_code != 200: - raise HTTPException( - status_code=502, - detail=f"IPFS Fehler: {response.text}" - ) - - return response.json() - - -async def ipfs_cat(cid: str) -> bytes: - """Liest Inhalt von IPFS""" - async with httpx.AsyncClient(timeout=60.0) as client: - response = await client.post( - f"{IPFS_API_URL}/api/v0/cat", - params={"arg": cid} - ) - - if response.status_code != 200: - raise HTTPException( - status_code=404, - detail=f"Dokument nicht gefunden: {cid}" - ) - - return response.content - - -async def ipfs_pin_ls() -> list: - """Listet alle gepinnten Objekte""" - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.post( - f"{IPFS_API_URL}/api/v0/pin/ls", - params={"type": "recursive"} - ) - - if response.status_code != 200: - return [] - - data = response.json() - return list(data.get("Keys", {}).keys()) - - -# API Endpoints -@app.get("/health") -async def health_check(): - """Health Check für DSMS Gateway""" - try: - async with httpx.AsyncClient(timeout=5.0) as client: - response = await client.post(f"{IPFS_API_URL}/api/v0/id") - ipfs_status = response.status_code == 200 - except Exception: - ipfs_status = False - - return { - "status": "healthy" if ipfs_status else "degraded", - "ipfs_connected": ipfs_status, - "timestamp": datetime.utcnow().isoformat() - } - - -@app.post("/api/v1/documents", response_model=StoredDocument) -async def store_document( - file: UploadFile = File(...), - document_type: str = "legal_document", - document_id: Optional[str] = None, - version: Optional[str] = None, - language: str = "de", - _auth: dict = Depends(verify_token) -): - """ - Speichert ein Dokument im DSMS. - - - **file**: Das zu speichernde Dokument - - **document_type**: Typ des Dokuments (legal_document, consent_record, audit_log) - - **document_id**: Optionale ID des Dokuments - - **version**: Optionale Versionsnummer - - **language**: Sprache (default: de) - """ - content = await file.read() - - # Checksum berechnen - checksum = hashlib.sha256(content).hexdigest() - - # Metadaten erstellen - metadata = DocumentMetadata( - document_type=document_type, - document_id=document_id, - version=version, - language=language, - created_at=datetime.utcnow().isoformat(), - checksum=checksum, - encrypted=False - ) - - # Dokument mit Metadaten als JSON verpacken - package = { - "metadata": metadata.model_dump(), - "content_base64": content.hex(), # Hex-encodiert für JSON - "filename": file.filename - } - - package_bytes = json.dumps(package).encode() - - # Zu IPFS hinzufügen - result = await ipfs_add(package_bytes) - - cid = result.get("Hash") - size = int(result.get("Size", 0)) - - return StoredDocument( - cid=cid, - size=size, - metadata=metadata, - gateway_url=f"{IPFS_GATEWAY_URL}/ipfs/{cid}", - timestamp=datetime.utcnow().isoformat() - ) - - -@app.get("/api/v1/documents/{cid}") -async def get_document( - cid: str, - _auth: dict = Depends(verify_token) -): - """ - Ruft ein Dokument aus dem DSMS ab. - - - **cid**: Content Identifier (IPFS Hash) - """ - content = await ipfs_cat(cid) - - try: - package = json.loads(content) - metadata = package.get("metadata", {}) - original_content = bytes.fromhex(package.get("content_base64", "")) - filename = package.get("filename", "document") - - return StreamingResponse( - io.BytesIO(original_content), - media_type="application/octet-stream", - headers={ - "Content-Disposition": f'attachment; filename="{filename}"', - "X-DSMS-Document-Type": metadata.get("document_type", "unknown"), - "X-DSMS-Checksum": metadata.get("checksum", ""), - "X-DSMS-Created-At": metadata.get("created_at", "") - } - ) - except json.JSONDecodeError: - # Wenn es kein DSMS-Paket ist, gib rohen Inhalt zurück - return StreamingResponse( - io.BytesIO(content), - media_type="application/octet-stream" - ) - - -@app.get("/api/v1/documents/{cid}/metadata") -async def get_document_metadata( - cid: str, - _auth: dict = Depends(verify_token) -): - """ - Ruft nur die Metadaten eines Dokuments ab. - - - **cid**: Content Identifier (IPFS Hash) - """ - content = await ipfs_cat(cid) - - try: - package = json.loads(content) - return { - "cid": cid, - "metadata": package.get("metadata", {}), - "filename": package.get("filename"), - "size": len(bytes.fromhex(package.get("content_base64", ""))) - } - except json.JSONDecodeError: - return { - "cid": cid, - "metadata": {}, - "raw_size": len(content) - } - - -@app.get("/api/v1/documents", response_model=DocumentList) -async def list_documents( - _auth: dict = Depends(verify_token) -): - """ - Listet alle gespeicherten Dokumente auf. - """ - cids = await ipfs_pin_ls() - - documents = [] - for cid in cids[:100]: # Limit auf 100 für Performance - try: - content = await ipfs_cat(cid) - package = json.loads(content) - documents.append({ - "cid": cid, - "metadata": package.get("metadata", {}), - "filename": package.get("filename") - }) - except Exception: - # Überspringe nicht-DSMS Objekte - continue - - return DocumentList( - documents=documents, - total=len(documents) - ) - - -@app.delete("/api/v1/documents/{cid}") -async def unpin_document( - cid: str, - _auth: dict = Depends(verify_token) -): - """ - Entfernt ein Dokument aus dem lokalen Pin-Set. - Das Dokument bleibt im Netzwerk, wird aber bei GC entfernt. - - - **cid**: Content Identifier (IPFS Hash) - """ - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.post( - f"{IPFS_API_URL}/api/v0/pin/rm", - params={"arg": cid} - ) - - if response.status_code != 200: - raise HTTPException( - status_code=404, - detail=f"Konnte Pin nicht entfernen: {cid}" - ) - - return { - "status": "unpinned", - "cid": cid, - "message": "Dokument wird bei nächster Garbage Collection entfernt" - } - - -@app.post("/api/v1/legal-documents/archive") -async def archive_legal_document( - document_id: str, - version: str, - content: str, - language: str = "de", - _auth: dict = Depends(verify_token) -): - """ - Archiviert eine rechtliche Dokumentversion dauerhaft. - Speziell für AGB, Datenschutzerklärung, etc. - - - **document_id**: ID des Legal Documents - - **version**: Versionsnummer - - **content**: HTML/Markdown Inhalt - - **language**: Sprache - """ - # Checksum berechnen - content_bytes = content.encode('utf-8') - checksum = hashlib.sha256(content_bytes).hexdigest() - - # Metadaten - metadata = { - "document_type": "legal_document", - "document_id": document_id, - "version": version, - "language": language, - "created_at": datetime.utcnow().isoformat(), - "checksum": checksum, - "content_type": "text/html" - } - - # Paket erstellen - package = { - "metadata": metadata, - "content": content, - "archived_at": datetime.utcnow().isoformat() - } - - package_bytes = json.dumps(package, ensure_ascii=False).encode('utf-8') - - # Zu IPFS hinzufügen - result = await ipfs_add(package_bytes) - - cid = result.get("Hash") - - return { - "cid": cid, - "document_id": document_id, - "version": version, - "checksum": checksum, - "archived_at": datetime.utcnow().isoformat(), - "verification_url": f"{IPFS_GATEWAY_URL}/ipfs/{cid}" - } - - -@app.get("/api/v1/verify/{cid}") -async def verify_document(cid: str): - """ - Verifiziert die Integrität eines Dokuments. - Öffentlich zugänglich für Audit-Zwecke. - - - **cid**: Content Identifier (IPFS Hash) - """ - try: - content = await ipfs_cat(cid) - package = json.loads(content) - - # Checksum verifizieren - stored_checksum = package.get("metadata", {}).get("checksum") - - if "content_base64" in package: - original_content = bytes.fromhex(package["content_base64"]) - calculated_checksum = hashlib.sha256(original_content).hexdigest() - elif "content" in package: - calculated_checksum = hashlib.sha256( - package["content"].encode('utf-8') - ).hexdigest() - else: - calculated_checksum = None - - integrity_valid = ( - stored_checksum == calculated_checksum - if stored_checksum and calculated_checksum - else None - ) - - return { - "cid": cid, - "exists": True, - "integrity_valid": integrity_valid, - "metadata": package.get("metadata", {}), - "stored_checksum": stored_checksum, - "calculated_checksum": calculated_checksum, - "verified_at": datetime.utcnow().isoformat() - } - except Exception as e: - return { - "cid": cid, - "exists": False, - "error": str(e), - "verified_at": datetime.utcnow().isoformat() - } - - -@app.get("/api/v1/node/info") -async def get_node_info(): - """ - Gibt Informationen über den DSMS Node zurück. - """ - try: - async with httpx.AsyncClient(timeout=10.0) as client: - # Node ID - id_response = await client.post(f"{IPFS_API_URL}/api/v0/id") - node_info = id_response.json() if id_response.status_code == 200 else {} - - # Repo Stats - stat_response = await client.post(f"{IPFS_API_URL}/api/v0/repo/stat") - repo_stats = stat_response.json() if stat_response.status_code == 200 else {} - - return { - "node_id": node_info.get("ID"), - "protocol_version": node_info.get("ProtocolVersion"), - "agent_version": node_info.get("AgentVersion"), - "repo_size": repo_stats.get("RepoSize"), - "storage_max": repo_stats.get("StorageMax"), - "num_objects": repo_stats.get("NumObjects"), - "addresses": node_info.get("Addresses", [])[:5] # Erste 5 - } - except Exception as e: - return {"error": str(e)} +# Router registration +app.include_router(node_router) +app.include_router(documents_router) if __name__ == "__main__": diff --git a/dsms-gateway/models.py b/dsms-gateway/models.py new file mode 100644 index 0000000..e5ec1eb --- /dev/null +++ b/dsms-gateway/models.py @@ -0,0 +1,32 @@ +""" +DSMS Gateway — Pydantic request/response models. +""" + +from typing import Optional +from pydantic import BaseModel + + +class DocumentMetadata(BaseModel): + """Metadaten für gespeicherte Dokumente""" + document_type: str # 'legal_document', 'consent_record', 'audit_log' + document_id: Optional[str] = None + version: Optional[str] = None + language: Optional[str] = "de" + created_at: Optional[str] = None + checksum: Optional[str] = None + encrypted: bool = False + + +class StoredDocument(BaseModel): + """Antwort nach erfolgreichem Speichern""" + cid: str # Content Identifier (IPFS Hash) + size: int + metadata: DocumentMetadata + gateway_url: str + timestamp: str + + +class DocumentList(BaseModel): + """Liste der gespeicherten Dokumente""" + documents: list + total: int diff --git a/dsms-gateway/routers/__init__.py b/dsms-gateway/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dsms-gateway/routers/documents.py b/dsms-gateway/routers/documents.py new file mode 100644 index 0000000..b45ffa5 --- /dev/null +++ b/dsms-gateway/routers/documents.py @@ -0,0 +1,256 @@ +""" +Documents router — handles /api/v1/documents and /api/v1/legal-documents endpoints. +""" + +import hashlib +import json +import io +from datetime import datetime +from typing import Optional + +import httpx +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from fastapi.responses import StreamingResponse + +from models import DocumentList, DocumentMetadata, StoredDocument +from dependencies import verify_token, ipfs_add, ipfs_cat, ipfs_pin_ls +from config import IPFS_API_URL, IPFS_GATEWAY_URL + +router = APIRouter() + + +@router.post("/api/v1/documents", response_model=StoredDocument) +async def store_document( + file: UploadFile = File(...), + document_type: str = "legal_document", + document_id: Optional[str] = None, + version: Optional[str] = None, + language: str = "de", + _auth: dict = Depends(verify_token) +): + """ + Speichert ein Dokument im DSMS. + + - **file**: Das zu speichernde Dokument + - **document_type**: Typ des Dokuments (legal_document, consent_record, audit_log) + - **document_id**: Optionale ID des Dokuments + - **version**: Optionale Versionsnummer + - **language**: Sprache (default: de) + """ + content = await file.read() + + # Checksum berechnen + checksum = hashlib.sha256(content).hexdigest() + + # Metadaten erstellen + metadata = DocumentMetadata( + document_type=document_type, + document_id=document_id, + version=version, + language=language, + created_at=datetime.utcnow().isoformat(), + checksum=checksum, + encrypted=False + ) + + # Dokument mit Metadaten als JSON verpacken + package = { + "metadata": metadata.model_dump(), + "content_base64": content.hex(), # Hex-encodiert für JSON + "filename": file.filename + } + + package_bytes = json.dumps(package).encode() + + # Zu IPFS hinzufügen + result = await ipfs_add(package_bytes) + + cid = result.get("Hash") + size = int(result.get("Size", 0)) + + return StoredDocument( + cid=cid, + size=size, + metadata=metadata, + gateway_url=f"{IPFS_GATEWAY_URL}/ipfs/{cid}", + timestamp=datetime.utcnow().isoformat() + ) + + +@router.get("/api/v1/documents/{cid}") +async def get_document( + cid: str, + _auth: dict = Depends(verify_token) +): + """ + Ruft ein Dokument aus dem DSMS ab. + + - **cid**: Content Identifier (IPFS Hash) + """ + content = await ipfs_cat(cid) + + try: + package = json.loads(content) + metadata = package.get("metadata", {}) + original_content = bytes.fromhex(package.get("content_base64", "")) + filename = package.get("filename", "document") + + return StreamingResponse( + io.BytesIO(original_content), + media_type="application/octet-stream", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "X-DSMS-Document-Type": metadata.get("document_type", "unknown"), + "X-DSMS-Checksum": metadata.get("checksum", ""), + "X-DSMS-Created-At": metadata.get("created_at", "") + } + ) + except json.JSONDecodeError: + # Wenn es kein DSMS-Paket ist, gib rohen Inhalt zurück + return StreamingResponse( + io.BytesIO(content), + media_type="application/octet-stream" + ) + + +@router.get("/api/v1/documents/{cid}/metadata") +async def get_document_metadata( + cid: str, + _auth: dict = Depends(verify_token) +): + """ + Ruft nur die Metadaten eines Dokuments ab. + + - **cid**: Content Identifier (IPFS Hash) + """ + content = await ipfs_cat(cid) + + try: + package = json.loads(content) + return { + "cid": cid, + "metadata": package.get("metadata", {}), + "filename": package.get("filename"), + "size": len(bytes.fromhex(package.get("content_base64", ""))) + } + except json.JSONDecodeError: + return { + "cid": cid, + "metadata": {}, + "raw_size": len(content) + } + + +@router.get("/api/v1/documents", response_model=DocumentList) +async def list_documents( + _auth: dict = Depends(verify_token) +): + """ + Listet alle gespeicherten Dokumente auf. + """ + cids = await ipfs_pin_ls() + + documents = [] + for cid in cids[:100]: # Limit auf 100 für Performance + try: + content = await ipfs_cat(cid) + package = json.loads(content) + documents.append({ + "cid": cid, + "metadata": package.get("metadata", {}), + "filename": package.get("filename") + }) + except Exception: + # Überspringe nicht-DSMS Objekte + continue + + return DocumentList( + documents=documents, + total=len(documents) + ) + + +@router.delete("/api/v1/documents/{cid}") +async def unpin_document( + cid: str, + _auth: dict = Depends(verify_token) +): + """ + Entfernt ein Dokument aus dem lokalen Pin-Set. + Das Dokument bleibt im Netzwerk, wird aber bei GC entfernt. + + - **cid**: Content Identifier (IPFS Hash) + """ + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{IPFS_API_URL}/api/v0/pin/rm", + params={"arg": cid} + ) + + if response.status_code != 200: + raise HTTPException( + status_code=404, + detail=f"Konnte Pin nicht entfernen: {cid}" + ) + + return { + "status": "unpinned", + "cid": cid, + "message": "Dokument wird bei nächster Garbage Collection entfernt" + } + + +@router.post("/api/v1/legal-documents/archive") +async def archive_legal_document( + document_id: str, + version: str, + content: str, + language: str = "de", + _auth: dict = Depends(verify_token) +): + """ + Archiviert eine rechtliche Dokumentversion dauerhaft. + Speziell für AGB, Datenschutzerklärung, etc. + + - **document_id**: ID des Legal Documents + - **version**: Versionsnummer + - **content**: HTML/Markdown Inhalt + - **language**: Sprache + """ + # Checksum berechnen + content_bytes = content.encode('utf-8') + checksum = hashlib.sha256(content_bytes).hexdigest() + + # Metadaten + metadata = { + "document_type": "legal_document", + "document_id": document_id, + "version": version, + "language": language, + "created_at": datetime.utcnow().isoformat(), + "checksum": checksum, + "content_type": "text/html" + } + + # Paket erstellen + package = { + "metadata": metadata, + "content": content, + "archived_at": datetime.utcnow().isoformat() + } + + package_bytes = json.dumps(package, ensure_ascii=False).encode('utf-8') + + # Zu IPFS hinzufügen + result = await ipfs_add(package_bytes) + + cid = result.get("Hash") + + return { + "cid": cid, + "document_id": document_id, + "version": version, + "checksum": checksum, + "archived_at": datetime.utcnow().isoformat(), + "verification_url": f"{IPFS_GATEWAY_URL}/ipfs/{cid}" + } diff --git a/dsms-gateway/routers/node.py b/dsms-gateway/routers/node.py new file mode 100644 index 0000000..21883e9 --- /dev/null +++ b/dsms-gateway/routers/node.py @@ -0,0 +1,109 @@ +""" +Node router — handles /health, /api/v1/verify/{cid}, and /api/v1/node/info endpoints. +""" + +import hashlib +import json +from datetime import datetime + +import httpx +from fastapi import APIRouter + +from dependencies import ipfs_cat +from config import IPFS_API_URL + +router = APIRouter() + + +@router.get("/health") +async def health_check(): + """Health Check für DSMS Gateway""" + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.post(f"{IPFS_API_URL}/api/v0/id") + ipfs_status = response.status_code == 200 + except Exception: + ipfs_status = False + + return { + "status": "healthy" if ipfs_status else "degraded", + "ipfs_connected": ipfs_status, + "timestamp": datetime.utcnow().isoformat() + } + + +@router.get("/api/v1/verify/{cid}") +async def verify_document(cid: str): + """ + Verifiziert die Integrität eines Dokuments. + Öffentlich zugänglich für Audit-Zwecke. + + - **cid**: Content Identifier (IPFS Hash) + """ + try: + content = await ipfs_cat(cid) + package = json.loads(content) + + # Checksum verifizieren + stored_checksum = package.get("metadata", {}).get("checksum") + + if "content_base64" in package: + original_content = bytes.fromhex(package["content_base64"]) + calculated_checksum = hashlib.sha256(original_content).hexdigest() + elif "content" in package: + calculated_checksum = hashlib.sha256( + package["content"].encode('utf-8') + ).hexdigest() + else: + calculated_checksum = None + + integrity_valid = ( + stored_checksum == calculated_checksum + if stored_checksum and calculated_checksum + else None + ) + + return { + "cid": cid, + "exists": True, + "integrity_valid": integrity_valid, + "metadata": package.get("metadata", {}), + "stored_checksum": stored_checksum, + "calculated_checksum": calculated_checksum, + "verified_at": datetime.utcnow().isoformat() + } + except Exception as e: + return { + "cid": cid, + "exists": False, + "error": str(e), + "verified_at": datetime.utcnow().isoformat() + } + + +@router.get("/api/v1/node/info") +async def get_node_info(): + """ + Gibt Informationen über den DSMS Node zurück. + """ + try: + async with httpx.AsyncClient(timeout=10.0) as client: + # Node ID + id_response = await client.post(f"{IPFS_API_URL}/api/v0/id") + node_info = id_response.json() if id_response.status_code == 200 else {} + + # Repo Stats + stat_response = await client.post(f"{IPFS_API_URL}/api/v0/repo/stat") + repo_stats = stat_response.json() if stat_response.status_code == 200 else {} + + return { + "node_id": node_info.get("ID"), + "protocol_version": node_info.get("ProtocolVersion"), + "agent_version": node_info.get("AgentVersion"), + "repo_size": repo_stats.get("RepoSize"), + "storage_max": repo_stats.get("StorageMax"), + "num_objects": repo_stats.get("NumObjects"), + "addresses": node_info.get("Addresses", [])[:5] # Erste 5 + } + except Exception as e: + return {"error": str(e)} diff --git a/dsms-gateway/test_main.py b/dsms-gateway/test_main.py index 8a40705..bde31b7 100644 --- a/dsms-gateway/test_main.py +++ b/dsms-gateway/test_main.py @@ -56,7 +56,7 @@ class TestHealthCheck: def test_health_check_ipfs_connected(self): """Test: Health Check wenn IPFS verbunden ist""" - with patch("main.httpx.AsyncClient") as mock_client: + with patch("routers.node.httpx.AsyncClient") as mock_client: mock_instance = AsyncMock() mock_instance.post.return_value = MagicMock(status_code=200) mock_client.return_value.__aenter__.return_value = mock_instance @@ -71,7 +71,7 @@ class TestHealthCheck: def test_health_check_ipfs_disconnected(self): """Test: Health Check wenn IPFS nicht erreichbar""" - with patch("main.httpx.AsyncClient") as mock_client: + with patch("routers.node.httpx.AsyncClient") as mock_client: mock_instance = AsyncMock() mock_instance.post.side_effect = Exception("Connection failed") mock_client.return_value.__aenter__.return_value = mock_instance @@ -104,7 +104,7 @@ class TestAuthorization: def test_documents_endpoint_with_valid_token_format(self, valid_auth_header): """Test: Gültiges Token-Format wird akzeptiert""" - with patch("main.ipfs_pin_ls", new_callable=AsyncMock) as mock_pin_ls: + with patch("routers.documents.ipfs_pin_ls", new_callable=AsyncMock) as mock_pin_ls: mock_pin_ls.return_value = [] response = client.get( @@ -122,7 +122,7 @@ class TestDocumentStorage: def test_store_document_success(self, valid_auth_header, mock_ipfs_response): """Test: Dokument erfolgreich speichern""" - with patch("main.ipfs_add", new_callable=AsyncMock) as mock_add: + with patch("routers.documents.ipfs_add", new_callable=AsyncMock) as mock_add: mock_add.return_value = mock_ipfs_response test_content = b"Test document content" @@ -148,7 +148,7 @@ class TestDocumentStorage: def test_store_document_calculates_checksum(self, valid_auth_header, mock_ipfs_response): """Test: Checksum wird korrekt berechnet""" - with patch("main.ipfs_add", new_callable=AsyncMock) as mock_add: + with patch("routers.documents.ipfs_add", new_callable=AsyncMock) as mock_add: mock_add.return_value = mock_ipfs_response test_content = b"Test content for checksum" @@ -191,7 +191,7 @@ class TestDocumentRetrieval: "filename": "test.txt" } - with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + with patch("routers.documents.ipfs_cat", new_callable=AsyncMock) as mock_cat: mock_cat.return_value = json.dumps(package).encode() response = client.get( @@ -204,7 +204,7 @@ class TestDocumentRetrieval: def test_get_document_not_found(self, valid_auth_header): """Test: Nicht existierendes Dokument gibt 404 zurück""" - with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + with patch("routers.documents.ipfs_cat", new_callable=AsyncMock) as mock_cat: from fastapi import HTTPException mock_cat.side_effect = HTTPException(status_code=404, detail="Not found") @@ -228,7 +228,7 @@ class TestDocumentRetrieval: "filename": "test.txt" } - with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + with patch("routers.documents.ipfs_cat", new_callable=AsyncMock) as mock_cat: mock_cat.return_value = json.dumps(package).encode() response = client.get( @@ -249,7 +249,7 @@ class TestDocumentList: def test_list_documents_empty(self, valid_auth_header): """Test: Leere Dokumentenliste""" - with patch("main.ipfs_pin_ls", new_callable=AsyncMock) as mock_pin_ls: + with patch("routers.documents.ipfs_pin_ls", new_callable=AsyncMock) as mock_pin_ls: mock_pin_ls.return_value = [] response = client.get( @@ -270,10 +270,10 @@ class TestDocumentList: "filename": "test.txt" } - with patch("main.ipfs_pin_ls", new_callable=AsyncMock) as mock_pin_ls: + with patch("routers.documents.ipfs_pin_ls", new_callable=AsyncMock) as mock_pin_ls: mock_pin_ls.return_value = ["QmCid1", "QmCid2"] - with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + with patch("routers.documents.ipfs_cat", new_callable=AsyncMock) as mock_cat: mock_cat.return_value = json.dumps(package).encode() response = client.get( @@ -293,7 +293,7 @@ class TestDocumentDeletion: def test_unpin_document_success(self, valid_auth_header): """Test: Dokument erfolgreich unpinnen""" - with patch("main.httpx.AsyncClient") as mock_client: + with patch("routers.documents.httpx.AsyncClient") as mock_client: mock_instance = AsyncMock() mock_instance.post.return_value = MagicMock(status_code=200) mock_client.return_value.__aenter__.return_value = mock_instance @@ -310,7 +310,7 @@ class TestDocumentDeletion: def test_unpin_document_not_found(self, valid_auth_header): """Test: Nicht existierendes Dokument unpinnen""" - with patch("main.httpx.AsyncClient") as mock_client: + with patch("routers.documents.httpx.AsyncClient") as mock_client: mock_instance = AsyncMock() mock_instance.post.return_value = MagicMock(status_code=404) mock_client.return_value.__aenter__.return_value = mock_instance @@ -330,7 +330,7 @@ class TestLegalDocumentArchive: def test_archive_legal_document_success(self, valid_auth_header, mock_ipfs_response): """Test: Legal Document erfolgreich archivieren""" - with patch("main.ipfs_add", new_callable=AsyncMock) as mock_add: + with patch("routers.documents.ipfs_add", new_callable=AsyncMock) as mock_add: mock_add.return_value = mock_ipfs_response response = client.post( @@ -357,7 +357,7 @@ class TestLegalDocumentArchive: content = "

Test Content

" expected_checksum = hashlib.sha256(content.encode('utf-8')).hexdigest() - with patch("main.ipfs_add", new_callable=AsyncMock) as mock_add: + with patch("routers.documents.ipfs_add", new_callable=AsyncMock) as mock_add: mock_add.return_value = mock_ipfs_response response = client.post( @@ -393,7 +393,7 @@ class TestDocumentVerification: "content": content } - with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + with patch("routers.node.ipfs_cat", new_callable=AsyncMock) as mock_cat: mock_cat.return_value = json.dumps(package).encode() response = client.get("/api/v1/verify/QmTestCid123") @@ -415,7 +415,7 @@ class TestDocumentVerification: "content": "Actual content" } - with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + with patch("routers.node.ipfs_cat", new_callable=AsyncMock) as mock_cat: mock_cat.return_value = json.dumps(package).encode() response = client.get("/api/v1/verify/QmTestCid123") @@ -427,7 +427,7 @@ class TestDocumentVerification: def test_verify_document_not_found(self): """Test: Nicht existierendes Dokument verifizieren""" - with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + with patch("routers.node.ipfs_cat", new_callable=AsyncMock) as mock_cat: mock_cat.side_effect = Exception("Not found") response = client.get("/api/v1/verify/QmNonExistent") @@ -444,7 +444,7 @@ class TestDocumentVerification: "content": "test" } - with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + with patch("routers.node.ipfs_cat", new_callable=AsyncMock) as mock_cat: mock_cat.return_value = json.dumps(package).encode() # Kein Authorization Header! @@ -472,7 +472,7 @@ class TestNodeInfo: "NumObjects": 42 } - with patch("main.httpx.AsyncClient") as mock_client: + with patch("routers.node.httpx.AsyncClient") as mock_client: mock_instance = AsyncMock() async def mock_post(url, **kwargs): @@ -497,7 +497,7 @@ class TestNodeInfo: def test_get_node_info_public_access(self): """Test: Node-Info ist öffentlich zugänglich""" - with patch("main.httpx.AsyncClient") as mock_client: + with patch("routers.node.httpx.AsyncClient") as mock_client: mock_instance = AsyncMock() mock_instance.post.return_value = MagicMock( status_code=200,