refactor(consent-sdk,dsms-gateway): split ConsentManager, types, and main.py

- consent-sdk/src/types/index.ts: extracted 438 LOC into core.ts, config.ts,
  vendor.ts, api.ts, events.ts, storage.ts, translations.ts; index.ts is now
  a 21-LOC barrel re-exporter
- consent-sdk/src/core/ConsentManager.ts: extracted normalizeConsentInput,
  isConsentExpired, needsConsent, ALL_CATEGORIES, MINIMAL_CATEGORIES into
  consent-manager-helpers.ts; reduced from 467 to 345 LOC
- dsms-gateway/main.py: extracted models → models.py, config → config.py,
  IPFS helpers + verify_token → dependencies.py, route handlers →
  routers/documents.py and routers/node.py; main.py is now a 41-LOC app
  factory; test mock paths updated accordingly (27/27 tests pass)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-04-18 08:42:32 +02:00
parent 9ecd3b2d84
commit a7fe32fb82
18 changed files with 1115 additions and 1042 deletions

View File

@@ -1,14 +1,9 @@
/** /** ConsentManager - DSGVO/TTDSG-konformes Consent Management fuer Web, PWA und Mobile. */
* ConsentManager - Hauptklasse fuer das Consent Management
*
* DSGVO/TTDSG-konformes Consent Management fuer Web, PWA und Mobile.
*/
import type { import type {
ConsentConfig, ConsentConfig,
ConsentState, ConsentState,
ConsentCategory, ConsentCategory,
ConsentCategories,
ConsentInput, ConsentInput,
ConsentEventType, ConsentEventType,
ConsentEventCallback, ConsentEventCallback,
@@ -20,15 +15,16 @@ import { ConsentAPI } from './ConsentAPI';
import { EventEmitter } from '../utils/EventEmitter'; import { EventEmitter } from '../utils/EventEmitter';
import { generateFingerprint } from '../utils/fingerprint'; import { generateFingerprint } from '../utils/fingerprint';
import { SDK_VERSION } from '../version'; import { SDK_VERSION } from '../version';
import { import { mergeConsentConfig } from './consent-manager-config';
DEFAULT_CONSENT,
mergeConsentConfig,
} from './consent-manager-config';
import { updateGoogleConsentMode as applyGoogleConsent } from './consent-manager-google'; 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 { export class ConsentManager {
private config: ConsentConfig; private config: ConsentConfig;
private storage: ConsentStorage; private storage: ConsentStorage;
@@ -41,7 +37,7 @@ export class ConsentManager {
private deviceFingerprint: string = ''; private deviceFingerprint: string = '';
constructor(config: ConsentConfig) { constructor(config: ConsentConfig) {
this.config = this.mergeConfig(config); this.config = mergeConsentConfig(config);
this.storage = new ConsentStorage(this.config); this.storage = new ConsentStorage(this.config);
this.scriptBlocker = new ScriptBlocker(this.config); this.scriptBlocker = new ScriptBlocker(this.config);
this.api = new ConsentAPI(this.config); this.api = new ConsentAPI(this.config);
@@ -72,7 +68,7 @@ export class ConsentManager {
this.log('Loaded consent from storage:', this.currentConsent); this.log('Loaded consent from storage:', this.currentConsent);
// Pruefen ob Consent abgelaufen // Pruefen ob Consent abgelaufen
if (this.isConsentExpired()) { if (isConsentExpired(this.currentConsent, this.config)) {
this.log('Consent expired, clearing'); this.log('Consent expired, clearing');
this.storage.clear(); this.storage.clear();
this.currentConsent = null; this.currentConsent = null;
@@ -89,7 +85,7 @@ export class ConsentManager {
this.emit('init', this.currentConsent); this.emit('init', this.currentConsent);
// Banner anzeigen falls noetig // Banner anzeigen falls noetig
if (this.needsConsent()) { if (needsConsent(this.currentConsent, this.config)) {
this.showBanner(); this.showBanner();
} }
@@ -100,9 +96,7 @@ export class ConsentManager {
} }
} }
// =========================================================================== // --- Public API ---
// Public API
// ===========================================================================
/** /**
* Pruefen ob Consent fuer Kategorie vorhanden * Pruefen ob Consent fuer Kategorie vorhanden
@@ -135,7 +129,7 @@ export class ConsentManager {
* Consent setzen * Consent setzen
*/ */
async setConsent(input: ConsentInput): Promise<void> { async setConsent(input: ConsentInput): Promise<void> {
const categories = this.normalizeConsentInput(input); const categories = normalizeConsentInput(input);
// Essential ist immer aktiv // Essential ist immer aktiv
categories.essential = true; categories.essential = true;
@@ -184,15 +178,7 @@ export class ConsentManager {
* Alle Kategorien akzeptieren * Alle Kategorien akzeptieren
*/ */
async acceptAll(): Promise<void> { async acceptAll(): Promise<void> {
const allCategories: ConsentCategories = { await this.setConsent(ALL_CATEGORIES);
essential: true,
functional: true,
analytics: true,
marketing: true,
social: true,
};
await this.setConsent(allCategories);
this.emit('accept_all', this.currentConsent!); this.emit('accept_all', this.currentConsent!);
this.hideBanner(); this.hideBanner();
} }
@@ -201,15 +187,7 @@ export class ConsentManager {
* Alle nicht-essentiellen Kategorien ablehnen * Alle nicht-essentiellen Kategorien ablehnen
*/ */
async rejectAll(): Promise<void> { async rejectAll(): Promise<void> {
const minimalCategories: ConsentCategories = { await this.setConsent(MINIMAL_CATEGORIES);
essential: true,
functional: false,
analytics: false,
marketing: false,
social: false,
};
await this.setConsent(minimalCategories);
this.emit('reject_all', this.currentConsent!); this.emit('reject_all', this.currentConsent!);
this.hideBanner(); this.hideBanner();
} }
@@ -247,52 +225,23 @@ export class ConsentManager {
return JSON.stringify(exportData, null, 2); return JSON.stringify(exportData, null, 2);
} }
// =========================================================================== // --- Banner Control ---
// Banner Control
// ===========================================================================
/** /**
* Pruefen ob Consent-Abfrage noetig * Pruefen ob Consent-Abfrage noetig
*/ */
needsConsent(): boolean { needsConsent(): boolean {
if (!this.currentConsent) { return needsConsent(this.currentConsent, this.config);
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;
} }
/** /**
* Banner anzeigen * Banner anzeigen
*/ */
showBanner(): void { showBanner(): void {
if (this.bannerVisible) { if (this.bannerVisible) return;
return;
}
this.bannerVisible = true; this.bannerVisible = true;
this.emit('banner_show', undefined); this.emit('banner_show', undefined);
this.config.onBannerShow?.(); this.config.onBannerShow?.();
// Banner wird von UI-Komponente gerendert
// Hier nur Status setzen
this.log('Banner shown'); this.log('Banner shown');
} }
@@ -300,14 +249,10 @@ export class ConsentManager {
* Banner verstecken * Banner verstecken
*/ */
hideBanner(): void { hideBanner(): void {
if (!this.bannerVisible) { if (!this.bannerVisible) return;
return;
}
this.bannerVisible = false; this.bannerVisible = false;
this.emit('banner_hide', undefined); this.emit('banner_hide', undefined);
this.config.onBannerHide?.(); this.config.onBannerHide?.();
this.log('Banner hidden'); this.log('Banner hidden');
} }
@@ -326,9 +271,7 @@ export class ConsentManager {
return this.bannerVisible; return this.bannerVisible;
} }
// =========================================================================== // --- Event Handling ---
// Event Handling
// ===========================================================================
/** /**
* Event-Listener registrieren * Event-Listener registrieren
@@ -350,35 +293,13 @@ export class ConsentManager {
this.events.off(event, callback); this.events.off(event, callback);
} }
// =========================================================================== // --- Internal Methods ---
// 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<ConsentCategories>) };
}
/** /**
* Consent anwenden (Skripte aktivieren/blockieren) * Consent anwenden (Skripte aktivieren/blockieren)
*/ */
private applyConsent(): void { private applyConsent(): void {
if (!this.currentConsent) { if (!this.currentConsent) return;
return;
}
for (const [category, allowed] of Object.entries( for (const [category, allowed] of Object.entries(
this.currentConsent.categories this.currentConsent.categories
@@ -391,69 +312,26 @@ export class ConsentManager {
} }
// Google Consent Mode aktualisieren // Google Consent Mode aktualisieren
this.updateGoogleConsentMode();
}
/**
* Google Consent Mode v2 aktualisieren — delegates to the extracted helper.
*/
private updateGoogleConsentMode(): void {
if (applyGoogleConsent(this.currentConsent)) { if (applyGoogleConsent(this.currentConsent)) {
this.log('Google Consent Mode updated'); this.log('Google Consent Mode updated');
} }
} }
/** private emit<T extends ConsentEventType>(event: T, data: ConsentEventData[T]): void {
* 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<T extends ConsentEventType>(
event: T,
data: ConsentEventData[T]
): void {
this.events.emit(event, data); this.events.emit(event, data);
} }
/**
* Fehler behandeln
*/
private handleError(error: Error): void { private handleError(error: Error): void {
this.log('Error:', error); this.log('Error:', error);
this.emit('error', error); this.emit('error', error);
this.config.onError?.(error); this.config.onError?.(error);
} }
/**
* Debug-Logging
*/
private log(...args: unknown[]): void { private log(...args: unknown[]): void {
if (this.config.debug) { if (this.config.debug) console.log('[ConsentSDK]', ...args);
console.log('[ConsentSDK]', ...args);
}
} }
// =========================================================================== // --- Static Methods ---
// Static Methods
// ===========================================================================
/** /**
* SDK-Version abrufen * SDK-Version abrufen

View File

@@ -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<ConsentCategories>) };
}
/**
* 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;
}

View File

@@ -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<string, string>;
description: Record<string, string>;
required: boolean;
vendors: ConsentVendor[];
}
/**
* Rechtliche Konfiguration
*/
export interface LegalConfig {
privacyPolicyUrl: string;
imprintUrl: string;
dpo?: {
name: string;
email: string;
};
}

View File

@@ -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;
}

View File

@@ -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<ConsentCategory, boolean>;
/**
* Consent-Status pro Vendor
*/
export type ConsentVendors = Record<string, boolean>;
// =============================================================================
// 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<ConsentCategories> | {
categories?: Partial<ConsentCategories>;
vendors?: ConsentVendors;
};

View File

@@ -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<T = unknown> = (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;
};

View File

@@ -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
*/ */
// ============================================================================= export * from './core';
// Consent Categories export * from './config';
// ============================================================================= export * from './vendor';
export * from './api';
/** export * from './events';
* Standard-Consent-Kategorien nach IAB TCF 2.2 export * from './storage';
*/ export * from './translations';
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<ConsentCategory, boolean>;
/**
* Consent-Status pro Vendor
*/
export type ConsentVendors = Record<string, boolean>;
// =============================================================================
// 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<ConsentCategories> | {
categories?: Partial<ConsentCategories>;
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<string, string>;
description: Record<string, string>;
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<T = unknown> = (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';

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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;
}

9
dsms-gateway/config.py Normal file
View File

@@ -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")

View File

@@ -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())

View File

@@ -3,17 +3,18 @@ DSMS Gateway - REST API für dezentrales Speichersystem
Bietet eine vereinfachte API über IPFS für BreakPilot Bietet eine vereinfachte API über IPFS für BreakPilot
""" """
import sys
import os import os
import json
import httpx # Ensure the gateway directory itself is on the path so routers can use flat imports.
import hashlib sys.path.insert(0, os.path.dirname(__file__))
from datetime import datetime
from typing import Optional from fastapi import FastAPI
from fastapi import FastAPI, HTTPException, UploadFile, File, Header, Depends
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel from models import DocumentMetadata, StoredDocument, DocumentList # noqa: F401 — re-exported for tests
import io from routers.documents import router as documents_router
from routers.node import router as node_router
app = FastAPI( app = FastAPI(
title="DSMS Gateway", title="DSMS Gateway",
@@ -30,436 +31,9 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# Configuration # Router registration
IPFS_API_URL = os.getenv("IPFS_API_URL", "http://dsms-node:5001") app.include_router(node_router)
IPFS_GATEWAY_URL = os.getenv("IPFS_GATEWAY_URL", "http://dsms-node:8080") app.include_router(documents_router)
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)}
if __name__ == "__main__": if __name__ == "__main__":

32
dsms-gateway/models.py Normal file
View File

@@ -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

View File

View File

@@ -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}"
}

View File

@@ -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)}

View File

@@ -56,7 +56,7 @@ class TestHealthCheck:
def test_health_check_ipfs_connected(self): def test_health_check_ipfs_connected(self):
"""Test: Health Check wenn IPFS verbunden ist""" """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 = AsyncMock()
mock_instance.post.return_value = MagicMock(status_code=200) mock_instance.post.return_value = MagicMock(status_code=200)
mock_client.return_value.__aenter__.return_value = mock_instance mock_client.return_value.__aenter__.return_value = mock_instance
@@ -71,7 +71,7 @@ class TestHealthCheck:
def test_health_check_ipfs_disconnected(self): def test_health_check_ipfs_disconnected(self):
"""Test: Health Check wenn IPFS nicht erreichbar""" """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 = AsyncMock()
mock_instance.post.side_effect = Exception("Connection failed") mock_instance.post.side_effect = Exception("Connection failed")
mock_client.return_value.__aenter__.return_value = mock_instance 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): def test_documents_endpoint_with_valid_token_format(self, valid_auth_header):
"""Test: Gültiges Token-Format wird akzeptiert""" """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 = [] mock_pin_ls.return_value = []
response = client.get( response = client.get(
@@ -122,7 +122,7 @@ class TestDocumentStorage:
def test_store_document_success(self, valid_auth_header, mock_ipfs_response): def test_store_document_success(self, valid_auth_header, mock_ipfs_response):
"""Test: Dokument erfolgreich speichern""" """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 mock_add.return_value = mock_ipfs_response
test_content = b"Test document content" 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): def test_store_document_calculates_checksum(self, valid_auth_header, mock_ipfs_response):
"""Test: Checksum wird korrekt berechnet""" """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 mock_add.return_value = mock_ipfs_response
test_content = b"Test content for checksum" test_content = b"Test content for checksum"
@@ -191,7 +191,7 @@ class TestDocumentRetrieval:
"filename": "test.txt" "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() mock_cat.return_value = json.dumps(package).encode()
response = client.get( response = client.get(
@@ -204,7 +204,7 @@ class TestDocumentRetrieval:
def test_get_document_not_found(self, valid_auth_header): def test_get_document_not_found(self, valid_auth_header):
"""Test: Nicht existierendes Dokument gibt 404 zurück""" """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 from fastapi import HTTPException
mock_cat.side_effect = HTTPException(status_code=404, detail="Not found") mock_cat.side_effect = HTTPException(status_code=404, detail="Not found")
@@ -228,7 +228,7 @@ class TestDocumentRetrieval:
"filename": "test.txt" "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() mock_cat.return_value = json.dumps(package).encode()
response = client.get( response = client.get(
@@ -249,7 +249,7 @@ class TestDocumentList:
def test_list_documents_empty(self, valid_auth_header): def test_list_documents_empty(self, valid_auth_header):
"""Test: Leere Dokumentenliste""" """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 = [] mock_pin_ls.return_value = []
response = client.get( response = client.get(
@@ -270,10 +270,10 @@ class TestDocumentList:
"filename": "test.txt" "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"] 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() mock_cat.return_value = json.dumps(package).encode()
response = client.get( response = client.get(
@@ -293,7 +293,7 @@ class TestDocumentDeletion:
def test_unpin_document_success(self, valid_auth_header): def test_unpin_document_success(self, valid_auth_header):
"""Test: Dokument erfolgreich unpinnen""" """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 = AsyncMock()
mock_instance.post.return_value = MagicMock(status_code=200) mock_instance.post.return_value = MagicMock(status_code=200)
mock_client.return_value.__aenter__.return_value = mock_instance 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): def test_unpin_document_not_found(self, valid_auth_header):
"""Test: Nicht existierendes Dokument unpinnen""" """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 = AsyncMock()
mock_instance.post.return_value = MagicMock(status_code=404) mock_instance.post.return_value = MagicMock(status_code=404)
mock_client.return_value.__aenter__.return_value = mock_instance 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): def test_archive_legal_document_success(self, valid_auth_header, mock_ipfs_response):
"""Test: Legal Document erfolgreich archivieren""" """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 mock_add.return_value = mock_ipfs_response
response = client.post( response = client.post(
@@ -357,7 +357,7 @@ class TestLegalDocumentArchive:
content = "<h1>Test Content</h1>" content = "<h1>Test Content</h1>"
expected_checksum = hashlib.sha256(content.encode('utf-8')).hexdigest() 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 mock_add.return_value = mock_ipfs_response
response = client.post( response = client.post(
@@ -393,7 +393,7 @@ class TestDocumentVerification:
"content": content "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() mock_cat.return_value = json.dumps(package).encode()
response = client.get("/api/v1/verify/QmTestCid123") response = client.get("/api/v1/verify/QmTestCid123")
@@ -415,7 +415,7 @@ class TestDocumentVerification:
"content": "Actual content" "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() mock_cat.return_value = json.dumps(package).encode()
response = client.get("/api/v1/verify/QmTestCid123") response = client.get("/api/v1/verify/QmTestCid123")
@@ -427,7 +427,7 @@ class TestDocumentVerification:
def test_verify_document_not_found(self): def test_verify_document_not_found(self):
"""Test: Nicht existierendes Dokument verifizieren""" """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") mock_cat.side_effect = Exception("Not found")
response = client.get("/api/v1/verify/QmNonExistent") response = client.get("/api/v1/verify/QmNonExistent")
@@ -444,7 +444,7 @@ class TestDocumentVerification:
"content": "test" "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() mock_cat.return_value = json.dumps(package).encode()
# Kein Authorization Header! # Kein Authorization Header!
@@ -472,7 +472,7 @@ class TestNodeInfo:
"NumObjects": 42 "NumObjects": 42
} }
with patch("main.httpx.AsyncClient") as mock_client: with patch("routers.node.httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock() mock_instance = AsyncMock()
async def mock_post(url, **kwargs): async def mock_post(url, **kwargs):
@@ -497,7 +497,7 @@ class TestNodeInfo:
def test_get_node_info_public_access(self): def test_get_node_info_public_access(self):
"""Test: Node-Info ist öffentlich zugänglich""" """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 = AsyncMock()
mock_instance.post.return_value = MagicMock( mock_instance.post.return_value = MagicMock(
status_code=200, status_code=200,