diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..07d8b9a --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Dependencies +node_modules/ +frontend/node_modules/ + +# Build outputs +dist/ +frontend/dist/ +backend/bin/ + +# Database +*.db +*.sqlite +*.sqlite3 +backend/spaces.db + +# Environment variables +.env +.env.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + diff --git a/API_CHEATSHEET.md b/API_CHEATSHEET.md new file mode 100644 index 0000000..15f723a --- /dev/null +++ b/API_CHEATSHEET.md @@ -0,0 +1,441 @@ +# Certigo Addon API Cheatsheet + +## Base URL +``` +http://localhost:8080/api +``` + +Alle Endpunkte unterstützen CORS und akzeptieren OPTIONS-Requests für Preflight-Checks. + +--- + +## System & Statistics + +### GET /health +Prüft den Systemstatus des Backends. + +**Response:** +```json +{ + "status": "ok", + "message": "Backend ist erreichbar", + "time": "2024-01-15T10:30:00Z" +} +``` + +**Beispiel:** +```bash +curl http://localhost:8080/api/health +``` + +--- + +### GET /stats +Ruft Statistiken über die Anzahl der Spaces, FQDNs und CSRs ab. + +**Response:** +```json +{ + "spaces": 5, + "fqdns": 12, + "csrs": 7 +} +``` + +**Beispiel:** +```bash +curl http://localhost:8080/api/stats +``` + +--- + +## Spaces + +### GET /spaces +Ruft alle Spaces ab. + +**Response:** +```json +[ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Mein Space", + "description": "Beschreibung des Spaces", + "createdAt": "2024-01-15T10:30:00Z" + } +] +``` + +**Beispiel:** +```bash +curl http://localhost:8080/api/spaces +``` + +--- + +### POST /spaces +Erstellt einen neuen Space. + +**Request Body:** +```json +{ + "name": "Mein Space", + "description": "Beschreibung des Spaces" +} +``` + +**Response:** `201 Created` +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Mein Space", + "description": "Beschreibung des Spaces", + "createdAt": "2024-01-15T10:30:00Z" +} +``` + +**Beispiel:** +```bash +curl -X POST http://localhost:8080/api/spaces \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Mein Space", + "description": "Beschreibung des Spaces" + }' +``` + +--- + +### DELETE /spaces/{id} +Löscht einen Space. + +**Query Parameters:** +- `deleteFqdns` (optional, boolean): Wenn `true`, werden alle FQDNs des Spaces mitgelöscht. + +**Response:** `200 OK` +```json +{ + "message": "Space erfolgreich gelöscht" +} +``` + +**Fehler:** +- `409 Conflict`: Space enthält noch FQDNs (nur wenn `deleteFqdns` nicht `true` ist) + +**Beispiele:** +```bash +# Space ohne FQDNs löschen +curl -X DELETE http://localhost:8080/api/spaces/550e8400-e29b-41d4-a716-446655440000 + +# Space mit allen FQDNs löschen +curl -X DELETE "http://localhost:8080/api/spaces/550e8400-e29b-41d4-a716-446655440000?deleteFqdns=true" +``` + +--- + +### GET /spaces/{id}/fqdns/count +Ruft die Anzahl der FQDNs für einen Space ab. + +**Response:** +```json +{ + "count": 5 +} +``` + +**Beispiel:** +```bash +curl http://localhost:8080/api/spaces/550e8400-e29b-41d4-a716-446655440000/fqdns/count +``` + +--- + +## FQDNs + +### GET /spaces/{id}/fqdns +Ruft alle FQDNs für einen Space ab. + +**Response:** +```json +[ + { + "id": "660e8400-e29b-41d4-a716-446655440000", + "spaceId": "550e8400-e29b-41d4-a716-446655440000", + "fqdn": "example.com", + "description": "Beschreibung des FQDN", + "createdAt": "2024-01-15T10:30:00Z" + } +] +``` + +**Beispiel:** +```bash +curl http://localhost:8080/api/spaces/550e8400-e29b-41d4-a716-446655440000/fqdns +``` + +--- + +### POST /spaces/{id}/fqdns +Erstellt einen neuen FQDN innerhalb eines Spaces. + +**Request Body:** +```json +{ + "fqdn": "example.com", + "description": "Beschreibung des FQDN" +} +``` + +**Response:** `201 Created` +```json +{ + "id": "660e8400-e29b-41d4-a716-446655440000", + "spaceId": "550e8400-e29b-41d4-a716-446655440000", + "fqdn": "example.com", + "description": "Beschreibung des FQDN", + "createdAt": "2024-01-15T10:30:00Z" +} +``` + +**Fehler:** +- `409 Conflict`: FQDN existiert bereits in diesem Space (case-insensitive) + +**Beispiel:** +```bash +curl -X POST http://localhost:8080/api/spaces/550e8400-e29b-41d4-a716-446655440000/fqdns \ + -H "Content-Type: application/json" \ + -d '{ + "fqdn": "example.com", + "description": "Beschreibung des FQDN" + }' +``` + +--- + +### DELETE /spaces/{id}/fqdns/{fqdnId} +Löscht einen einzelnen FQDN. + +**Response:** `200 OK` +```json +{ + "message": "FQDN erfolgreich gelöscht" +} +``` + +**Beispiel:** +```bash +curl -X DELETE http://localhost:8080/api/spaces/550e8400-e29b-41d4-a716-446655440000/fqdns/660e8400-e29b-41d4-a716-446655440000 +``` + +--- + +### DELETE /spaces/{id}/fqdns +Löscht alle FQDNs eines Spaces. + +**Response:** `200 OK` +```json +{ + "message": "Alle FQDNs erfolgreich gelöscht", + "deletedCount": 5 +} +``` + +**Beispiel:** +```bash +curl -X DELETE http://localhost:8080/api/spaces/550e8400-e29b-41d4-a716-446655440000/fqdns +``` + +--- + +### DELETE /fqdns?confirm=true +Löscht alle FQDNs aus allen Spaces. **WICHTIG:** Erfordert `confirm=true` Query-Parameter. + +**Query Parameters:** +- `confirm` (required, boolean): Muss `true` sein, um die Operation auszuführen. + +**Response:** `200 OK` +```json +{ + "message": "Alle FQDNs erfolgreich gelöscht", + "deletedCount": 12 +} +``` + +**Beispiel:** +```bash +curl -X DELETE "http://localhost:8080/api/fqdns?confirm=true" +``` + +--- + +## CSRs (Certificate Signing Requests) + +### POST /spaces/{spaceId}/fqdns/{fqdnId}/csr +Lädt einen CSR (Certificate Signing Request) im PEM-Format hoch. + +**Request:** `multipart/form-data` +- `csr` (file, required): CSR-Datei im PEM-Format (.pem oder .csr) +- `spaceId` (string, required): ID des Spaces +- `fqdn` (string, required): Name des FQDNs + +**Response:** `201 Created` +```json +{ + "id": "770e8400-e29b-41d4-a716-446655440000", + "fqdnId": "660e8400-e29b-41d4-a716-446655440000", + "spaceId": "550e8400-e29b-41d4-a716-446655440000", + "fqdn": "example.com", + "csrPem": "-----BEGIN CERTIFICATE REQUEST-----\n...\n-----END CERTIFICATE REQUEST-----", + "subject": "CN=example.com", + "publicKeyAlgorithm": "RSA", + "signatureAlgorithm": "SHA256-RSA", + "keySize": 2048, + "dnsNames": ["example.com", "www.example.com"], + "emailAddresses": ["admin@example.com"], + "ipAddresses": ["192.168.1.1"], + "uris": ["https://example.com"], + "extensions": [ + { + "id": "2.5.29.37", + "oid": "2.5.29.37", + "name": "X509v3 Extended Key Usage", + "critical": false, + "value": "301406082b0601050507030106082b06010505070302", + "description": "TLS Web Server Authentication\n TLS Web Client Authentication", + "purposes": ["TLS Web Server Authentication", "TLS Web Client Authentication"] + } + ], + "createdAt": "2024-01-15T10:30:00Z" +} +``` + +**Beispiel:** +```bash +curl -X POST http://localhost:8080/api/spaces/550e8400-e29b-41d4-a716-446655440000/fqdns/660e8400-e29b-41d4-a716-446655440000/csr \ + -F "csr=@/path/to/certificate.csr" \ + -F "spaceId=550e8400-e29b-41d4-a716-446655440000" \ + -F "fqdn=example.com" +``` + +--- + +### GET /spaces/{spaceId}/fqdns/{fqdnId}/csr +Ruft CSR(s) für einen FQDN ab. + +**Query Parameters:** +- `latest` (optional, boolean): Wenn `true`, wird nur der neueste CSR zurückgegeben. Standard: alle CSRs. + +**Response (ohne `latest`):** `200 OK` +```json +[ + { + "id": "770e8400-e29b-41d4-a716-446655440000", + "fqdnId": "660e8400-e29b-41d4-a716-446655440000", + "spaceId": "550e8400-e29b-41d4-a716-446655440000", + "fqdn": "example.com", + "csrPem": "-----BEGIN CERTIFICATE REQUEST-----\n...\n-----END CERTIFICATE REQUEST-----", + "subject": "CN=example.com", + "publicKeyAlgorithm": "RSA", + "signatureAlgorithm": "SHA256-RSA", + "keySize": 2048, + "dnsNames": ["example.com"], + "emailAddresses": [], + "ipAddresses": [], + "uris": [], + "extensions": [...], + "createdAt": "2024-01-15T10:30:00Z" + } +] +``` + +**Response (mit `latest=true`):** `200 OK` +```json +{ + "id": "770e8400-e29b-41d4-a716-446655440000", + ... +} +``` + +**Beispiele:** +```bash +# Alle CSRs abrufen +curl http://localhost:8080/api/spaces/550e8400-e29b-41d4-a716-446655440000/fqdns/660e8400-e29b-41d4-a716-446655440000/csr + +# Nur neuesten CSR abrufen +curl "http://localhost:8080/api/spaces/550e8400-e29b-41d4-a716-446655440000/fqdns/660e8400-e29b-41d4-a716-446655440000/csr?latest=true" +``` + +--- + +## Extension Details + +CSR Extensions werden automatisch geparst und in menschenlesbarem Format zurückgegeben: + +### Bekannte Extension-Namen: +- `X509v3 Key Usage` +- `X509v3 Subject Alternative Name` +- `X509v3 Basic Constraints` +- `X509v3 Extended Key Usage` +- `X509v3 CRL Distribution Points` +- `X509v3 Certificate Policies` +- `X509v3 Authority Key Identifier` +- `X509v3 Subject Key Identifier` + +### Extended Key Usage Werte: +- `TLS Web Server Authentication` +- `TLS Web Client Authentication` +- `Code Signing` +- `E-mail Protection` +- `Time Stamping` +- `OCSP Signing` +- `IPsec End System` +- `IPsec Tunnel` +- `IPsec User` + +--- + +## HTTP Status Codes + +- `200 OK`: Erfolgreiche Anfrage +- `201 Created`: Ressource erfolgreich erstellt +- `400 Bad Request`: Ungültige Anfrage +- `404 Not Found`: Ressource nicht gefunden +- `409 Conflict`: Konflikt (z.B. FQDN existiert bereits) +- `500 Internal Server Error`: Serverfehler + +--- + +## Fehlerbehandlung + +Bei Fehlern wird eine Fehlermeldung im Response-Body zurückgegeben: + +```json +{ + "error": "Fehlermeldung" +} +``` + +Oder als Plain-Text: +``` +Fehlermeldung +``` + +--- + +## CORS + +Alle Endpunkte unterstützen CORS mit folgenden Headern: +- `Access-Control-Allow-Origin: *` +- `Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS` +- `Access-Control-Allow-Headers: Content-Type` + +--- + +## Hinweise + +1. **UUIDs**: Alle IDs sind UUIDs im Format `550e8400-e29b-41d4-a716-446655440000` +2. **Timestamps**: Alle Timestamps sind im RFC3339-Format (ISO 8601) +3. **FQDN-Validierung**: FQDNs werden case-insensitive verglichen +4. **CSR-Format**: CSRs müssen im PEM-Format vorliegen +5. **Cascading Deletes**: Beim Löschen eines Spaces werden alle zugehörigen FQDNs und CSRs automatisch gelöscht (ON DELETE CASCADE) + diff --git a/backend/config/providers/autodns.json b/backend/config/providers/autodns.json new file mode 100644 index 0000000..a44eac1 --- /dev/null +++ b/backend/config/providers/autodns.json @@ -0,0 +1,7 @@ +{ + "enabled": false, + "settings": { + "password": "test", + "username": "test" + } +} \ No newline at end of file diff --git a/backend/config/providers/dummy-ca.json b/backend/config/providers/dummy-ca.json new file mode 100644 index 0000000..f96a9a4 --- /dev/null +++ b/backend/config/providers/dummy-ca.json @@ -0,0 +1,4 @@ +{ + "enabled": true, + "settings": {} +} \ No newline at end of file diff --git a/backend/config/providers/hetzner.json b/backend/config/providers/hetzner.json new file mode 100644 index 0000000..977815c --- /dev/null +++ b/backend/config/providers/hetzner.json @@ -0,0 +1,4 @@ +{ + "enabled": false, + "settings": {} +} \ No newline at end of file diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..b9219e3 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,9 @@ +module certigo-addon-backend + +go 1.21 + +require ( + github.com/google/uuid v1.5.0 + github.com/gorilla/mux v1.8.1 + github.com/mattn/go-sqlite3 v1.14.18 +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..e5d0ccc --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,6 @@ +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI= +github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..1f8359f --- /dev/null +++ b/backend/main.go @@ -0,0 +1,2686 @@ +package main + +import ( + "context" + "crypto/x509" + "database/sql" + "encoding/asn1" + "encoding/hex" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" + + "github.com/google/uuid" + "github.com/gorilla/mux" + _ "github.com/mattn/go-sqlite3" + + "certigo-addon-backend/providers" +) + +// OID zu Name Mapping (OpenSSL Format) +var oidToName = map[string]string{ + "2.5.29.15": "X509v3 Key Usage", + "2.5.29.17": "X509v3 Subject Alternative Name", + "2.5.29.19": "X509v3 Basic Constraints", + "2.5.29.31": "X509v3 CRL Distribution Points", + "2.5.29.32": "X509v3 Certificate Policies", + "2.5.29.35": "X509v3 Authority Key Identifier", + "2.5.29.37": "X509v3 Extended Key Usage", + "2.5.29.14": "X509v3 Subject Key Identifier", + "1.3.6.1.5.5.7.1.1": "Authority Information Access", + "1.3.6.1.5.5.7.48.1": "OCSP", + "1.3.6.1.5.5.7.48.2": "CA Issuers", +} + +// Extended Key Usage OIDs (OpenSSL Format) +var extendedKeyUsageOIDs = map[string]string{ + "1.3.6.1.5.5.7.3.1": "TLS Web Server Authentication", + "1.3.6.1.5.5.7.3.2": "TLS Web Client Authentication", + "1.3.6.1.5.5.7.3.3": "Code Signing", + "1.3.6.1.5.5.7.3.4": "E-mail Protection", + "1.3.6.1.5.5.7.3.8": "Time Stamping", + "1.3.6.1.5.5.7.3.9": "OCSP Signing", + "1.3.6.1.5.5.7.3.5": "IPsec End System", + "1.3.6.1.5.5.7.3.6": "IPsec Tunnel", + "1.3.6.1.5.5.7.3.7": "IPsec User", +} + +// Key Usage Flags +var keyUsageFlags = map[int]string{ + 0: "Digital Signature", + 1: "Content Commitment", + 2: "Key Encipherment", + 3: "Data Encipherment", + 4: "Key Agreement", + 5: "Key Cert Sign", + 6: "CRL Sign", + 7: "Encipher Only", + 8: "Decipher Only", +} + +func getExtensionName(oid string) string { + if name, ok := oidToName[oid]; ok { + return name + } + return "Unknown Extension" +} + +func parseExtensionValue(oid string, value []byte, csr *x509.CertificateRequest) (string, []string) { + switch oid { + case "2.5.29.37": // Extended Key Usage + return parseExtendedKeyUsage(value) + case "2.5.29.15": // Key Usage + return parseKeyUsage(value) + case "2.5.29.19": // Basic Constraints + return parseBasicConstraints(value) + case "2.5.29.17": // Subject Alternative Name + return parseSubjectAlternativeName(csr) + default: + return hex.EncodeToString(value), nil + } +} + +func parseSubjectAlternativeName(csr *x509.CertificateRequest) (string, []string) { + var parts []string + + // DNS Names + for _, dns := range csr.DNSNames { + parts = append(parts, fmt.Sprintf("DNS:%s", dns)) + } + + // Email Addresses + for _, email := range csr.EmailAddresses { + parts = append(parts, fmt.Sprintf("email:%s", email)) + } + + // IP Addresses + for _, ip := range csr.IPAddresses { + parts = append(parts, fmt.Sprintf("IP:%s", ip.String())) + } + + // URIs + for _, uri := range csr.URIs { + parts = append(parts, fmt.Sprintf("URI:%s", uri.String())) + } + + if len(parts) > 0 { + return strings.Join(parts, ", "), parts + } + return "No Subject Alternative Name", nil +} + +func parseExtendedKeyUsage(value []byte) (string, []string) { + var oids []asn1.ObjectIdentifier + _, err := asn1.Unmarshal(value, &oids) + if err != nil { + return hex.EncodeToString(value), nil + } + + var purposes []string + for _, oid := range oids { + oidStr := oid.String() + if purpose, ok := extendedKeyUsageOIDs[oidStr]; ok { + purposes = append(purposes, purpose) + } else { + purposes = append(purposes, oidStr) + } + } + + if len(purposes) > 0 { + // Format wie OpenSSL: jede Purpose auf eigener Zeile + return strings.Join(purposes, "\n "), purposes + } + return hex.EncodeToString(value), nil +} + +func parseKeyUsage(value []byte) (string, []string) { + var bits asn1.BitString + _, err := asn1.Unmarshal(value, &bits) + if err != nil { + return hex.EncodeToString(value), nil + } + + var usages []string + for i := 0; i < len(bits.Bytes)*8 && i < 9; i++ { + if bits.At(i) == 1 { + if usage, ok := keyUsageFlags[i]; ok { + usages = append(usages, usage) + } + } + } + + if len(usages) > 0 { + return strings.Join(usages, ", "), usages + } + return "No key usage specified", nil +} + +func parseBasicConstraints(value []byte) (string, []string) { + var constraints struct { + IsCA bool `asn1:"optional"` + MaxPathLen int `asn1:"optional,default:-1"` + } + _, err := asn1.Unmarshal(value, &constraints) + if err != nil { + return hex.EncodeToString(value), nil + } + + var parts []string + if constraints.IsCA { + parts = append(parts, "CA: true") + } else { + parts = append(parts, "CA: false") + } + if constraints.MaxPathLen >= 0 { + parts = append(parts, fmt.Sprintf("Path Length: %d", constraints.MaxPathLen)) + } + + return strings.Join(parts, ", "), parts +} + +type HealthResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Time string `json:"time"` +} + +type StatsResponse struct { + Spaces int `json:"spaces"` + FQDNs int `json:"fqdns"` + CSRs int `json:"csrs"` + Certificates int `json:"certificates"` +} + +type Space struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt string `json:"createdAt"` +} + +type CreateSpaceRequest struct { + Name string `json:"name"` + Description string `json:"description"` +} + +type FQDN struct { + ID string `json:"id"` + SpaceID string `json:"spaceId"` + FQDN string `json:"fqdn"` + Description string `json:"description"` + CreatedAt string `json:"createdAt"` +} + +type CreateFQDNRequest struct { + FQDN string `json:"fqdn"` + Description string `json:"description"` +} + +type Extension struct { + ID string `json:"id"` + OID string `json:"oid"` + Name string `json:"name"` + Critical bool `json:"critical"` + Value string `json:"value"` + Description string `json:"description"` + Purposes []string `json:"purposes,omitempty"` +} + +type CSR struct { + ID string `json:"id"` + FQDNID string `json:"fqdnId"` + SpaceID string `json:"spaceId"` + FQDN string `json:"fqdn"` + CSRPEM string `json:"csrPem"` + Subject string `json:"subject"` + PublicKeyAlgorithm string `json:"publicKeyAlgorithm"` + SignatureAlgorithm string `json:"signatureAlgorithm"` + KeySize int `json:"keySize"` + DNSNames []string `json:"dnsNames"` + EmailAddresses []string `json:"emailAddresses"` + IPAddresses []string `json:"ipAddresses"` + URIs []string `json:"uris"` + Extensions []Extension `json:"extensions"` + CreatedAt string `json:"createdAt"` +} + +var db *sql.DB + +func initDB() { + var err error + // SQLite Connection String mit Timeout und WAL Mode für bessere Concurrency + // _busy_timeout erhöht die Wartezeit bei Locks + db, err = sql.Open("sqlite3", "./spaces.db?_foreign_keys=1&_journal_mode=WAL&_timeout=10000&_busy_timeout=10000") + if err != nil { + log.Fatal("Fehler beim Öffnen der Datenbank:", err) + } + + // Setze Connection Pool Settings + db.SetMaxOpenConns(1) // SQLite unterstützt nur eine Verbindung gleichzeitig + db.SetMaxIdleConns(1) + + // Teste die Verbindung mit Retry + log.Println("Teste Datenbank-Verbindung...") + maxRetries := 5 + for i := 0; i < maxRetries; i++ { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + err := db.PingContext(ctx) + cancel() + if err != nil { + if i < maxRetries-1 { + log.Printf("Datenbank-Verbindung fehlgeschlagen, versuche erneut (%d/%d)...", i+1, maxRetries) + time.Sleep(time.Second * 2) + continue + } + log.Fatal("Fehler beim Verbinden mit der Datenbank nach mehreren Versuchen:", err) + } + log.Println("Datenbank-Verbindung erfolgreich") + break + } + + // Aktiviere Foreign Keys (auch über Connection String, aber zur Sicherheit nochmal) + log.Println("Aktiviere Foreign Keys...") + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + _, err = db.ExecContext(ctx, "PRAGMA foreign_keys = ON") + cancel() + if err != nil { + log.Fatal("Fehler beim Aktivieren der Foreign Keys:", err) + } + + // Prüfe und bereinige WAL-Dateien falls nötig + log.Println("Führe WAL-Checkpoint aus...") + ctx, cancel = context.WithTimeout(context.Background(), time.Second*5) + _, err = db.ExecContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)") + cancel() + if err != nil { + log.Printf("Warnung: WAL-Checkpoint fehlgeschlagen: %v", err) + } + + // Erstelle Tabelle falls sie nicht existiert + log.Println("Erstelle spaces-Tabelle...") + createTableSQL := ` + CREATE TABLE IF NOT EXISTS spaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + created_at DATETIME NOT NULL + );` + + ctx, cancel = context.WithTimeout(context.Background(), time.Second*5) + _, err = db.ExecContext(ctx, createTableSQL) + cancel() + if err != nil { + log.Fatal("Fehler beim Erstellen der Tabelle:", err) + } + + // Erstelle FQDN-Tabelle + log.Println("Erstelle fqdns-Tabelle...") + createFQDNTableSQL := ` + CREATE TABLE IF NOT EXISTS fqdns ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL, + fqdn TEXT NOT NULL, + description TEXT, + created_at DATETIME NOT NULL, + FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE + );` + + ctx, cancel = context.WithTimeout(context.Background(), time.Second*5) + _, err = db.ExecContext(ctx, createFQDNTableSQL) + cancel() + if err != nil { + log.Fatal("Fehler beim Erstellen der FQDN-Tabelle:", err) + } + + // Erstelle CSR-Tabelle + log.Println("Erstelle csrs-Tabelle...") + createCSRTableSQL := ` + CREATE TABLE IF NOT EXISTS csrs ( + id TEXT PRIMARY KEY, + fqdn_id TEXT NOT NULL, + space_id TEXT NOT NULL, + fqdn TEXT NOT NULL, + csr_pem TEXT NOT NULL, + subject TEXT, + public_key_algorithm TEXT, + signature_algorithm TEXT, + key_size INTEGER, + dns_names TEXT, + email_addresses TEXT, + ip_addresses TEXT, + uris TEXT, + extensions TEXT, + created_at DATETIME NOT NULL, + FOREIGN KEY (fqdn_id) REFERENCES fqdns(id) ON DELETE CASCADE, + FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE + );` + + ctx, cancel = context.WithTimeout(context.Background(), time.Second*5) + _, err = db.ExecContext(ctx, createCSRTableSQL) + cancel() + if err != nil { + log.Fatal("Fehler beim Erstellen der CSR-Tabelle:", err) + } + + // Füge Extensions-Spalte hinzu, falls sie nicht existiert (für bestehende Datenbanken) + // Prüfe zuerst, ob die Spalte bereits existiert + log.Println("Prüfe Extensions-Spalte...") + var columnExists bool + ctx, cancel = context.WithTimeout(context.Background(), time.Second*5) + rows, err := db.QueryContext(ctx, "PRAGMA table_info(csrs)") + cancel() + if err == nil { + defer rows.Close() + for rows.Next() { + var cid int + var name string + var dataType string + var notNull int + var defaultValue interface{} + var pk int + if err := rows.Scan(&cid, &name, &dataType, ¬Null, &defaultValue, &pk); err == nil { + if name == "extensions" { + columnExists = true + break + } + } + } + rows.Close() + } + + // Füge Spalte nur hinzu, wenn sie nicht existiert + if !columnExists { + log.Println("Füge Extensions-Spalte hinzu...") + ctx, cancel = context.WithTimeout(context.Background(), time.Second*5) + _, err = db.ExecContext(ctx, "ALTER TABLE csrs ADD COLUMN extensions TEXT") + cancel() + if err != nil { + // Ignoriere "duplicate column" Fehler, da die Spalte möglicherweise zwischenzeitlich hinzugefügt wurde + if !strings.Contains(err.Error(), "duplicate column") { + log.Printf("Fehler beim Hinzufügen der Extensions-Spalte: %v", err) + } + } else { + log.Println("Extensions-Spalte zur csrs-Tabelle hinzugefügt") + } + } else { + log.Println("Extensions-Spalte existiert bereits") + } + + // Erstelle Zertifikat-Tabelle + log.Println("Erstelle certificates-Tabelle...") + createCertificateTableSQL := ` + CREATE TABLE IF NOT EXISTS certificates ( + id TEXT PRIMARY KEY, + fqdn_id TEXT NOT NULL, + space_id TEXT NOT NULL, + csr_id TEXT NOT NULL, + certificate_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + certificate_pem TEXT, + status TEXT NOT NULL, + created_at DATETIME NOT NULL, + FOREIGN KEY (fqdn_id) REFERENCES fqdns(id) ON DELETE CASCADE, + FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE, + FOREIGN KEY (csr_id) REFERENCES csrs(id) ON DELETE CASCADE + );` + + ctx, cancel = context.WithTimeout(context.Background(), time.Second*5) + _, err = db.ExecContext(ctx, createCertificateTableSQL) + cancel() + if err != nil { + if strings.Contains(err.Error(), "database is locked") { + log.Fatal("Datenbank ist gesperrt. Bitte beenden Sie alle anderen Prozesse, die die Datenbank verwenden (z.B. andere go run main.go Instanzen).") + } + log.Fatal("Fehler beim Erstellen der Zertifikat-Tabelle:", err) + } + + log.Println("Datenbank erfolgreich initialisiert") +} + +func healthHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + response := HealthResponse{ + Status: "ok", + Message: "Backend ist erreichbar", + Time: time.Now().Format(time.RFC3339), + } + + json.NewEncoder(w).Encode(response) +} + +func getStatsHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + var spacesCount, fqdnsCount, csrsCount, certificatesCount int + + // Zähle Spaces + err := db.QueryRow("SELECT COUNT(*) FROM spaces").Scan(&spacesCount) + if err != nil { + http.Error(w, "Fehler beim Abrufen der Statistiken", http.StatusInternalServerError) + log.Printf("Fehler beim Zählen der Spaces: %v", err) + return + } + + // Zähle FQDNs + err = db.QueryRow("SELECT COUNT(*) FROM fqdns").Scan(&fqdnsCount) + if err != nil { + http.Error(w, "Fehler beim Abrufen der Statistiken", http.StatusInternalServerError) + log.Printf("Fehler beim Zählen der FQDNs: %v", err) + return + } + + // Zähle CSRs + err = db.QueryRow("SELECT COUNT(*) FROM csrs").Scan(&csrsCount) + if err != nil { + http.Error(w, "Fehler beim Abrufen der Statistiken", http.StatusInternalServerError) + log.Printf("Fehler beim Zählen der CSRs: %v", err) + return + } + + // Zähle Zertifikate + err = db.QueryRow("SELECT COUNT(*) FROM certificates").Scan(&certificatesCount) + if err != nil { + http.Error(w, "Fehler beim Abrufen der Statistiken", http.StatusInternalServerError) + log.Printf("Fehler beim Zählen der Zertifikate: %v", err) + return + } + + response := StatsResponse{ + Spaces: spacesCount, + FQDNs: fqdnsCount, + CSRs: csrsCount, + Certificates: certificatesCount, + } + + json.NewEncoder(w).Encode(response) +} + +func getSpacesHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + // Verwende Prepared Statement für bessere Performance und Sicherheit + stmt, err := db.Prepare("SELECT id, name, description, created_at FROM spaces ORDER BY created_at DESC") + if err != nil { + http.Error(w, "Fehler beim Vorbereiten der Abfrage", http.StatusInternalServerError) + log.Printf("Fehler beim Vorbereiten der Abfrage: %v", err) + return + } + defer stmt.Close() + + rows, err := stmt.Query() + if err != nil { + http.Error(w, "Fehler beim Abrufen der Spaces", http.StatusInternalServerError) + log.Printf("Fehler beim Abrufen der Spaces: %v", err) + return + } + defer rows.Close() + + spaces := make([]Space, 0) + for rows.Next() { + var space Space + var createdAt time.Time + var description sql.NullString + + err := rows.Scan(&space.ID, &space.Name, &description, &createdAt) + if err != nil { + http.Error(w, "Fehler beim Lesen der Daten", http.StatusInternalServerError) + log.Printf("Fehler beim Lesen der Daten: %v", err) + return + } + + if description.Valid { + space.Description = description.String + } else { + space.Description = "" + } + space.CreatedAt = createdAt.Format(time.RFC3339) + spaces = append(spaces, space) + } + + if err = rows.Err(); err != nil { + http.Error(w, "Fehler beim Verarbeiten der Daten", http.StatusInternalServerError) + log.Printf("Fehler beim Verarbeiten der Daten: %v", err) + return + } + + // Stelle sicher, dass immer ein Array zurückgegeben wird + if spaces == nil { + spaces = []Space{} + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(spaces) +} + +func createSpaceHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + var req CreateSpaceRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.Name == "" { + http.Error(w, "Name is required", http.StatusBadRequest) + return + } + + // Generiere eindeutige UUID + id := uuid.New().String() + createdAt := time.Now() + + // Speichere in Datenbank + _, err := db.Exec( + "INSERT INTO spaces (id, name, description, created_at) VALUES (?, ?, ?, ?)", + id, req.Name, req.Description, createdAt, + ) + if err != nil { + http.Error(w, "Fehler beim Speichern des Space", http.StatusInternalServerError) + log.Printf("Fehler beim Speichern des Space: %v", err) + return + } + + newSpace := Space{ + ID: id, + Name: req.Name, + Description: req.Description, + CreatedAt: createdAt.Format(time.RFC3339), + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(newSpace) +} + +func deleteSpaceHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + vars := mux.Vars(r) + id := vars["id"] + + if id == "" { + http.Error(w, "Space ID ist erforderlich", http.StatusBadRequest) + return + } + + // Prüfe ob der Space existiert + var exists bool + err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM spaces WHERE id = ?)", id).Scan(&exists) + if err != nil { + http.Error(w, "Fehler beim Prüfen des Space", http.StatusInternalServerError) + log.Printf("Fehler beim Prüfen des Space: %v", err) + return + } + + if !exists { + http.Error(w, "Space nicht gefunden", http.StatusNotFound) + return + } + + // Prüfe ob FQDNs vorhanden sind + var fqdnCount int + err = db.QueryRow("SELECT COUNT(*) FROM fqdns WHERE space_id = ?", id).Scan(&fqdnCount) + if err != nil { + http.Error(w, "Fehler beim Prüfen der FQDNs", http.StatusInternalServerError) + log.Printf("Fehler beim Prüfen der FQDNs: %v", err) + return + } + + // Prüfe Query-Parameter für Mitlöschen + deleteFqdns := r.URL.Query().Get("deleteFqdns") == "true" + + if fqdnCount > 0 && !deleteFqdns { + http.Error(w, "Space enthält noch FQDNs. Bitte löschen Sie zuerst die FQDNs oder wählen Sie die Option zum Mitlöschen.", http.StatusConflict) + return + } + + // Beginne Transaktion für atomares Löschen + tx, err := db.Begin() + if err != nil { + http.Error(w, "Fehler beim Starten der Transaktion", http.StatusInternalServerError) + log.Printf("Fehler beim Starten der Transaktion: %v", err) + return + } + defer tx.Rollback() + + // Lösche FQDNs zuerst, wenn gewünscht + if deleteFqdns && fqdnCount > 0 { + _, err = tx.Exec("DELETE FROM fqdns WHERE space_id = ?", id) + if err != nil { + http.Error(w, "Fehler beim Löschen der FQDNs", http.StatusInternalServerError) + log.Printf("Fehler beim Löschen der FQDNs: %v", err) + return + } + log.Printf("Gelöscht: %d FQDNs für Space %s", fqdnCount, id) + } + + // Lösche den Space + result, err := tx.Exec("DELETE FROM spaces WHERE id = ?", id) + if err != nil { + http.Error(w, "Fehler beim Löschen des Space", http.StatusInternalServerError) + log.Printf("Fehler beim Löschen des Space: %v", err) + return + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + tx.Rollback() + http.Error(w, "Fehler beim Prüfen der gelöschten Zeilen", http.StatusInternalServerError) + log.Printf("Fehler beim Prüfen der gelöschten Zeilen: %v", err) + return + } + + if rowsAffected == 0 { + tx.Rollback() + http.Error(w, "Space nicht gefunden", http.StatusNotFound) + return + } + + // Committe die Transaktion + err = tx.Commit() + if err != nil { + http.Error(w, "Fehler beim Speichern der Änderungen", http.StatusInternalServerError) + log.Printf("Fehler beim Committen der Transaktion: %v", err) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"message": "Space erfolgreich gelöscht"}) +} + +func getSpaceFqdnCountHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + vars := mux.Vars(r) + spaceID := vars["id"] + + if spaceID == "" { + http.Error(w, "Space ID ist erforderlich", http.StatusBadRequest) + return + } + + var count int + err := db.QueryRow("SELECT COUNT(*) FROM fqdns WHERE space_id = ?", spaceID).Scan(&count) + if err != nil { + http.Error(w, "Fehler beim Abrufen der FQDN-Anzahl", http.StatusInternalServerError) + log.Printf("Fehler beim Abrufen der FQDN-Anzahl: %v", err) + return + } + + json.NewEncoder(w).Encode(map[string]int{"count": count}) +} + +func getFqdnsHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + vars := mux.Vars(r) + spaceID := vars["id"] + + if spaceID == "" { + http.Error(w, "Space ID ist erforderlich", http.StatusBadRequest) + return + } + + // Prüfe ob der Space existiert + var exists bool + err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM spaces WHERE id = ?)", spaceID).Scan(&exists) + if err != nil { + http.Error(w, "Fehler beim Prüfen des Space", http.StatusInternalServerError) + log.Printf("Fehler beim Prüfen des Space: %v", err) + return + } + + if !exists { + http.Error(w, "Space nicht gefunden", http.StatusNotFound) + return + } + + rows, err := db.Query("SELECT id, space_id, fqdn, description, created_at FROM fqdns WHERE space_id = ? ORDER BY created_at DESC", spaceID) + if err != nil { + http.Error(w, "Fehler beim Abrufen der FQDNs", http.StatusInternalServerError) + log.Printf("Fehler beim Abrufen der FQDNs: %v", err) + return + } + defer rows.Close() + + var fqdns []FQDN + for rows.Next() { + var fqdn FQDN + var createdAt time.Time + var description sql.NullString + err := rows.Scan(&fqdn.ID, &fqdn.SpaceID, &fqdn.FQDN, &description, &createdAt) + if err != nil { + http.Error(w, "Fehler beim Lesen der Daten", http.StatusInternalServerError) + log.Printf("Fehler beim Lesen der Daten: %v", err) + return + } + if description.Valid { + fqdn.Description = description.String + } else { + fqdn.Description = "" + } + fqdn.CreatedAt = createdAt.Format(time.RFC3339) + fqdns = append(fqdns, fqdn) + } + + if err = rows.Err(); err != nil { + http.Error(w, "Fehler beim Verarbeiten der Daten", http.StatusInternalServerError) + log.Printf("Fehler beim Verarbeiten der Daten: %v", err) + return + } + + if fqdns == nil { + fqdns = []FQDN{} + } + + json.NewEncoder(w).Encode(fqdns) +} + +func createFqdnHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + vars := mux.Vars(r) + spaceID := vars["id"] + + if spaceID == "" { + http.Error(w, "Space ID ist erforderlich", http.StatusBadRequest) + return + } + + // Prüfe ob der Space existiert + var exists bool + err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM spaces WHERE id = ?)", spaceID).Scan(&exists) + if err != nil { + http.Error(w, "Fehler beim Prüfen des Space", http.StatusInternalServerError) + log.Printf("Fehler beim Prüfen des Space: %v", err) + return + } + + if !exists { + http.Error(w, "Space nicht gefunden", http.StatusNotFound) + return + } + + var req CreateFQDNRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.FQDN == "" { + http.Error(w, "FQDN is required", http.StatusBadRequest) + return + } + + // Prüfe ob der FQDN bereits existiert (case-insensitive) + var fqdnExists bool + err = db.QueryRow("SELECT EXISTS(SELECT 1 FROM fqdns WHERE LOWER(fqdn) = LOWER(?))", req.FQDN).Scan(&fqdnExists) + if err != nil { + http.Error(w, "Fehler beim Prüfen des FQDN", http.StatusInternalServerError) + log.Printf("Fehler beim Prüfen des FQDN: %v", err) + return + } + + if fqdnExists { + http.Error(w, "Dieser FQDN existiert bereits", http.StatusConflict) + return + } + + // Generiere eindeutige UUID + id := uuid.New().String() + createdAt := time.Now() + + // Speichere in Datenbank + _, err = db.Exec( + "INSERT INTO fqdns (id, space_id, fqdn, description, created_at) VALUES (?, ?, ?, ?, ?)", + id, spaceID, req.FQDN, req.Description, createdAt, + ) + if err != nil { + http.Error(w, "Fehler beim Speichern des FQDN", http.StatusInternalServerError) + log.Printf("Fehler beim Speichern des FQDN: %v", err) + return + } + + newFqdn := FQDN{ + ID: id, + SpaceID: spaceID, + FQDN: req.FQDN, + Description: req.Description, + CreatedAt: createdAt.Format(time.RFC3339), + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(newFqdn) +} + +func deleteFqdnHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + vars := mux.Vars(r) + spaceID := vars["id"] + fqdnID := vars["fqdnId"] + + if spaceID == "" || fqdnID == "" { + http.Error(w, "Space ID und FQDN ID sind erforderlich", http.StatusBadRequest) + return + } + + // Prüfe ob der FQDN existiert und zum Space gehört + var exists bool + err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM fqdns WHERE id = ? AND space_id = ?)", fqdnID, spaceID).Scan(&exists) + if err != nil { + http.Error(w, "Fehler beim Prüfen des FQDN", http.StatusInternalServerError) + log.Printf("Fehler beim Prüfen des FQDN: %v", err) + return + } + + if !exists { + http.Error(w, "FQDN nicht gefunden", http.StatusNotFound) + return + } + + // Beginne Transaktion für atomares Löschen + tx, err := db.Begin() + if err != nil { + http.Error(w, "Fehler beim Starten der Transaktion", http.StatusInternalServerError) + log.Printf("Fehler beim Starten der Transaktion: %v", err) + return + } + defer tx.Rollback() + + // Lösche zuerst alle CSRs für diesen FQDN (falls CASCADE nicht funktioniert) + _, err = tx.Exec("DELETE FROM csrs WHERE fqdn_id = ? AND space_id = ?", fqdnID, spaceID) + if err != nil { + http.Error(w, "Fehler beim Löschen der CSRs", http.StatusInternalServerError) + log.Printf("Fehler beim Löschen der CSRs: %v", err) + return + } + + // Lösche den FQDN + result, err := tx.Exec("DELETE FROM fqdns WHERE id = ? AND space_id = ?", fqdnID, spaceID) + if err != nil { + http.Error(w, "Fehler beim Löschen des FQDN", http.StatusInternalServerError) + log.Printf("Fehler beim Löschen des FQDN: %v", err) + return + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + tx.Rollback() + http.Error(w, "Fehler beim Prüfen der gelöschten Zeilen", http.StatusInternalServerError) + log.Printf("Fehler beim Prüfen der gelöschten Zeilen: %v", err) + return + } + + if rowsAffected == 0 { + tx.Rollback() + http.Error(w, "FQDN nicht gefunden", http.StatusNotFound) + return + } + + // Committe die Transaktion + err = tx.Commit() + if err != nil { + http.Error(w, "Fehler beim Speichern der Änderungen", http.StatusInternalServerError) + log.Printf("Fehler beim Committen der Transaktion: %v", err) + return + } + + log.Printf("FQDN %s und zugehörige CSRs erfolgreich gelöscht", fqdnID) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"message": "FQDN erfolgreich gelöscht"}) +} + +func deleteAllFqdnsHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + vars := mux.Vars(r) + spaceID := vars["id"] + + if spaceID == "" { + http.Error(w, "Space ID ist erforderlich", http.StatusBadRequest) + return + } + + // Prüfe ob der Space existiert + var exists bool + err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM spaces WHERE id = ?)", spaceID).Scan(&exists) + if err != nil { + http.Error(w, "Fehler beim Prüfen des Space", http.StatusInternalServerError) + log.Printf("Fehler beim Prüfen des Space: %v", err) + return + } + + if !exists { + http.Error(w, "Space nicht gefunden", http.StatusNotFound) + return + } + + // Beginne Transaktion für atomares Löschen + tx, err := db.Begin() + if err != nil { + http.Error(w, "Fehler beim Starten der Transaktion", http.StatusInternalServerError) + log.Printf("Fehler beim Starten der Transaktion: %v", err) + return + } + defer tx.Rollback() + + // Lösche zuerst alle CSRs für alle FQDNs dieses Spaces + _, err = tx.Exec("DELETE FROM csrs WHERE space_id = ?", spaceID) + if err != nil { + http.Error(w, "Fehler beim Löschen der CSRs", http.StatusInternalServerError) + log.Printf("Fehler beim Löschen der CSRs: %v", err) + return + } + + // Lösche alle FQDNs des Spaces + result, err := tx.Exec("DELETE FROM fqdns WHERE space_id = ?", spaceID) + if err != nil { + http.Error(w, "Fehler beim Löschen der FQDNs", http.StatusInternalServerError) + log.Printf("Fehler beim Löschen der FQDNs: %v", err) + return + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + tx.Rollback() + http.Error(w, "Fehler beim Prüfen der gelöschten Zeilen", http.StatusInternalServerError) + log.Printf("Fehler beim Prüfen der gelöschten Zeilen: %v", err) + return + } + + // Committe die Transaktion + err = tx.Commit() + if err != nil { + http.Error(w, "Fehler beim Speichern der Änderungen", http.StatusInternalServerError) + log.Printf("Fehler beim Committen der Transaktion: %v", err) + return + } + + log.Printf("Gelöscht: %d FQDNs und zugehörige CSRs aus Space %s", rowsAffected, spaceID) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Alle FQDNs und zugehörige CSRs erfolgreich gelöscht", + "deletedCount": rowsAffected, + }) +} + +func deleteAllFqdnsGlobalHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + // Prüfe Query-Parameter für Bestätigung (Sicherheitsmaßnahme) + confirm := r.URL.Query().Get("confirm") + if confirm != "true" { + http.Error(w, "Bestätigung erforderlich. Verwenden Sie ?confirm=true", http.StatusBadRequest) + return + } + + // Zähle zuerst die Anzahl der FQDNs + var totalCount int + err := db.QueryRow("SELECT COUNT(*) FROM fqdns").Scan(&totalCount) + if err != nil { + http.Error(w, "Fehler beim Zählen der FQDNs", http.StatusInternalServerError) + log.Printf("Fehler beim Zählen der FQDNs: %v", err) + return + } + + if totalCount == 0 { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Keine FQDNs zum Löschen vorhanden", + "deletedCount": 0, + }) + return + } + + // Beginne Transaktion für atomares Löschen + tx, err := db.Begin() + if err != nil { + http.Error(w, "Fehler beim Starten der Transaktion", http.StatusInternalServerError) + log.Printf("Fehler beim Starten der Transaktion: %v", err) + return + } + defer tx.Rollback() + + // Lösche zuerst alle CSRs + _, err = tx.Exec("DELETE FROM csrs") + if err != nil { + http.Error(w, "Fehler beim Löschen aller CSRs", http.StatusInternalServerError) + log.Printf("Fehler beim Löschen aller CSRs: %v", err) + return + } + + // Lösche alle FQDNs + result, err := tx.Exec("DELETE FROM fqdns") + if err != nil { + http.Error(w, "Fehler beim Löschen aller FQDNs", http.StatusInternalServerError) + log.Printf("Fehler beim Löschen aller FQDNs: %v", err) + return + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + tx.Rollback() + http.Error(w, "Fehler beim Prüfen der gelöschten Zeilen", http.StatusInternalServerError) + log.Printf("Fehler beim Prüfen der gelöschten Zeilen: %v", err) + return + } + + // Committe die Transaktion + err = tx.Commit() + if err != nil { + http.Error(w, "Fehler beim Speichern der Änderungen", http.StatusInternalServerError) + log.Printf("Fehler beim Committen der Transaktion: %v", err) + return + } + + log.Printf("Gelöscht: %d FQDNs und alle zugehörigen CSRs aus allen Spaces", rowsAffected) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Alle FQDNs und zugehörige CSRs erfolgreich gelöscht", + "deletedCount": rowsAffected, + }) +} + +func deleteAllCSRsHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + // Prüfe Query-Parameter für Bestätigung (Sicherheitsmaßnahme) + confirm := r.URL.Query().Get("confirm") + if confirm != "true" { + http.Error(w, "Bestätigung erforderlich. Verwenden Sie ?confirm=true", http.StatusBadRequest) + return + } + + // Zähle zuerst die Anzahl der CSRs + var totalCount int + err := db.QueryRow("SELECT COUNT(*) FROM csrs").Scan(&totalCount) + if err != nil { + http.Error(w, "Fehler beim Zählen der CSRs", http.StatusInternalServerError) + log.Printf("Fehler beim Zählen der CSRs: %v", err) + return + } + + if totalCount == 0 { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Keine CSRs zum Löschen vorhanden", + "deletedCount": 0, + }) + return + } + + // Beginne Transaktion für atomare Operation + tx, err := db.Begin() + if err != nil { + http.Error(w, "Fehler beim Starten der Transaktion", http.StatusInternalServerError) + log.Printf("Fehler beim Starten der Transaktion: %v", err) + return + } + defer tx.Rollback() + + // Lösche alle CSRs + result, err := tx.Exec("DELETE FROM csrs") + if err != nil { + http.Error(w, "Fehler beim Löschen aller CSRs", http.StatusInternalServerError) + log.Printf("Fehler beim Löschen aller CSRs: %v", err) + return + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + tx.Rollback() + http.Error(w, "Fehler beim Prüfen der gelöschten Zeilen", http.StatusInternalServerError) + log.Printf("Fehler beim Prüfen der gelöschten Zeilen: %v", err) + return + } + + // Committe die Transaktion + err = tx.Commit() + if err != nil { + http.Error(w, "Fehler beim Speichern der Änderungen", http.StatusInternalServerError) + log.Printf("Fehler beim Committen der Transaktion: %v", err) + return + } + + log.Printf("Gelöscht: %d CSRs aus allen Spaces", rowsAffected) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Alle CSRs erfolgreich gelöscht", + "deletedCount": rowsAffected, + }) +} + +func uploadCSRHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + // Parse multipart form + err := r.ParseMultipartForm(10 << 20) // 10 MB max + if err != nil { + http.Error(w, "Fehler beim Parsen des Formulars", http.StatusBadRequest) + return + } + + spaceID := r.FormValue("spaceId") + fqdnName := r.FormValue("fqdn") + + if spaceID == "" || fqdnName == "" { + http.Error(w, "spaceId und fqdn sind erforderlich", http.StatusBadRequest) + return + } + + // Prüfe ob Space existiert + var spaceExists bool + err = db.QueryRow("SELECT EXISTS(SELECT 1 FROM spaces WHERE id = ?)", spaceID).Scan(&spaceExists) + if err != nil || !spaceExists { + http.Error(w, "Space nicht gefunden", http.StatusNotFound) + return + } + + // Prüfe ob FQDN existiert und zum Space gehört + var fqdnID string + err = db.QueryRow("SELECT id FROM fqdns WHERE fqdn = ? AND space_id = ?", fqdnName, spaceID).Scan(&fqdnID) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "FQDN nicht gefunden oder gehört nicht zu diesem Space", http.StatusNotFound) + } else { + http.Error(w, "Fehler beim Prüfen des FQDN", http.StatusInternalServerError) + } + return + } + + // Hole die CSR-Datei + file, header, err := r.FormFile("csr") + if err != nil { + http.Error(w, "Fehler beim Lesen der CSR-Datei", http.StatusBadRequest) + return + } + defer file.Close() + + // Lese den Dateiinhalt + csrBytes := make([]byte, header.Size) + _, err = io.ReadFull(file, csrBytes) + if err != nil { + http.Error(w, "Fehler beim Lesen der CSR-Datei", http.StatusBadRequest) + return + } + + csrPEM := string(csrBytes) + + // Parse CSR + block, _ := pem.Decode(csrBytes) + if block == nil { + http.Error(w, "Ungültiges PEM-Format", http.StatusBadRequest) + return + } + + csr, err := x509.ParseCertificateRequest(block.Bytes) + if err != nil { + http.Error(w, "Fehler beim Parsen des CSR: "+err.Error(), http.StatusBadRequest) + return + } + + // Extrahiere Informationen + subject := csr.Subject.String() + publicKeyAlgorithm := csr.PublicKeyAlgorithm.String() + signatureAlgorithm := csr.SignatureAlgorithm.String() + + // Bestimme Key Size + keySize := 0 + if csr.PublicKey != nil { + switch pub := csr.PublicKey.(type) { + case interface{ Size() int }: + keySize = pub.Size() * 8 // Convert bytes to bits + } + } + + // Extrahiere SANs + dnsNames := csr.DNSNames + emailAddresses := csr.EmailAddresses + ipAddresses := make([]string, len(csr.IPAddresses)) + for i, ip := range csr.IPAddresses { + ipAddresses[i] = ip.String() + } + uris := make([]string, len(csr.URIs)) + for i, uri := range csr.URIs { + uris[i] = uri.String() + } + + // Extrahiere Extensions + extensions := make([]Extension, 0) + for _, ext := range csr.Extensions { + oidStr := ext.Id.String() + name := getExtensionName(oidStr) + description, purposes := parseExtensionValue(oidStr, ext.Value, csr) + + extension := Extension{ + ID: ext.Id.String(), + OID: oidStr, + Name: name, + Critical: ext.Critical, + Value: hex.EncodeToString(ext.Value), + Description: description, + Purposes: purposes, + } + extensions = append(extensions, extension) + } + + // Konvertiere Slices zu JSON-Strings für DB + dnsNamesJSON, _ := json.Marshal(dnsNames) + emailAddressesJSON, _ := json.Marshal(emailAddresses) + ipAddressesJSON, _ := json.Marshal(ipAddresses) + urisJSON, _ := json.Marshal(uris) + extensionsJSON, _ := json.Marshal(extensions) + + // Generiere eindeutige ID + csrID := uuid.New().String() + createdAt := time.Now() + + // Speichere in Datenbank + _, err = db.Exec(` + INSERT INTO csrs ( + id, fqdn_id, space_id, fqdn, csr_pem, subject, + public_key_algorithm, signature_algorithm, key_size, + dns_names, email_addresses, ip_addresses, uris, extensions, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + csrID, fqdnID, spaceID, fqdnName, csrPEM, subject, + publicKeyAlgorithm, signatureAlgorithm, keySize, + string(dnsNamesJSON), string(emailAddressesJSON), string(ipAddressesJSON), string(urisJSON), string(extensionsJSON), createdAt, + ) + if err != nil { + http.Error(w, "Fehler beim Speichern des CSR", http.StatusInternalServerError) + log.Printf("Fehler beim Speichern des CSR: %v", err) + return + } + + newCSR := CSR{ + ID: csrID, + FQDNID: fqdnID, + SpaceID: spaceID, + FQDN: fqdnName, + CSRPEM: csrPEM, + Subject: subject, + PublicKeyAlgorithm: publicKeyAlgorithm, + SignatureAlgorithm: signatureAlgorithm, + KeySize: keySize, + DNSNames: dnsNames, + EmailAddresses: emailAddresses, + IPAddresses: ipAddresses, + URIs: uris, + Extensions: extensions, + CreatedAt: createdAt.Format(time.RFC3339), + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(newCSR) +} + +func getCSRByFQDNHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + vars := mux.Vars(r) + spaceID := vars["spaceId"] + fqdnID := vars["fqdnId"] + + if spaceID == "" || fqdnID == "" { + http.Error(w, "spaceId und fqdnId sind erforderlich", http.StatusBadRequest) + return + } + + // Prüfe ob nur der neueste CSR gewünscht ist + latestOnly := r.URL.Query().Get("latest") == "true" + + if latestOnly { + // Hole nur den neuesten CSR + var csr CSR + var createdAt time.Time + var dnsNamesJSON, emailAddressesJSON, ipAddressesJSON, urisJSON, extensionsJSON sql.NullString + + err := db.QueryRow(` + SELECT id, fqdn_id, space_id, fqdn, csr_pem, subject, + public_key_algorithm, signature_algorithm, key_size, + dns_names, email_addresses, ip_addresses, uris, extensions, created_at + FROM csrs + WHERE fqdn_id = ? AND space_id = ? + ORDER BY created_at DESC + LIMIT 1 + `, fqdnID, spaceID).Scan( + &csr.ID, &csr.FQDNID, &csr.SpaceID, &csr.FQDN, &csr.CSRPEM, &csr.Subject, + &csr.PublicKeyAlgorithm, &csr.SignatureAlgorithm, &csr.KeySize, + &dnsNamesJSON, &emailAddressesJSON, &ipAddressesJSON, &urisJSON, &extensionsJSON, &createdAt, + ) + + if err != nil { + if err == sql.ErrNoRows { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(nil) + return + } + http.Error(w, "Fehler beim Abrufen des CSR", http.StatusInternalServerError) + log.Printf("Fehler beim Abrufen des CSR: %v", err) + return + } + + // Parse JSON-Strings zurück zu Slices + json.Unmarshal([]byte(dnsNamesJSON.String), &csr.DNSNames) + json.Unmarshal([]byte(emailAddressesJSON.String), &csr.EmailAddresses) + json.Unmarshal([]byte(ipAddressesJSON.String), &csr.IPAddresses) + json.Unmarshal([]byte(urisJSON.String), &csr.URIs) + if extensionsJSON.Valid { + json.Unmarshal([]byte(extensionsJSON.String), &csr.Extensions) + } else { + csr.Extensions = []Extension{} + } + csr.CreatedAt = createdAt.Format(time.RFC3339) + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(csr) + } else { + // Hole alle CSRs für diesen FQDN + rows, err := db.Query(` + SELECT id, fqdn_id, space_id, fqdn, csr_pem, subject, + public_key_algorithm, signature_algorithm, key_size, + dns_names, email_addresses, ip_addresses, uris, extensions, created_at + FROM csrs + WHERE fqdn_id = ? AND space_id = ? + ORDER BY created_at DESC + `, fqdnID, spaceID) + + if err != nil { + http.Error(w, "Fehler beim Abrufen der CSRs", http.StatusInternalServerError) + log.Printf("Fehler beim Abrufen der CSRs: %v", err) + return + } + defer rows.Close() + + var csrs []CSR + for rows.Next() { + var csr CSR + var createdAt time.Time + var dnsNamesJSON, emailAddressesJSON, ipAddressesJSON, urisJSON string + var extensionsJSON sql.NullString + + err := rows.Scan( + &csr.ID, &csr.FQDNID, &csr.SpaceID, &csr.FQDN, &csr.CSRPEM, &csr.Subject, + &csr.PublicKeyAlgorithm, &csr.SignatureAlgorithm, &csr.KeySize, + &dnsNamesJSON, &emailAddressesJSON, &ipAddressesJSON, &urisJSON, &extensionsJSON, &createdAt, + ) + if err != nil { + http.Error(w, "Fehler beim Lesen der Daten", http.StatusInternalServerError) + log.Printf("Fehler beim Lesen der Daten: %v", err) + return + } + + // Parse JSON-Strings zurück zu Slices + json.Unmarshal([]byte(dnsNamesJSON), &csr.DNSNames) + json.Unmarshal([]byte(emailAddressesJSON), &csr.EmailAddresses) + json.Unmarshal([]byte(ipAddressesJSON), &csr.IPAddresses) + json.Unmarshal([]byte(urisJSON), &csr.URIs) + if extensionsJSON.Valid { + json.Unmarshal([]byte(extensionsJSON.String), &csr.Extensions) + } else { + csr.Extensions = []Extension{} + } + csr.CreatedAt = createdAt.Format(time.RFC3339) + + csrs = append(csrs, csr) + } + + if err = rows.Err(); err != nil { + http.Error(w, "Fehler beim Verarbeiten der Daten", http.StatusInternalServerError) + log.Printf("Fehler beim Verarbeiten der Daten: %v", err) + return + } + + if csrs == nil { + csrs = []CSR{} + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(csrs) + } +} + +func swaggerUIHandler(w http.ResponseWriter, r *http.Request) { + html := ` + + + + Certigo Addon API - Swagger UI + + + + +
+ + + + +` + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(html)) +} + +func openAPIHandler(w http.ResponseWriter, r *http.Request) { + // Lese die OpenAPI YAML Datei + openAPIContent := `openapi: 3.0.3 +info: + title: Certigo Addon API + description: API für die Verwaltung von Spaces, FQDNs und Certificate Signing Requests (CSRs) + version: 1.0.0 + contact: + name: Certigo Addon +servers: + - url: http://localhost:8080/api + description: Local development server +paths: + /health: + get: + summary: System Health Check + description: Prüft den Systemstatus des Backends + tags: [System] + responses: + '200': + description: System ist erreichbar + content: + application/json: + schema: + $ref: '#/components/schemas/HealthResponse' + /stats: + get: + summary: Statistiken abrufen + description: Ruft Statistiken über die Anzahl der Spaces, FQDNs und CSRs ab + tags: [System] + responses: + '200': + description: Statistiken erfolgreich abgerufen + content: + application/json: + schema: + $ref: '#/components/schemas/StatsResponse' + /spaces: + get: + summary: Alle Spaces abrufen + description: Ruft eine Liste aller Spaces ab + tags: [Spaces] + responses: + '200': + description: Liste der Spaces + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Space' + post: + summary: Space erstellen + description: Erstellt einen neuen Space + tags: [Spaces] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSpaceRequest' + responses: + '201': + description: Space erfolgreich erstellt + content: + application/json: + schema: + $ref: '#/components/schemas/Space' + '400': + description: Ungültige Anfrage + /spaces/{id}: + delete: + summary: Space löschen + description: Löscht einen Space. Wenn der Space FQDNs enthält, muss der Parameter deleteFqdns=true gesetzt werden. + tags: [Spaces] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: deleteFqdns + in: query + required: false + schema: + type: boolean + default: false + description: Wenn true, werden alle FQDNs des Spaces mitgelöscht + responses: + '200': + description: Space erfolgreich gelöscht + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '404': + description: Space nicht gefunden + '409': + description: Space enthält noch FQDNs + /spaces/{id}/fqdns/count: + get: + summary: FQDN-Anzahl abrufen + description: Ruft die Anzahl der FQDNs für einen Space ab + tags: [FQDNs] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Anzahl der FQDNs + content: + application/json: + schema: + $ref: '#/components/schemas/CountResponse' + /spaces/{id}/fqdns: + get: + summary: Alle FQDNs eines Spaces abrufen + description: Ruft alle FQDNs für einen Space ab + tags: [FQDNs] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Liste der FQDNs + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/FQDN' + '404': + description: Space nicht gefunden + post: + summary: FQDN erstellen + description: Erstellt einen neuen FQDN innerhalb eines Spaces + tags: [FQDNs] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateFQDNRequest' + responses: + '201': + description: FQDN erfolgreich erstellt + content: + application/json: + schema: + $ref: '#/components/schemas/FQDN' + '400': + description: Ungültige Anfrage + '404': + description: Space nicht gefunden + '409': + description: FQDN existiert bereits in diesem Space + delete: + summary: Alle FQDNs eines Spaces löschen + description: Löscht alle FQDNs eines Spaces + tags: [FQDNs] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Alle FQDNs erfolgreich gelöscht + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteResponse' + /spaces/{id}/fqdns/{fqdnId}: + delete: + summary: FQDN löschen + description: Löscht einen einzelnen FQDN + tags: [FQDNs] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: fqdnId + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: FQDN erfolgreich gelöscht + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '404': + description: FQDN nicht gefunden + /fqdns: + delete: + summary: Alle FQDNs global löschen + description: Löscht alle FQDNs aus allen Spaces. Erfordert confirm=true Query-Parameter. + tags: [FQDNs] + parameters: + - name: confirm + in: query + required: true + schema: + type: boolean + description: Muss true sein, um die Operation auszuführen + responses: + '200': + description: Alle FQDNs erfolgreich gelöscht + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteResponse' + '400': + description: Bestätigung erforderlich + /csrs: + delete: + summary: Alle CSRs global löschen + description: Löscht alle CSRs aus allen Spaces. Erfordert confirm=true Query-Parameter. + tags: [CSRs] + parameters: + - name: confirm + in: query + required: true + schema: + type: string + description: Muss "true" sein, um die Operation auszuführen + example: "true" + responses: + '200': + description: Alle CSRs erfolgreich gelöscht + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteResponse' + '400': + description: Bestätigung erforderlich + /spaces/{spaceId}/fqdns/{fqdnId}/csr: + post: + summary: CSR hochladen + description: Lädt einen CSR (Certificate Signing Request) im PEM-Format hoch + tags: [CSRs] + parameters: + - name: spaceId + in: path + required: true + schema: + type: string + format: uuid + - name: fqdnId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [csr, spaceId, fqdn] + properties: + csr: + type: string + format: binary + description: CSR-Datei im PEM-Format + spaceId: + type: string + description: ID des Spaces + fqdn: + type: string + description: Name des FQDNs + responses: + '201': + description: CSR erfolgreich hochgeladen + content: + application/json: + schema: + $ref: '#/components/schemas/CSR' + '400': + description: Ungültige Anfrage oder ungültiges CSR-Format + '404': + description: Space oder FQDN nicht gefunden + get: + summary: CSR(s) abrufen + description: Ruft CSR(s) für einen FQDN ab. Mit latest=true wird nur der neueste CSR zurückgegeben. + tags: [CSRs] + parameters: + - name: spaceId + in: path + required: true + schema: + type: string + format: uuid + - name: fqdnId + in: path + required: true + schema: + type: string + format: uuid + - name: latest + in: query + required: false + schema: + type: boolean + default: false + description: Wenn true, wird nur der neueste CSR zurückgegeben + responses: + '200': + description: CSR(s) erfolgreich abgerufen + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/CSR' + - type: array + items: + $ref: '#/components/schemas/CSR' + '404': + description: FQDN nicht gefunden +components: + schemas: + HealthResponse: + type: object + properties: + status: + type: string + example: "ok" + message: + type: string + example: "Backend ist erreichbar" + time: + type: string + format: date-time + example: "2024-01-15T10:30:00Z" + StatsResponse: + type: object + properties: + spaces: + type: integer + example: 5 + fqdns: + type: integer + example: 12 + csrs: + type: integer + example: 7 + Space: + type: object + properties: + id: + type: string + format: uuid + example: "550e8400-e29b-41d4-a716-446655440000" + name: + type: string + example: "Mein Space" + description: + type: string + example: "Beschreibung des Spaces" + createdAt: + type: string + format: date-time + example: "2024-01-15T10:30:00Z" + CreateSpaceRequest: + type: object + required: [name] + properties: + name: + type: string + example: "Mein Space" + description: + type: string + example: "Beschreibung des Spaces" + FQDN: + type: object + properties: + id: + type: string + format: uuid + example: "660e8400-e29b-41d4-a716-446655440000" + spaceId: + type: string + format: uuid + example: "550e8400-e29b-41d4-a716-446655440000" + fqdn: + type: string + example: "example.com" + description: + type: string + example: "Beschreibung des FQDN" + createdAt: + type: string + format: date-time + example: "2024-01-15T10:30:00Z" + CreateFQDNRequest: + type: object + required: [fqdn] + properties: + fqdn: + type: string + example: "example.com" + description: + type: string + example: "Beschreibung des FQDN" + Extension: + type: object + properties: + id: + type: string + example: "2.5.29.37" + oid: + type: string + example: "2.5.29.37" + name: + type: string + example: "X509v3 Extended Key Usage" + critical: + type: boolean + example: false + value: + type: string + example: "301406082b0601050507030106082b06010505070302" + description: + type: string + example: "TLS Web Server Authentication" + purposes: + type: array + items: + type: string + example: ["TLS Web Server Authentication", "TLS Web Client Authentication"] + CSR: + type: object + properties: + id: + type: string + format: uuid + example: "770e8400-e29b-41d4-a716-446655440000" + fqdnId: + type: string + format: uuid + example: "660e8400-e29b-41d4-a716-446655440000" + spaceId: + type: string + format: uuid + example: "550e8400-e29b-41d4-a716-446655440000" + fqdn: + type: string + example: "example.com" + csrPem: + type: string + example: "-----BEGIN CERTIFICATE REQUEST-----" + subject: + type: string + example: "CN=example.com" + publicKeyAlgorithm: + type: string + example: "RSA" + signatureAlgorithm: + type: string + example: "SHA256-RSA" + keySize: + type: integer + example: 2048 + dnsNames: + type: array + items: + type: string + example: ["example.com", "www.example.com"] + emailAddresses: + type: array + items: + type: string + example: ["admin@example.com"] + ipAddresses: + type: array + items: + type: string + example: ["192.168.1.1"] + uris: + type: array + items: + type: string + example: ["https://example.com"] + extensions: + type: array + items: + $ref: '#/components/schemas/Extension' + createdAt: + type: string + format: date-time + example: "2024-01-15T10:30:00Z" + MessageResponse: + type: object + properties: + message: + type: string + example: "Operation erfolgreich" + CountResponse: + type: object + properties: + count: + type: integer + example: 5 + DeleteResponse: + type: object + properties: + message: + type: string + example: "Alle FQDNs erfolgreich gelöscht" + deletedCount: + type: integer + example: 5` + w.Header().Set("Content-Type", "application/x-yaml") + w.Write([]byte(openAPIContent)) +} + +func main() { + log.Println("Starte certigo-addon Backend...") + + // Initialisiere Datenbank + log.Println("Initialisiere Datenbank...") + initDB() + defer func() { + log.Println("Schließe Datenbankverbindung...") + db.Close() + }() + log.Println("Datenbank initialisiert") + + // Initialisiere Provider + pm := providers.GetManager() + pm.RegisterProvider(providers.NewDummyCAProvider()) + pm.RegisterProvider(providers.NewAutoDNSProvider()) + pm.RegisterProvider(providers.NewHetznerProvider()) + + r := mux.NewRouter() + + // Swagger UI Route + r.HandleFunc("/swagger", swaggerUIHandler).Methods("GET") + r.HandleFunc("/api/openapi.yaml", openAPIHandler).Methods("GET") + + // API Routes + api := r.PathPrefix("/api").Subrouter() + api.HandleFunc("/health", healthHandler).Methods("GET", "OPTIONS") + api.HandleFunc("/stats", getStatsHandler).Methods("GET", "OPTIONS") + api.HandleFunc("/spaces", getSpacesHandler).Methods("GET", "OPTIONS") + api.HandleFunc("/spaces", createSpaceHandler).Methods("POST", "OPTIONS") + api.HandleFunc("/spaces/{id}", deleteSpaceHandler).Methods("DELETE", "OPTIONS") + api.HandleFunc("/spaces/{id}/fqdns/count", getSpaceFqdnCountHandler).Methods("GET", "OPTIONS") + api.HandleFunc("/spaces/{id}/fqdns", getFqdnsHandler).Methods("GET", "OPTIONS") + api.HandleFunc("/spaces/{id}/fqdns", createFqdnHandler).Methods("POST", "OPTIONS") + api.HandleFunc("/spaces/{id}/fqdns", deleteAllFqdnsHandler).Methods("DELETE", "OPTIONS") + api.HandleFunc("/spaces/{id}/fqdns/{fqdnId}", deleteFqdnHandler).Methods("DELETE", "OPTIONS") + api.HandleFunc("/fqdns", deleteAllFqdnsGlobalHandler).Methods("DELETE", "OPTIONS") + api.HandleFunc("/spaces/{spaceId}/fqdns/{fqdnId}/csr", uploadCSRHandler).Methods("POST", "OPTIONS") + api.HandleFunc("/spaces/{spaceId}/fqdns/{fqdnId}/csr", getCSRByFQDNHandler).Methods("GET", "OPTIONS") + api.HandleFunc("/csrs", deleteAllCSRsHandler).Methods("DELETE", "OPTIONS") + + // Provider Routes + api.HandleFunc("/providers", getProvidersHandler).Methods("GET", "OPTIONS") + api.HandleFunc("/providers/{id}", getProviderHandler).Methods("GET", "OPTIONS") + api.HandleFunc("/providers/{id}/enabled", setProviderEnabledHandler).Methods("PUT", "OPTIONS") + api.HandleFunc("/providers/{id}/config", updateProviderConfigHandler).Methods("PUT", "OPTIONS") + api.HandleFunc("/providers/{id}/test", testProviderConnectionHandler).Methods("POST", "OPTIONS") + api.HandleFunc("/spaces/{spaceId}/fqdns/{fqdnId}/csr/sign", signCSRHandler).Methods("POST", "OPTIONS") + api.HandleFunc("/spaces/{spaceId}/fqdns/{fqdnId}/certificates", getCertificatesHandler).Methods("GET", "OPTIONS") + api.HandleFunc("/spaces/{spaceId}/fqdns/{fqdnId}/certificates/{certId}/refresh", refreshCertificateHandler).Methods("POST", "OPTIONS") + + // Start server + port := ":8080" + log.Printf("Server läuft auf Port %s", port) + log.Fatal(http.ListenAndServe(port, r)) +} + +// Provider Handlers + +func getProvidersHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + pm := providers.GetManager() + allProviders := pm.GetAllProviders() + + // Definiere feste Reihenfolge der Provider + providerOrder := []string{"dummy-ca", "autodns", "hetzner"} + + // Erstelle Map für schnellen Zugriff + providerMap := make(map[string]providers.ProviderInfo) + for id, provider := range allProviders { + config, _ := pm.GetProviderConfig(id) + providerInfo := providers.ProviderInfo{ + ID: id, + Name: provider.GetName(), + DisplayName: provider.GetDisplayName(), + Description: provider.GetDescription(), + Enabled: config.Enabled, + Settings: provider.GetRequiredSettings(), + } + providerMap[id] = providerInfo + } + + // Sortiere nach definierter Reihenfolge + var providerInfos []providers.ProviderInfo + for _, id := range providerOrder { + if providerInfo, exists := providerMap[id]; exists { + providerInfos = append(providerInfos, providerInfo) + delete(providerMap, id) + } + } + + // Füge alle anderen Provider hinzu, die nicht in der Liste sind + for _, providerInfo := range providerMap { + providerInfos = append(providerInfos, providerInfo) + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(providerInfos) +} + +func getProviderHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + vars := mux.Vars(r) + id := vars["id"] + + pm := providers.GetManager() + provider, exists := pm.GetProvider(id) + if !exists { + http.Error(w, "Provider nicht gefunden", http.StatusNotFound) + return + } + + config, _ := pm.GetProviderConfig(id) + providerInfo := providers.ProviderInfo{ + ID: id, + Name: provider.GetName(), + DisplayName: provider.GetDisplayName(), + Description: provider.GetDescription(), + Enabled: config.Enabled, + Settings: provider.GetRequiredSettings(), + } + + // Füge aktuelle Konfigurationswerte hinzu (ohne Passwörter) + safeSettings := make(map[string]interface{}) + for key, value := range config.Settings { + // Verstecke Passwörter und API Keys in der Antwort + if key == "password" || key == "apiKey" { + if str, ok := value.(string); ok && str != "" { + safeSettings[key] = "***" + } else { + safeSettings[key] = value + } + } else { + safeSettings[key] = value + } + } + + // Konvertiere zu JSON für die Response + safeSettingsJSON, _ := json.Marshal(safeSettings) + var safeSettingsMap map[string]interface{} + json.Unmarshal(safeSettingsJSON, &safeSettingsMap) + + response := map[string]interface{}{ + "id": providerInfo.ID, + "name": providerInfo.Name, + "displayName": providerInfo.DisplayName, + "description": providerInfo.Description, + "enabled": providerInfo.Enabled, + "settings": providerInfo.Settings, + "config": safeSettingsMap, + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} + +func setProviderEnabledHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "PUT, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + vars := mux.Vars(r) + id := vars["id"] + + var req struct { + Enabled bool `json:"enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + pm := providers.GetManager() + if err := pm.SetProviderEnabled(id, req.Enabled); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Provider-Status erfolgreich aktualisiert", + "enabled": req.Enabled, + }) +} + +func updateProviderConfigHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "PUT, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + vars := mux.Vars(r) + id := vars["id"] + + var req struct { + Settings map[string]interface{} `json:"settings"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + pm := providers.GetManager() + config, _ := pm.GetProviderConfig(id) + config.Settings = req.Settings + + if err := pm.UpdateProviderConfig(id, config); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Konfiguration erfolgreich aktualisiert", + }) +} + +func testProviderConnectionHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + vars := mux.Vars(r) + id := vars["id"] + + var req struct { + Settings map[string]interface{} `json:"settings"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + pm := providers.GetManager() + provider, exists := pm.GetProvider(id) + if !exists { + http.Error(w, "Provider nicht gefunden", http.StatusNotFound) + return + } + + if err := provider.TestConnection(req.Settings); err != nil { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": err.Error(), + }) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Verbindung erfolgreich", + }) +} + +func signCSRHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + vars := mux.Vars(r) + spaceID := vars["spaceId"] + fqdnID := vars["fqdnId"] + + var req struct { + ProviderID string `json:"providerId"` + CSRID string `json:"csrId,omitempty"` // Optional: spezifischer CSR, sonst neuester + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.ProviderID == "" { + http.Error(w, "providerId ist erforderlich", http.StatusBadRequest) + return + } + + // Hole neuesten CSR für den FQDN + var csrPEM string + var csrID string + err := db.QueryRow(` + SELECT id, csr_pem + FROM csrs + WHERE fqdn_id = ? AND space_id = ? + ORDER BY created_at DESC + LIMIT 1 + `, fqdnID, spaceID).Scan(&csrID, &csrPEM) + + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "Kein CSR für diesen FQDN gefunden", http.StatusNotFound) + return + } + http.Error(w, "Fehler beim Laden des CSR", http.StatusInternalServerError) + log.Printf("Fehler beim Laden des CSR: %v", err) + return + } + + // Wenn spezifischer CSR angefordert wurde + if req.CSRID != "" && req.CSRID != csrID { + err := db.QueryRow(` + SELECT csr_pem + FROM csrs + WHERE id = ? AND fqdn_id = ? AND space_id = ? + `, req.CSRID, fqdnID, spaceID).Scan(&csrPEM) + + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "CSR nicht gefunden", http.StatusNotFound) + return + } + http.Error(w, "Fehler beim Laden des CSR", http.StatusInternalServerError) + return + } + csrID = req.CSRID + } + + // Hole Provider + pm := providers.GetManager() + provider, exists := pm.GetProvider(req.ProviderID) + if !exists { + http.Error(w, "Provider nicht gefunden", http.StatusNotFound) + return + } + + // Prüfe ob Provider aktiviert ist + config, err := pm.GetProviderConfig(req.ProviderID) + if err != nil || !config.Enabled { + http.Error(w, "Provider ist nicht aktiviert", http.StatusBadRequest) + return + } + + // Signiere CSR + result, err := provider.SignCSR(csrPEM, config.Settings) + if err != nil { + http.Error(w, fmt.Sprintf("Fehler beim Signieren des CSR: %v", err), http.StatusInternalServerError) + log.Printf("Fehler beim Signieren des CSR: %v", err) + return + } + + // Speichere das Zertifikat in der DB + certID := uuid.New().String() + createdAt := time.Now() + _, err = db.Exec(` + INSERT INTO certificates (id, fqdn_id, space_id, csr_id, certificate_id, provider_id, certificate_pem, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, certID, fqdnID, spaceID, csrID, result.OrderID, req.ProviderID, result.CertificatePEM, result.Status, createdAt) + + if err != nil { + log.Printf("Fehler beim Speichern des Zertifikats: %v", err) + // Weiterhin erfolgreich zurückgeben, auch wenn Speichern fehlschlägt + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": result.Message, + "certificateId": certID, + "orderId": result.OrderID, + "status": result.Status, + "csrId": csrID, + }) +} + +func getCertificatesHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + vars := mux.Vars(r) + spaceID := vars["spaceId"] + fqdnID := vars["fqdnId"] + + // Hole alle Zertifikate für diesen FQDN + rows, err := db.Query(` + SELECT id, csr_id, certificate_id, provider_id, certificate_pem, status, created_at + FROM certificates + WHERE fqdn_id = ? AND space_id = ? + ORDER BY created_at DESC + `, fqdnID, spaceID) + + if err != nil { + http.Error(w, "Fehler beim Laden der Zertifikate", http.StatusInternalServerError) + log.Printf("Fehler beim Laden der Zertifikate: %v", err) + return + } + defer rows.Close() + + var certificates []map[string]interface{} + for rows.Next() { + var id, csrID, certID, providerID, certPEM, status, createdAt string + err := rows.Scan(&id, &csrID, &certID, &providerID, &certPEM, &status, &createdAt) + if err != nil { + log.Printf("Fehler beim Scannen der Zertifikat-Zeile: %v", err) + continue + } + + certificates = append(certificates, map[string]interface{}{ + "id": id, + "csrId": csrID, + "certificateId": certID, + "providerId": providerID, + "certificatePEM": certPEM, + "status": status, + "createdAt": createdAt, + }) + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(certificates) +} + +func refreshCertificateHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + vars := mux.Vars(r) + spaceID := vars["spaceId"] + fqdnID := vars["fqdnId"] + certID := vars["certId"] + + // Hole Zertifikat aus DB + var certificateID, providerID string + err := db.QueryRow(` + SELECT certificate_id, provider_id + FROM certificates + WHERE id = ? AND fqdn_id = ? AND space_id = ? + `, certID, fqdnID, spaceID).Scan(&certificateID, &providerID) + + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "Zertifikat nicht gefunden", http.StatusNotFound) + return + } + http.Error(w, "Fehler beim Laden des Zertifikats", http.StatusInternalServerError) + return + } + + // Hole Provider + pm := providers.GetManager() + provider, exists := pm.GetProvider(providerID) + if !exists { + http.Error(w, "Provider nicht gefunden", http.StatusNotFound) + return + } + + // Prüfe ob Provider aktiviert ist + config, err := pm.GetProviderConfig(providerID) + if err != nil || !config.Enabled { + http.Error(w, "Provider ist nicht aktiviert", http.StatusBadRequest) + return + } + + // Rufe Zertifikat von CA ab + certPEM, err := provider.GetCertificate(certificateID, config.Settings) + if err != nil { + http.Error(w, fmt.Sprintf("Fehler beim Abrufen des Zertifikats: %v", err), http.StatusInternalServerError) + return + } + + // Aktualisiere Zertifikat in DB + _, err = db.Exec(` + UPDATE certificates + SET certificate_pem = ? + WHERE id = ? AND fqdn_id = ? AND space_id = ? + `, certPEM, certID, fqdnID, spaceID) + + if err != nil { + log.Printf("Fehler beim Aktualisieren des Zertifikats: %v", err) + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "certificatePEM": certPEM, + "certificateId": certificateID, + }) +} diff --git a/backend/myapp b/backend/myapp new file mode 100755 index 0000000..60f232f Binary files /dev/null and b/backend/myapp differ diff --git a/backend/openapi.yaml b/backend/openapi.yaml new file mode 100644 index 0000000..082bebf --- /dev/null +++ b/backend/openapi.yaml @@ -0,0 +1,586 @@ +openapi: 3.0.3 +info: + title: Certigo Addon API + description: API für die Verwaltung von Spaces, FQDNs und Certificate Signing Requests (CSRs) + version: 1.0.0 + contact: + name: Certigo Addon + +servers: + - url: http://localhost:8080/api + description: Local development server + +paths: + /health: + get: + summary: System Health Check + description: Prüft den Systemstatus des Backends + tags: + - System + responses: + '200': + description: System ist erreichbar + content: + application/json: + schema: + $ref: '#/components/schemas/HealthResponse' + + /stats: + get: + summary: Statistiken abrufen + description: Ruft Statistiken über die Anzahl der Spaces, FQDNs und CSRs ab + tags: + - System + responses: + '200': + description: Statistiken erfolgreich abgerufen + content: + application/json: + schema: + $ref: '#/components/schemas/StatsResponse' + + /spaces: + get: + summary: Alle Spaces abrufen + description: Ruft eine Liste aller Spaces ab + tags: + - Spaces + responses: + '200': + description: Liste der Spaces + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Space' + + post: + summary: Space erstellen + description: Erstellt einen neuen Space + tags: + - Spaces + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSpaceRequest' + responses: + '201': + description: Space erfolgreich erstellt + content: + application/json: + schema: + $ref: '#/components/schemas/Space' + '400': + description: Ungültige Anfrage + + /spaces/{id}: + delete: + summary: Space löschen + description: Löscht einen Space. Wenn der Space FQDNs enthält, muss der Parameter deleteFqdns=true gesetzt werden. + tags: + - Spaces + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: deleteFqdns + in: query + required: false + schema: + type: boolean + default: false + description: Wenn true, werden alle FQDNs des Spaces mitgelöscht + responses: + '200': + description: Space erfolgreich gelöscht + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '404': + description: Space nicht gefunden + '409': + description: Space enthält noch FQDNs + + /spaces/{id}/fqdns/count: + get: + summary: FQDN-Anzahl abrufen + description: Ruft die Anzahl der FQDNs für einen Space ab + tags: + - FQDNs + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Anzahl der FQDNs + content: + application/json: + schema: + $ref: '#/components/schemas/CountResponse' + + /spaces/{id}/fqdns: + get: + summary: Alle FQDNs eines Spaces abrufen + description: Ruft alle FQDNs für einen Space ab + tags: + - FQDNs + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Liste der FQDNs + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/FQDN' + '404': + description: Space nicht gefunden + + post: + summary: FQDN erstellen + description: Erstellt einen neuen FQDN innerhalb eines Spaces + tags: + - FQDNs + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateFQDNRequest' + responses: + '201': + description: FQDN erfolgreich erstellt + content: + application/json: + schema: + $ref: '#/components/schemas/FQDN' + '400': + description: Ungültige Anfrage + '404': + description: Space nicht gefunden + '409': + description: FQDN existiert bereits in diesem Space + + delete: + summary: Alle FQDNs eines Spaces löschen + description: Löscht alle FQDNs eines Spaces + tags: + - FQDNs + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Alle FQDNs erfolgreich gelöscht + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteResponse' + + /spaces/{id}/fqdns/{fqdnId}: + delete: + summary: FQDN löschen + description: Löscht einen einzelnen FQDN + tags: + - FQDNs + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: fqdnId + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: FQDN erfolgreich gelöscht + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '404': + description: FQDN nicht gefunden + + /fqdns: + delete: + summary: Alle FQDNs global löschen + description: Löscht alle FQDNs aus allen Spaces. Erfordert confirm=true Query-Parameter. + tags: + - FQDNs + parameters: + - name: confirm + in: query + required: true + schema: + type: boolean + description: Muss true sein, um die Operation auszuführen + responses: + '200': + description: Alle FQDNs erfolgreich gelöscht + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteResponse' + '400': + description: Bestätigung erforderlich + + /csrs: + delete: + summary: Alle CSRs global löschen + description: Löscht alle CSRs aus allen Spaces. Erfordert confirm=true Query-Parameter. + tags: + - CSRs + parameters: + - name: confirm + in: query + required: true + schema: + type: string + description: Muss "true" sein, um die Operation auszuführen + example: "true" + responses: + '200': + description: Alle CSRs erfolgreich gelöscht + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteResponse' + '400': + description: Bestätigung erforderlich + + /spaces/{spaceId}/fqdns/{fqdnId}/csr: + post: + summary: CSR hochladen + description: Lädt einen CSR (Certificate Signing Request) im PEM-Format hoch + tags: + - CSRs + parameters: + - name: spaceId + in: path + required: true + schema: + type: string + format: uuid + - name: fqdnId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - csr + - spaceId + - fqdn + properties: + csr: + type: string + format: binary + description: CSR-Datei im PEM-Format + spaceId: + type: string + description: ID des Spaces + fqdn: + type: string + description: Name des FQDNs + responses: + '201': + description: CSR erfolgreich hochgeladen + content: + application/json: + schema: + $ref: '#/components/schemas/CSR' + '400': + description: Ungültige Anfrage oder ungültiges CSR-Format + '404': + description: Space oder FQDN nicht gefunden + + get: + summary: CSR(s) abrufen + description: Ruft CSR(s) für einen FQDN ab. Mit latest=true wird nur der neueste CSR zurückgegeben. + tags: + - CSRs + parameters: + - name: spaceId + in: path + required: true + schema: + type: string + format: uuid + - name: fqdnId + in: path + required: true + schema: + type: string + format: uuid + - name: latest + in: query + required: false + schema: + type: boolean + default: false + description: Wenn true, wird nur der neueste CSR zurückgegeben + responses: + '200': + description: CSR(s) erfolgreich abgerufen + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/CSR' + - type: array + items: + $ref: '#/components/schemas/CSR' + '404': + description: FQDN nicht gefunden + +components: + schemas: + HealthResponse: + type: object + properties: + status: + type: string + example: "ok" + message: + type: string + example: "Backend ist erreichbar" + time: + type: string + format: date-time + example: "2024-01-15T10:30:00Z" + + StatsResponse: + type: object + properties: + spaces: + type: integer + example: 5 + fqdns: + type: integer + example: 12 + csrs: + type: integer + example: 7 + + Space: + type: object + properties: + id: + type: string + format: uuid + example: "550e8400-e29b-41d4-a716-446655440000" + name: + type: string + example: "Mein Space" + description: + type: string + example: "Beschreibung des Spaces" + createdAt: + type: string + format: date-time + example: "2024-01-15T10:30:00Z" + + CreateSpaceRequest: + type: object + required: + - name + properties: + name: + type: string + example: "Mein Space" + description: + type: string + example: "Beschreibung des Spaces" + + FQDN: + type: object + properties: + id: + type: string + format: uuid + example: "660e8400-e29b-41d4-a716-446655440000" + spaceId: + type: string + format: uuid + example: "550e8400-e29b-41d4-a716-446655440000" + fqdn: + type: string + example: "example.com" + description: + type: string + example: "Beschreibung des FQDN" + createdAt: + type: string + format: date-time + example: "2024-01-15T10:30:00Z" + + CreateFQDNRequest: + type: object + required: + - fqdn + properties: + fqdn: + type: string + example: "example.com" + description: + type: string + example: "Beschreibung des FQDN" + + Extension: + type: object + properties: + id: + type: string + example: "2.5.29.37" + oid: + type: string + example: "2.5.29.37" + name: + type: string + example: "X509v3 Extended Key Usage" + critical: + type: boolean + example: false + value: + type: string + example: "301406082b0601050507030106082b06010505070302" + description: + type: string + example: "TLS Web Server Authentication\n TLS Web Client Authentication" + purposes: + type: array + items: + type: string + example: ["TLS Web Server Authentication", "TLS Web Client Authentication"] + + CSR: + type: object + properties: + id: + type: string + format: uuid + example: "770e8400-e29b-41d4-a716-446655440000" + fqdnId: + type: string + format: uuid + example: "660e8400-e29b-41d4-a716-446655440000" + spaceId: + type: string + format: uuid + example: "550e8400-e29b-41d4-a716-446655440000" + fqdn: + type: string + example: "example.com" + csrPem: + type: string + example: "-----BEGIN CERTIFICATE REQUEST-----\n...\n-----END CERTIFICATE REQUEST-----" + subject: + type: string + example: "CN=example.com" + publicKeyAlgorithm: + type: string + example: "RSA" + signatureAlgorithm: + type: string + example: "SHA256-RSA" + keySize: + type: integer + example: 2048 + dnsNames: + type: array + items: + type: string + example: ["example.com", "www.example.com"] + emailAddresses: + type: array + items: + type: string + example: ["admin@example.com"] + ipAddresses: + type: array + items: + type: string + example: ["192.168.1.1"] + uris: + type: array + items: + type: string + example: ["https://example.com"] + extensions: + type: array + items: + $ref: '#/components/schemas/Extension' + createdAt: + type: string + format: date-time + example: "2024-01-15T10:30:00Z" + + MessageResponse: + type: object + properties: + message: + type: string + example: "Operation erfolgreich" + + CountResponse: + type: object + properties: + count: + type: integer + example: 5 + + DeleteResponse: + type: object + properties: + message: + type: string + example: "Alle FQDNs erfolgreich gelöscht" + deletedCount: + type: integer + example: 5 + + securitySchemes: + {}: + type: http + scheme: none + diff --git a/backend/providers/autodns.go b/backend/providers/autodns.go new file mode 100644 index 0000000..bf5e4b4 --- /dev/null +++ b/backend/providers/autodns.go @@ -0,0 +1,76 @@ +package providers + +import ( + "fmt" + "strings" +) + +// AutoDNSProvider ist der Provider für AutoDNS +type AutoDNSProvider struct{} + +func NewAutoDNSProvider() *AutoDNSProvider { + return &AutoDNSProvider{} +} + +func (p *AutoDNSProvider) GetName() string { + return "autodns" +} + +func (p *AutoDNSProvider) GetDisplayName() string { + return "AutoDNS" +} + +func (p *AutoDNSProvider) GetDescription() string { + return "AutoDNS SSL Certificate Provider" +} + +func (p *AutoDNSProvider) ValidateConfig(settings map[string]interface{}) error { + username, ok := settings["username"].(string) + if !ok || strings.TrimSpace(username) == "" { + return fmt.Errorf("username ist erforderlich") + } + + password, ok := settings["password"].(string) + if !ok || strings.TrimSpace(password) == "" { + return fmt.Errorf("password ist erforderlich") + } + + return nil +} + +func (p *AutoDNSProvider) TestConnection(settings map[string]interface{}) error { + // Hier würde die tatsächliche Verbindung zu AutoDNS getestet werden + // Für jetzt nur Validierung + return p.ValidateConfig(settings) +} + +// GetRequiredSettings gibt die erforderlichen Einstellungen zurück +func (p *AutoDNSProvider) GetRequiredSettings() []SettingField { + return []SettingField{ + { + Name: "username", + Label: "Benutzername", + Type: "text", + Required: true, + Description: "AutoDNS Benutzername", + }, + { + Name: "password", + Label: "Passwort", + Type: "password", + Required: true, + Description: "AutoDNS Passwort", + }, + } +} + +// SignCSR signiert einen CSR (noch nicht implementiert) +func (p *AutoDNSProvider) SignCSR(csrPEM string, settings map[string]interface{}) (*SignCSRResult, error) { + return nil, fmt.Errorf("AutoDNS CSR-Signierung noch nicht implementiert") +} + +// GetCertificate ruft ein Zertifikat ab (noch nicht implementiert) +func (p *AutoDNSProvider) GetCertificate(certificateID string, settings map[string]interface{}) (string, error) { + return "", fmt.Errorf("AutoDNS Zertifikat-Abruf noch nicht implementiert") +} + diff --git a/backend/providers/dummy.go b/backend/providers/dummy.go new file mode 100644 index 0000000..2bf1282 --- /dev/null +++ b/backend/providers/dummy.go @@ -0,0 +1,205 @@ +package providers + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// DummyCAProvider ist ein Dummy-Provider für Tests +type DummyCAProvider struct { + baseURL string +} + +func NewDummyCAProvider() *DummyCAProvider { + return &DummyCAProvider{ + baseURL: "http://localhost:8088", + } +} + +func (p *DummyCAProvider) GetName() string { + return "dummy-ca" +} + +func (p *DummyCAProvider) GetDisplayName() string { + return "Dummy CA" +} + +func (p *DummyCAProvider) GetDescription() string { + return "Externe Dummy CA für Tests und Entwicklung (http://localhost:8088)" +} + +func (p *DummyCAProvider) ValidateConfig(settings map[string]interface{}) error { + // Dummy-Provider benötigt keine Konfiguration + return nil +} + +func (p *DummyCAProvider) TestConnection(settings map[string]interface{}) error { + // Teste Verbindung zur externen CA über Health Check + url := fmt.Sprintf("%s/health", p.baseURL) + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("CA-Server nicht erreichbar: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("CA-Server antwortet mit Status %d", resp.StatusCode) + } + + // Prüfe Response Body + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("fehler beim Lesen der Health-Check-Response: %v", err) + } + + var healthResponse struct { + Status string `json:"status"` + } + if err := json.Unmarshal(body, &healthResponse); err != nil { + return fmt.Errorf("ungültige Health-Check-Response: %v", err) + } + + if healthResponse.Status != "ok" { + return fmt.Errorf("CA-Server meldet Status: %s", healthResponse.Status) + } + + return nil +} + +// GetRequiredSettings gibt die erforderlichen Einstellungen zurück +func (p *DummyCAProvider) GetRequiredSettings() []SettingField { + return []SettingField{} +} + +// SignCSR signiert einen CSR über die externe Dummy CA API +func (p *DummyCAProvider) SignCSR(csrPEM string, settings map[string]interface{}) (*SignCSRResult, error) { + // Entferne mögliche Whitespace am Anfang/Ende + csrPEM = strings.TrimSpace(csrPEM) + + // Base64-kodiere den CSR + csrB64 := base64.StdEncoding.EncodeToString([]byte(csrPEM)) + + // Erstelle Request Body + requestBody := map[string]interface{}{ + "csr": csrB64, + "action": "sign", + "validity_days": 365, + } + + // Konvertiere zu JSON + jsonData, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("fehler beim Erstellen des Request-Body: %v", err) + } + + // Erstelle HTTP Request + url := fmt.Sprintf("%s/csr", p.baseURL) + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("fehler beim Erstellen des HTTP-Requests: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + + // Führe Request aus + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fehler beim Senden des Requests an die CA: %v", err) + } + defer resp.Body.Close() + + // Lese Response Body + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("fehler beim Lesen der Response: %v", err) + } + + // Prüfe Status Code + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("CA-API Fehler (Status %d): %s", resp.StatusCode, string(body)) + } + + // Parse Response + var apiResponse struct { + ID string `json:"id"` + Status string `json:"status"` + Message string `json:"message"` + Certificate string `json:"certificate"` + Error string `json:"error"` + } + + if err := json.Unmarshal(body, &apiResponse); err != nil { + return nil, fmt.Errorf("fehler beim Parsen der Response: %v", err) + } + + // Prüfe auf Fehler in der Response + if apiResponse.Error != "" { + return nil, fmt.Errorf("CA-API Fehler: %s", apiResponse.Error) + } + + // Prüfe Status + if apiResponse.Status != "success" { + return nil, fmt.Errorf("CSR-Signierung fehlgeschlagen: %s", apiResponse.Message) + } + + // Rückgabe des Ergebnisses + return &SignCSRResult{ + CertificatePEM: apiResponse.Certificate, + OrderID: apiResponse.ID, + Status: "issued", + Message: apiResponse.Message, + }, nil +} + +// GetCertificate ruft ein Zertifikat über die externe Dummy CA API ab +func (p *DummyCAProvider) GetCertificate(certificateID string, settings map[string]interface{}) (string, error) { + if certificateID == "" { + return "", fmt.Errorf("zertifikat-ID ist erforderlich") + } + + // Erstelle HTTP Request + url := fmt.Sprintf("%s/certificate/%s", p.baseURL, certificateID) + resp, err := http.Get(url) + if err != nil { + return "", fmt.Errorf("fehler beim Abrufen des Zertifikats: %v", err) + } + defer resp.Body.Close() + + // Lese Response Body + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("fehler beim Lesen der Response: %v", err) + } + + // Prüfe Status Code + if resp.StatusCode == http.StatusNotFound { + return "", fmt.Errorf("Zertifikat mit ID %s nicht gefunden", certificateID) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("CA-API Fehler (Status %d): %s", resp.StatusCode, string(body)) + } + + // Parse Response + var apiResponse struct { + ID string `json:"id"` + Certificate string `json:"certificate"` + CreatedAt string `json:"created_at"` + } + + if err := json.Unmarshal(body, &apiResponse); err != nil { + return "", fmt.Errorf("fehler beim Parsen der Response: %v", err) + } + + if apiResponse.Certificate == "" { + return "", fmt.Errorf("zertifikat in Response nicht gefunden") + } + + return apiResponse.Certificate, nil +} diff --git a/backend/providers/hetzner.go b/backend/providers/hetzner.go new file mode 100644 index 0000000..3d5661d --- /dev/null +++ b/backend/providers/hetzner.go @@ -0,0 +1,63 @@ +package providers + +import ( + "fmt" + "strings" +) + +// HetznerProvider ist der Provider für Hetzner +type HetznerProvider struct{} + +func NewHetznerProvider() *HetznerProvider { + return &HetznerProvider{} +} + +func (p *HetznerProvider) GetName() string { + return "hetzner" +} + +func (p *HetznerProvider) GetDisplayName() string { + return "Hetzner" +} + +func (p *HetznerProvider) GetDescription() string { + return "Hetzner SSL Certificate Provider" +} + +func (p *HetznerProvider) ValidateConfig(settings map[string]interface{}) error { + apiKey, ok := settings["apiKey"].(string) + if !ok || strings.TrimSpace(apiKey) == "" { + return fmt.Errorf("apiKey ist erforderlich") + } + + return nil +} + +func (p *HetznerProvider) TestConnection(settings map[string]interface{}) error { + // Hier würde die tatsächliche Verbindung zu Hetzner getestet werden + // Für jetzt nur Validierung + return p.ValidateConfig(settings) +} + +// GetRequiredSettings gibt die erforderlichen Einstellungen zurück +func (p *HetznerProvider) GetRequiredSettings() []SettingField { + return []SettingField{ + { + Name: "apiKey", + Label: "API Key", + Type: "password", + Required: true, + Description: "Hetzner API Key", + }, + } +} + +// SignCSR signiert einen CSR (noch nicht implementiert) +func (p *HetznerProvider) SignCSR(csrPEM string, settings map[string]interface{}) (*SignCSRResult, error) { + return nil, fmt.Errorf("Hetzner CSR-Signierung noch nicht implementiert") +} + +// GetCertificate ruft ein Zertifikat ab (noch nicht implementiert) +func (p *HetznerProvider) GetCertificate(certificateID string, settings map[string]interface{}) (string, error) { + return "", fmt.Errorf("Hetzner Zertifikat-Abruf noch nicht implementiert") +} diff --git a/backend/providers/provider.go b/backend/providers/provider.go new file mode 100644 index 0000000..e9ae57e --- /dev/null +++ b/backend/providers/provider.go @@ -0,0 +1,214 @@ +package providers + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" +) + +// ProviderConfig enthält die Konfiguration eines Providers +type ProviderConfig struct { + Enabled bool `json:"enabled"` + Settings map[string]interface{} `json:"settings"` +} + +// SignCSRResult enthält das Ergebnis einer CSR-Signierung +type SignCSRResult struct { + CertificatePEM string `json:"certificatePEM"` + OrderID string `json:"orderId,omitempty"` + Status string `json:"status"` + Message string `json:"message,omitempty"` +} + +// Provider Interface für alle SSL Certificate Provider +type Provider interface { + // GetName gibt den Namen des Providers zurück + GetName() string + // GetDisplayName gibt den Anzeigenamen zurück + GetDisplayName() string + // GetDescription gibt eine Beschreibung zurück + GetDescription() string + // ValidateConfig validiert die Konfiguration + ValidateConfig(settings map[string]interface{}) error + // TestConnection testet die Verbindung zum Provider + TestConnection(settings map[string]interface{}) error + // GetRequiredSettings gibt die erforderlichen Einstellungen zurück + GetRequiredSettings() []SettingField + // SignCSR signiert einen CSR und gibt das Zertifikat zurück + SignCSR(csrPEM string, settings map[string]interface{}) (*SignCSRResult, error) + // GetCertificate ruft ein Zertifikat anhand der Zertifikat-ID ab + GetCertificate(certificateID string, settings map[string]interface{}) (string, error) +} + +// ProviderManager verwaltet alle Provider +type ProviderManager struct { + providers map[string]Provider + configs map[string]*ProviderConfig + configDir string + mu sync.RWMutex +} + +var manager *ProviderManager +var once sync.Once + +// GetManager gibt die Singleton-Instanz des ProviderManagers zurück +func GetManager() *ProviderManager { + once.Do(func() { + manager = &ProviderManager{ + providers: make(map[string]Provider), + configs: make(map[string]*ProviderConfig), + configDir: "./config/providers", + } + manager.loadAllConfigs() + }) + return manager +} + +// RegisterProvider registriert einen neuen Provider +func (pm *ProviderManager) RegisterProvider(provider Provider) { + pm.mu.Lock() + defer pm.mu.Unlock() + + providerID := pm.getProviderID(provider.GetName()) + pm.providers[providerID] = provider + + // Lade Konfiguration falls vorhanden + if pm.configs[providerID] == nil { + pm.configs[providerID] = &ProviderConfig{ + Enabled: false, + Settings: make(map[string]interface{}), + } + } +} + +// GetProvider gibt einen Provider zurück +func (pm *ProviderManager) GetProvider(id string) (Provider, bool) { + pm.mu.RLock() + defer pm.mu.RUnlock() + provider, exists := pm.providers[id] + return provider, exists +} + +// GetAllProviders gibt alle registrierten Provider zurück +func (pm *ProviderManager) GetAllProviders() map[string]Provider { + pm.mu.RLock() + defer pm.mu.RUnlock() + result := make(map[string]Provider) + for id, provider := range pm.providers { + result[id] = provider + } + return result +} + +// GetProviderConfig gibt die Konfiguration eines Providers zurück +func (pm *ProviderManager) GetProviderConfig(id string) (*ProviderConfig, error) { + pm.mu.RLock() + defer pm.mu.RUnlock() + + config, exists := pm.configs[id] + if !exists { + return &ProviderConfig{ + Enabled: false, + Settings: make(map[string]interface{}), + }, nil + } + return config, nil +} + +// UpdateProviderConfig aktualisiert die Konfiguration eines Providers +func (pm *ProviderManager) UpdateProviderConfig(id string, config *ProviderConfig) error { + pm.mu.Lock() + defer pm.mu.Unlock() + + provider, exists := pm.providers[id] + if !exists { + return fmt.Errorf("provider %s nicht gefunden", id) + } + + // Validiere Konfiguration + if err := provider.ValidateConfig(config.Settings); err != nil { + return fmt.Errorf("ungültige Konfiguration: %v", err) + } + + pm.configs[id] = config + + // Speichere Konfiguration in Datei + return pm.saveConfig(id, config) +} + +// SetProviderEnabled aktiviert/deaktiviert einen Provider +func (pm *ProviderManager) SetProviderEnabled(id string, enabled bool) error { + pm.mu.Lock() + defer pm.mu.Unlock() + + if pm.configs[id] == nil { + pm.configs[id] = &ProviderConfig{ + Enabled: enabled, + Settings: make(map[string]interface{}), + } + } else { + pm.configs[id].Enabled = enabled + } + + return pm.saveConfig(id, pm.configs[id]) +} + +// getProviderID erstellt eine ID aus dem Provider-Namen +func (pm *ProviderManager) getProviderID(name string) string { + return name +} + +// loadAllConfigs lädt alle Konfigurationsdateien +func (pm *ProviderManager) loadAllConfigs() { + // Stelle sicher, dass das Verzeichnis existiert + os.MkdirAll(pm.configDir, 0755) + + // Lade alle JSON-Dateien im Konfigurationsverzeichnis + files, err := filepath.Glob(filepath.Join(pm.configDir, "*.json")) + if err != nil { + return + } + + for _, file := range files { + id := filepath.Base(file[:len(file)-5]) // Entferne .json + config, err := pm.loadConfig(id) + if err == nil { + pm.configs[id] = config + } + } +} + +// loadConfig lädt eine Konfigurationsdatei +func (pm *ProviderManager) loadConfig(id string) (*ProviderConfig, error) { + filePath := filepath.Join(pm.configDir, id+".json") + + data, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + var config ProviderConfig + if err := json.Unmarshal(data, &config); err != nil { + return nil, err + } + + return &config, nil +} + +// saveConfig speichert eine Konfiguration in eine Datei +func (pm *ProviderManager) saveConfig(id string, config *ProviderConfig) error { + // Stelle sicher, dass das Verzeichnis existiert + os.MkdirAll(pm.configDir, 0755) + + filePath := filepath.Join(pm.configDir, id+".json") + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + + return os.WriteFile(filePath, data, 0644) +} + diff --git a/backend/providers/types.go b/backend/providers/types.go new file mode 100644 index 0000000..7cd7875 --- /dev/null +++ b/backend/providers/types.go @@ -0,0 +1,22 @@ +package providers + +// SettingField beschreibt ein Konfigurationsfeld +type SettingField struct { + Name string `json:"name"` + Label string `json:"label"` + Type string `json:"type"` // text, password, number, email, url + Required bool `json:"required"` + Description string `json:"description"` + Default string `json:"default,omitempty"` +} + +// ProviderInfo enthält Informationen über einen Provider +type ProviderInfo struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + Settings []SettingField `json:"settings"` +} + diff --git a/backend/spaces.db-shm b/backend/spaces.db-shm new file mode 100644 index 0000000..c2dafbd Binary files /dev/null and b/backend/spaces.db-shm differ diff --git a/backend/spaces.db-wal b/backend/spaces.db-wal new file mode 100644 index 0000000..7979005 Binary files /dev/null and b/backend/spaces.db-wal differ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a0c4b0d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + Certigo Addon + + +
+ + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..f83e576 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2656 @@ +{ + "name": "certigo-addon-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "certigo-addon-frontend", + "version": "1.0.0", + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.3.6", + "vite": "^5.0.8" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz", + "integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", + "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.27.0", + "caniuse-lite": "^1.0.30001754", + "fraction.js": "^5.3.4", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.29.tgz", + "integrity": "sha512-sXdt2elaVnhpDNRDz+1BDx1JQoJRuNk7oVlAlbGiFkLikHCAQiccexF/9e91zVi6RCgqspl04aP+6Cnl9zRLrA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001756", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001756.tgz", + "integrity": "sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.256", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.256.tgz", + "integrity": "sha512-uqYq1IQhpXXLX+HgiXdyOZml7spy4xfy42yPxcCCRjswp0fYM2X+JwCON07lqnpLEGVCj739B7Yr+FngmHBMEQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz", + "integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz", + "integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.1", + "react-router": "6.30.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", + "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..9e463c5 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "certigo-addon-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.3.6", + "vite": "^5.0.8" + } +} + diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..b4a6220 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,7 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} + diff --git a/frontend/public/logo.webp b/frontend/public/logo.webp new file mode 100644 index 0000000..57ab1fa Binary files /dev/null and b/frontend/public/logo.webp differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..4b5df92 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,36 @@ +import { useState } from 'react' +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom' +import Sidebar from './components/Sidebar' +import Footer from './components/Footer' +import Home from './pages/Home' +import Spaces from './pages/Spaces' +import SpaceDetail from './pages/SpaceDetail' +import Impressum from './pages/Impressum' + +function App() { + const [sidebarOpen, setSidebarOpen] = useState(true) + + return ( + +
+
+ +
+
+ + } /> + } /> + } /> + } /> + +
+
+
+
+
+ ) +} + +export default App + diff --git a/frontend/src/components/Footer.jsx b/frontend/src/components/Footer.jsx new file mode 100644 index 0000000..5dfcbf7 --- /dev/null +++ b/frontend/src/components/Footer.jsx @@ -0,0 +1,42 @@ +import { useState, useEffect } from 'react' + +function Footer() { + const [currentYear, setCurrentYear] = useState(new Date().getFullYear()) + const [logoError, setLogoError] = useState(false) + + useEffect(() => { + setCurrentYear(new Date().getFullYear()) + }, []) + + return ( + + ) +} + +export default Footer diff --git a/frontend/src/components/ProvidersSection.jsx b/frontend/src/components/ProvidersSection.jsx new file mode 100644 index 0000000..c4e7144 --- /dev/null +++ b/frontend/src/components/ProvidersSection.jsx @@ -0,0 +1,341 @@ +import { useState, useEffect } from 'react' + +const ProvidersSection = () => { + const [providers, setProviders] = useState([]) + const [loading, setLoading] = useState(true) + const [showConfigModal, setShowConfigModal] = useState(false) + const [selectedProvider, setSelectedProvider] = useState(null) + const [configValues, setConfigValues] = useState({}) + const [testing, setTesting] = useState(false) + const [testResult, setTestResult] = useState(null) + + useEffect(() => { + fetchProviders() + }, []) + + const fetchProviders = async () => { + try { + const response = await fetch('/api/providers') + if (response.ok) { + const data = await response.json() + // Definiere feste Reihenfolge der Provider + const providerOrder = ['dummy-ca', 'autodns', 'hetzner'] + const sortedProviders = providerOrder + .map(id => data.find(p => p.id === id)) + .filter(p => p !== undefined) + .concat(data.filter(p => !providerOrder.includes(p.id))) + setProviders(sortedProviders) + } + } catch (err) { + console.error('Error fetching providers:', err) + } finally { + setLoading(false) + } + } + + const handleToggleProvider = async (providerId, currentEnabled) => { + try { + const response = await fetch(`/api/providers/${providerId}/enabled`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ enabled: !currentEnabled }), + }) + + if (response.ok) { + fetchProviders() + } else { + alert('Fehler beim Ändern des Provider-Status') + } + } catch (err) { + console.error('Error toggling provider:', err) + alert('Fehler beim Ändern des Provider-Status') + } + } + + const handleOpenConfig = async (provider) => { + setSelectedProvider(provider) + setTestResult(null) + + // Lade aktuelle Konfiguration + try { + const response = await fetch(`/api/providers/${provider.id}`) + if (response.ok) { + const data = await response.json() + // Initialisiere Config-Werte + const initialValues = {} + provider.settings.forEach(setting => { + if (data.config && data.config[setting.name] !== undefined) { + // Wenn Wert "***" ist, bedeutet das, dass es ein Passwort ist - leer lassen + initialValues[setting.name] = data.config[setting.name] === '***' ? '' : data.config[setting.name] + } else { + initialValues[setting.name] = setting.default || '' + } + }) + setConfigValues(initialValues) + } + } catch (err) { + console.error('Error fetching provider config:', err) + // Initialisiere mit leeren Werten + const initialValues = {} + provider.settings.forEach(setting => { + initialValues[setting.name] = setting.default || '' + }) + setConfigValues(initialValues) + } + + setShowConfigModal(true) + } + + const handleCloseConfig = () => { + setShowConfigModal(false) + setSelectedProvider(null) + setConfigValues({}) + setTestResult(null) + } + + const handleConfigChange = (name, value) => { + setConfigValues({ + ...configValues, + [name]: value, + }) + } + + const handleTestConnection = async () => { + if (!selectedProvider) return + + setTesting(true) + setTestResult(null) + + try { + const response = await fetch(`/api/providers/${selectedProvider.id}/test`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ settings: configValues }), + }) + + const result = await response.json() + setTestResult(result) + } catch (err) { + console.error('Error testing connection:', err) + setTestResult({ + success: false, + message: 'Fehler beim Testen der Verbindung', + }) + } finally { + setTesting(false) + } + } + + const handleSaveConfig = async () => { + if (!selectedProvider) return + + try { + const response = await fetch(`/api/providers/${selectedProvider.id}/config`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ settings: configValues }), + }) + + if (response.ok) { + handleCloseConfig() + fetchProviders() + } else { + const error = await response.text() + alert(`Fehler beim Speichern: ${error}`) + } + } catch (err) { + console.error('Error saving config:', err) + alert('Fehler beim Speichern der Konfiguration') + } + } + + if (loading) { + return ( +
+

+ SSL Certificate Providers +

+

Lade Provider...

+
+ ) + } + + return ( + <> +
+

+ SSL Certificate Providers +

+
+ {providers.map((provider) => ( +
+
+
+

+ {provider.displayName} +

+

+ {provider.description} +

+
+
+ + +
+
+
+ ))} +
+
+ + {/* Configuration Modal */} + {showConfigModal && selectedProvider && ( +
+
+
+

+ {selectedProvider.displayName} - Konfiguration +

+ +
+ +
+ {selectedProvider.settings.length > 0 ? ( + selectedProvider.settings.map((setting) => ( +
+ + {setting.description && ( +

{setting.description}

+ )} + {setting.type === 'password' ? ( + handleConfigChange(setting.name, e.target.value)} + className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-300" + placeholder={setting.label} + required={setting.required} + /> + ) : ( + handleConfigChange(setting.name, e.target.value)} + className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-300" + placeholder={setting.label} + required={setting.required} + /> + )} +
+ )) + ) : ( +

+ Dieser Provider benötigt keine Konfiguration. +

+ )} +
+ + {testResult && ( +
+

+ {testResult.success ? '✅' : '❌'} {testResult.message} +

+
+ )} + +
+ {selectedProvider.settings.length > 0 && ( + + )} + + +
+
+
+ )} + + ) +} + +export default ProvidersSection + diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx new file mode 100644 index 0000000..7581cc4 --- /dev/null +++ b/frontend/src/components/Sidebar.jsx @@ -0,0 +1,98 @@ +import { Link, useLocation } from 'react-router-dom' + +const Sidebar = ({ isOpen, setIsOpen }) => { + const location = useLocation() + + const menuItems = [ + { path: '/', label: 'Home', icon: '🏠' }, + { path: '/spaces', label: 'Spaces', icon: '📁' }, + { path: '/impressum', label: 'Impressum', icon: 'ℹ️' }, + ] + + const isActive = (path) => { + if (path === '/') { + return location.pathname === '/' + } + return location.pathname.startsWith(path) + } + + return ( + <> + {/* Overlay for mobile */} + {isOpen && ( +
setIsOpen(false)} + /> + )} + + {/* Sidebar */} + + + ) +} + +export default Sidebar + diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..e5f05bf --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,57 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Checkmark Animation */ +.checkmark-animated { + animation: checkmarkScale 0.6s ease-in-out; +} + +.circle-draw { + stroke-dasharray: 62.83; + stroke-dashoffset: 62.83; + animation: drawCircle 0.6s ease-in-out forwards; +} + +.check-draw { + stroke-dasharray: 10; + stroke-dashoffset: 10; + animation: drawCheck 0.4s ease-in-out 0.3s forwards; +} + +@keyframes drawCircle { + to { + stroke-dashoffset: 0; + } +} + +@keyframes drawCheck { + to { + stroke-dashoffset: 0; + } +} + +@keyframes checkmarkScale { + 0% { + transform: scale(0); + opacity: 0; + } + 50% { + transform: scale(1.1); + } + 100% { + transform: scale(1); + opacity: 1; + } +} + diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..299bc52 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,11 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.jsx' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + , +) + diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx new file mode 100644 index 0000000..a86a53b --- /dev/null +++ b/frontend/src/pages/Home.jsx @@ -0,0 +1,303 @@ +import { useEffect, useState, useRef, useCallback } from 'react' +import ProvidersSection from '../components/ProvidersSection' + +const Home = () => { + const [data, setData] = useState(null) + const [stats, setStats] = useState(null) + const [loadingStats, setLoadingStats] = useState(true) + const [lastUpdate, setLastUpdate] = useState(null) + const intervalRef = useRef(null) + const isMountedRef = useRef(true) + + // Fetch stats function + const fetchStats = useCallback(async (isInitial = false) => { + try { + if (isInitial) { + setLoadingStats(true) + } + const response = await fetch('/api/stats') + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + const statsData = await response.json() + if (isMountedRef.current) { + setStats(statsData) + setLoadingStats(false) + setLastUpdate(new Date()) + } + } catch (err) { + console.error('Error fetching stats:', err) + if (isMountedRef.current) { + setLoadingStats(false) + } + } + }, []) + + // Fetch health function + const fetchHealth = useCallback(async () => { + try { + const response = await fetch('/api/health') + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + const healthData = await response.json() + if (isMountedRef.current) { + setData(healthData) + } + } catch (err) { + console.error('Error fetching health:', err) + } + }, []) + + // Handle visibility change - pause polling when tab is hidden + useEffect(() => { + const handleVisibilityChange = () => { + if (document.hidden) { + // Tab is hidden, clear interval + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + } else { + // Tab is visible, resume polling + if (!intervalRef.current && isMountedRef.current) { + // Fetch immediately when tab becomes visible + fetch('/api/stats') + .then(res => res.json()) + .then(statsData => { + if (isMountedRef.current) { + setStats(statsData) + setLastUpdate(new Date()) + } + }) + .catch(err => console.error('Error fetching stats:', err)) + + fetch('/api/health') + .then(res => res.json()) + .then(healthData => { + if (isMountedRef.current) { + setData(healthData) + } + }) + .catch(err => console.error('Error fetching health:', err)) + + // Resume polling + intervalRef.current = setInterval(() => { + if (isMountedRef.current) { + fetch('/api/stats') + .then(res => res.json()) + .then(statsData => { + if (isMountedRef.current) { + setStats(statsData) + setLastUpdate(new Date()) + } + }) + .catch(err => console.error('Error fetching stats:', err)) + + fetch('/api/health') + .then(res => res.json()) + .then(healthData => { + if (isMountedRef.current) { + setData(healthData) + } + }) + .catch(err => console.error('Error fetching health:', err)) + } + }, 5000) + } + } + } + + document.addEventListener('visibilitychange', handleVisibilityChange) + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + }, []) + + useEffect(() => { + isMountedRef.current = true + + // Define fetch functions inside useEffect to avoid dependency issues + const fetchStatsInternal = async (isInitial = false) => { + try { + if (isInitial) { + setLoadingStats(true) + } + const response = await fetch('/api/stats') + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + const statsData = await response.json() + if (isMountedRef.current) { + setStats(statsData) + setLoadingStats(false) + setLastUpdate(new Date()) + } + } catch (err) { + console.error('Error fetching stats:', err) + if (isMountedRef.current) { + setLoadingStats(false) + } + } + } + + const fetchHealthInternal = async () => { + try { + const response = await fetch('/api/health') + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + const healthData = await response.json() + if (isMountedRef.current) { + setData(healthData) + } + } catch (err) { + console.error('Error fetching health:', err) + } + } + + // Initial fetch + fetchHealthInternal() + fetchStatsInternal(true) // Pass true for initial load to show loading state + + // Set up polling interval (5 seconds) + intervalRef.current = setInterval(() => { + if (isMountedRef.current) { + fetchStatsInternal(false) // Pass false for background updates + fetchHealthInternal() + } + }, 5000) + + // Cleanup on unmount + return () => { + isMountedRef.current = false + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + } + }, []) // Empty dependency array - only run on mount + + return ( +
+
+

