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 - 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<void> {
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<void> {
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<void> {
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<ConsentCategories>) };
}
// --- 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<T extends ConsentEventType>(
event: T,
data: ConsentEventData[T]
): void {
private emit<T extends ConsentEventType>(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

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
*/
// =============================================================================
// 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;
};
// =============================================================================
// 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';
export * from './core';
export * from './config';
export * from './vendor';
export * from './api';
export * from './events';
export * from './storage';
export * from './translations';

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
"""
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__":

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):
"""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 = "<h1>Test Content</h1>"
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,