Willkommen

+

+ Dies ist die Startseite der Certigo Addon Anwendung. +

+ +
+ {/* Stats Dashboard */} +
+
+

+ Statistiken +

+ {lastUpdate && ( +
+
+ + {new Date(lastUpdate).toLocaleTimeString('de-DE', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + })} + +
+ )} +
+ {!stats && loadingStats ? ( +

Lade Statistiken...

+ ) : stats ? ( +
+
+
+
+

Spaces

+

{stats.spaces}

+
+
+ + + +
+
+
+ +
+
+
+

FQDNs

+

{stats.fqdns}

+
+
+ + + +
+
+
+ +
+
+
+

CSRs

+

{stats.csrs}

+
+
+ + + +
+
+
+ +
+
+
+

Zertifikate

+

{stats.certificates || 0}

+
+
+ + + +
+
+
+
+ ) : ( +

Fehler beim Laden der Statistiken

+ )} +
+ + {/* System Status */} +
+

+ System Status +

+ {data ? ( +
+

+ Status:{' '} + {data.status} +

+

+ Nachricht: {data.message} +

+
+ ) : ( +

Lade Daten...

+ )} +
+ + {/* SSL Certificate Providers */} + +
+
+
+ ) +} + +export default Home + diff --git a/frontend/src/pages/Impressum.jsx b/frontend/src/pages/Impressum.jsx new file mode 100644 index 0000000..391dd05 --- /dev/null +++ b/frontend/src/pages/Impressum.jsx @@ -0,0 +1,41 @@ +const Impressum = () => { + return ( +
+
+

Impressum

+ +
+
+

+ Angaben gemäß § 5 TMG +

+

+ Hier können Sie Ihre rechtlichen Angaben eintragen. +

+
+ +
+

+ Kontakt +

+

+ Kontaktinformationen können hier eingetragen werden. +

+
+ +
+

+ Haftungsausschluss +

+

+ Haftungsausschluss-Informationen können hier eingetragen werden. +

+
+
+
+
+ ) +} + +export default Impressum + diff --git a/frontend/src/pages/SpaceDetail.jsx b/frontend/src/pages/SpaceDetail.jsx new file mode 100644 index 0000000..da656b4 --- /dev/null +++ b/frontend/src/pages/SpaceDetail.jsx @@ -0,0 +1,1538 @@ +import { useState, useEffect } from 'react' +import { useParams, useNavigate } from 'react-router-dom' + +const SpaceDetail = () => { + const { id } = useParams() + const navigate = useNavigate() + const [space, setSpace] = useState(null) + const [fqdns, setFqdns] = useState([]) + const [showForm, setShowForm] = useState(false) + const [formData, setFormData] = useState({ + fqdn: '', + description: '' + }) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [fetchError, setFetchError] = useState('') + const [loadingSpace, setLoadingSpace] = useState(true) + const [showDeleteModal, setShowDeleteModal] = useState(false) + const [fqdnToDelete, setFqdnToDelete] = useState(null) + const [confirmChecked, setConfirmChecked] = useState(false) + const [selectedFqdn, setSelectedFqdn] = useState(null) + const [csrData, setCsrData] = useState(null) + const [csrHistory, setCsrHistory] = useState([]) + const [uploadingCSR, setUploadingCSR] = useState(false) + const [csrError, setCsrError] = useState('') + const [showCSRModal, setShowCSRModal] = useState(false) + const [showCSRDropdown, setShowCSRDropdown] = useState({}) + const [copiedFqdnId, setCopiedFqdnId] = useState(null) + const [showSignCSRModal, setShowSignCSRModal] = useState(false) + const [signCSRStep, setSignCSRStep] = useState(1) + const [selectedProvider, setSelectedProvider] = useState(null) + const [providers, setProviders] = useState([]) + const [providerTestResult, setProviderTestResult] = useState(null) + const [signingCSR, setSigningCSR] = useState(false) + const [signResult, setSignResult] = useState(null) + const [showCertificatesModal, setShowCertificatesModal] = useState(false) + const [certificates, setCertificates] = useState([]) + const [loadingCertificates, setLoadingCertificates] = useState(false) + const [refreshingCertificate, setRefreshingCertificate] = useState(null) + + useEffect(() => { + fetchSpace() + fetchFqdns() + }, [id]) + + + const fetchSpace = async () => { + try { + setLoadingSpace(true) + const response = await fetch('/api/spaces') + if (response.ok) { + const spaces = await response.json() + const foundSpace = spaces.find(s => s.id === id) + if (foundSpace) { + setSpace(foundSpace) + } else { + setFetchError('Space nicht gefunden') + } + } else { + setFetchError('Fehler beim Laden des Space') + } + } catch (err) { + console.error('Error fetching space:', err) + setFetchError('Fehler beim Laden des Space') + } finally { + setLoadingSpace(false) + } + } + + const fetchFqdns = async () => { + try { + setFetchError('') + const response = await fetch(`/api/spaces/${id}/fqdns`) + if (response.ok) { + const data = await response.json() + setFqdns(Array.isArray(data) ? data : []) + } else { + if (response.status !== 404) { + const errorText = `Fehler beim Abrufen der FQDNs: ${response.status}` + console.error(errorText) + setFetchError(errorText) + } + setFqdns([]) + } + } catch (err) { + console.error('Error fetching fqdns:', err) + setFqdns([]) + } + } + + const handleSubmit = async (e) => { + e.preventDefault() + setError('') + setLoading(true) + + if (!formData.fqdn.trim()) { + setError('Bitte geben Sie einen FQDN ein.') + setLoading(false) + return + } + + // Einfache FQDN-Validierung + const fqdnPattern = /^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/ + if (!fqdnPattern.test(formData.fqdn.trim())) { + setError('Bitte geben Sie einen gültigen FQDN ein (z.B. example.com, subdomain.example.com)') + setLoading(false) + return + } + + try { + const response = await fetch(`/api/spaces/${id}/fqdns`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(formData), + }) + + if (response.ok) { + const newFqdn = await response.json() + setFqdns([...fqdns, newFqdn]) + setFormData({ fqdn: '', description: '' }) + setShowForm(false) + } else { + let errorMessage = 'Fehler beim Erstellen des FQDN' + try { + const errorText = await response.text() + if (errorText) { + // Versuche JSON zu parsen + try { + const errorData = JSON.parse(errorText) + errorMessage = errorData.error || errorText + } catch { + // Wenn kein JSON, verwende den Text direkt + errorMessage = errorText + } + } else if (response.status === 409) { + errorMessage = 'Dieser FQDN existiert bereits' + } + } catch (err) { + // Fallback auf Standard-Fehlermeldung + if (response.status === 409) { + errorMessage = 'Dieser FQDN existiert bereits' + } + } + setError(errorMessage) + } + } catch (err) { + setError('Fehler beim Erstellen des FQDN') + console.error('Error creating fqdn:', err) + } finally { + setLoading(false) + } + } + + const handleDelete = (fqdn) => { + setFqdnToDelete(fqdn) + setShowDeleteModal(true) + setConfirmChecked(false) + } + + const confirmDelete = async () => { + if (!confirmChecked || !fqdnToDelete) { + return + } + + try { + const response = await fetch(`/api/spaces/${id}/fqdns/${fqdnToDelete.id}`, { + method: 'DELETE', + }) + + if (response.ok) { + setFqdns(fqdns.filter(fqdn => fqdn.id !== fqdnToDelete.id)) + setShowDeleteModal(false) + setFqdnToDelete(null) + setConfirmChecked(false) + } else { + const errorData = await response.json().catch(() => ({ error: 'Fehler beim Löschen' })) + alert(errorData.error || 'Fehler beim Löschen des FQDN') + } + } catch (err) { + console.error('Error deleting fqdn:', err) + alert('Fehler beim Löschen des FQDN') + } + } + + const cancelDelete = () => { + setShowDeleteModal(false) + setFqdnToDelete(null) + setConfirmChecked(false) + } + + const copyFqdnIdToClipboard = async (fqdnId, e) => { + e.stopPropagation() + try { + await navigator.clipboard.writeText(fqdnId) + setCopiedFqdnId(fqdnId) + setTimeout(() => setCopiedFqdnId(null), 2000) + } catch (err) { + console.error('Fehler beim Kopieren:', err) + } + } + + const handleCSRUpload = async (fqdn, file) => { + if (!file) { + setCsrError('Bitte wählen Sie eine Datei aus') + return + } + + setUploadingCSR(true) + setCsrError('') + + const formData = new FormData() + formData.append('csr', file) + formData.append('spaceId', id) + formData.append('fqdn', fqdn.fqdn) + + try { + const response = await fetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr`, { + method: 'POST', + body: formData, + }) + + if (response.ok) { + const csr = await response.json() + + // Füge den neuen CSR zur History hinzu (nur wenn der Bereich bereits geöffnet ist) + if (showCSRDropdown[fqdn.id]) { + const newCsrWithFqdnId = { ...csr, fqdnId: fqdn.id } + setCsrHistory(prev => { + const filtered = prev.filter(csrItem => csrItem.fqdnId !== fqdn.id) + // Füge den neuen CSR am Anfang hinzu (neuester zuerst) + return [newCsrWithFqdnId, ...filtered] + }) + } + + setCsrData(csr) + setSelectedFqdn(fqdn) + setShowCSRModal(true) + + // Aktualisiere die FQDN-Liste + fetchFqdns() + } else { + const errorText = await response.text() + setCsrError(errorText || 'Fehler beim Hochladen des CSR') + } + } catch (err) { + console.error('Error uploading CSR:', err) + setCsrError('Fehler beim Hochladen des CSR') + } finally { + setUploadingCSR(false) + } + } + + const fetchCSR = async (fqdn) => { + try { + const response = await fetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr`) + if (response.ok) { + const csr = await response.json() + if (csr) { + setCsrData(csr) + setSelectedFqdn(fqdn) + } else { + setCsrData(null) + setSelectedFqdn(fqdn) + } + } + } catch (err) { + console.error('Error fetching CSR:', err) + } + } + + const handleViewCSR = async (fqdn) => { + setSelectedFqdn(fqdn) + setCsrError('') + setShowCSRModal(true) + + // Lade neuesten CSR und alle CSRs für History + try { + // Lade neuesten CSR + const latestResponse = await fetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr?latest=true`) + if (latestResponse.ok) { + const csr = await latestResponse.json() + setCsrData(csr || null) + } else { + setCsrData(null) + } + + // Lade alle CSRs für History + const historyResponse = await fetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr`) + if (historyResponse.ok) { + const history = await historyResponse.json() + setCsrHistory(Array.isArray(history) ? history : []) + } else { + setCsrHistory([]) + } + } catch (err) { + console.error('Error fetching CSR:', err) + setCsrData(null) + setCsrHistory([]) + } + } + + const handleSelectCSR = (csr) => { + setCsrData(csr) + setShowCSRDropdown({}) + } + + const closeCSRModal = () => { + setShowCSRModal(false) + setSelectedFqdn(null) + setCsrData(null) + setCsrError('') + setCsrHistory([]) + } + + const handleChange = (e) => { + setFormData({ + ...formData, + [e.target.name]: e.target.value + }) + } + + const handleRequestSigning = async (fqdn) => { + setSelectedFqdn(fqdn) + setSignCSRStep(1) + setSelectedProvider(null) + setProviderTestResult(null) + setSignResult(null) + + // Lade neuesten CSR + try { + const response = await fetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr?latest=true`) + if (response.ok) { + const csr = await response.json() + setCsrData(csr) + } + } catch (err) { + console.error('Error fetching CSR:', err) + } + + // Lade Provider + try { + const response = await fetch('/api/providers') + if (response.ok) { + const providersData = await response.json() + setProviders(providersData.filter(p => p.enabled)) + } + } catch (err) { + console.error('Error fetching providers:', err) + } + + setShowSignCSRModal(true) + } + + const handleTestProvider = async (providerId) => { + setProviderTestResult(null) + try { + const response = await fetch(`/api/providers/${providerId}/test`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }) + const result = await response.json() + setProviderTestResult(result) + } catch (err) { + setProviderTestResult({ success: false, message: 'Fehler beim Testen des Providers' }) + } + } + + const handleSignCSR = async () => { + if (!selectedProvider || !selectedFqdn) return + + setSigningCSR(true) + setSignResult(null) + + try { + const response = await fetch(`/api/spaces/${id}/fqdns/${selectedFqdn.id}/csr/sign`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + providerId: selectedProvider.id + }) + }) + + const result = await response.json() + setSignResult(result) + + if (result.success) { + // Lade Zertifikate automatisch neu, um das neue Zertifikat anzuzeigen + try { + const certResponse = await fetch(`/api/spaces/${id}/fqdns/${selectedFqdn.id}/certificates`) + if (certResponse.ok) { + const certs = await certResponse.json() + setCertificates(certs) + } + } catch (err) { + console.error('Fehler beim Laden der Zertifikate nach Signierung:', err) + } + } + } catch (err) { + setSignResult({ success: false, message: 'Fehler beim Signieren des CSR' }) + } finally { + setSigningCSR(false) + } + } + + const closeSignCSRModal = () => { + setShowSignCSRModal(false) + setSignCSRStep(1) + setSelectedProvider(null) + setProviderTestResult(null) + setSignResult(null) + setSelectedFqdn(null) + } + + const handleViewCertificates = async (fqdn) => { + setSelectedFqdn(fqdn) + setLoadingCertificates(true) + setCertificates([]) + + try { + const response = await fetch(`/api/spaces/${id}/fqdns/${fqdn.id}/certificates`) + if (response.ok) { + const certs = await response.json() + setCertificates(certs) + } else { + console.error('Fehler beim Laden der Zertifikate') + } + } catch (err) { + console.error('Error fetching certificates:', err) + } finally { + setLoadingCertificates(false) + setShowCertificatesModal(true) + } + } + + const handleRefreshCertificate = async (cert) => { + setRefreshingCertificate(cert.id) + try { + const response = await fetch(`/api/spaces/${id}/fqdns/${selectedFqdn.id}/certificates/${cert.id}/refresh`, { + method: 'POST' + }) + if (response.ok) { + const result = await response.json() + // Aktualisiere Zertifikat in der Liste + setCertificates(prev => prev.map(c => + c.id === cert.id + ? { ...c, certificatePEM: result.certificatePEM } + : c + )) + } + } catch (err) { + console.error('Error refreshing certificate:', err) + } finally { + setRefreshingCertificate(null) + } + } + + const closeCertificatesModal = () => { + setShowCertificatesModal(false) + setCertificates([]) + setSelectedFqdn(null) + } + + if (loadingSpace) { + return ( +
+
+

Lade Space...

+
+
+ ) + } + + if (!space) { + return ( +
+
+
+

{fetchError || 'Space nicht gefunden'}

+ +
+
+
+ ) + } + + return ( +
+
+
+ +

{space.name}

+ {space.description && ( +

{space.description}

+ )} +
+ + + {/* Create FQDN Form */} + {showForm && ( +
+

+ Neuen FQDN erstellen +

+
+
+ + +
+
+ +