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}
+
+
+
+
+
+
+
+
+
+
+
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 (
+
+ )
+ }
+
+ if (!space) {
+ return (
+
+
+
+
{fetchError || 'Space nicht gefunden'}
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
{space.name}
+ {space.description && (
+
{space.description}
+ )}
+
+
+
+ {/* Create FQDN Form */}
+ {showForm && (
+
+
+ Neuen FQDN erstellen
+
+
+
+ )}
+
+ {/* FQDNs List */}
+
+
+
+ FQDN-Liste
+
+
+
+ {fetchError && (
+
+
{fetchError}
+
+
+ )}
+ {!fetchError && fqdns.length === 0 ? (
+
+ Noch keine FQDNs vorhanden. Erstellen Sie Ihren ersten FQDN!
+
+ ) : (
+
+ {fqdns.map((fqdn) => (
+
+
+
+
+
+ {fqdn.fqdn || 'Unbenannt'}
+
+ {fqdn.description && (
+
{fqdn.description}
+ )}
+
+ Erstellt: {fqdn.createdAt ? new Date(fqdn.createdAt).toLocaleString('de-DE') : 'Unbekannt'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Erweiterter Bereich für CSR History */}
+ {showCSRDropdown[fqdn.id] && (
+
+
CSR History
+ {(() => {
+ const fqdnHistory = csrHistory
+ .filter(csr => csr.fqdnId === fqdn.id)
+ .sort((a, b) => {
+ // Sortiere nach created_at, neueste zuerst
+ const dateA = new Date(a.createdAt).getTime()
+ const dateB = new Date(b.createdAt).getTime()
+ return dateB - dateA
+ })
+ return fqdnHistory.length > 0 ? (
+
+ {fqdnHistory.map((csr) => (
+
+ ))}
+
+ ) : (
+
+ Keine CSRs vorhanden. Laden Sie einen CSR hoch.
+
+ )
+ })()}
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+ {/* CSR Details Modal */}
+ {showCSRModal && selectedFqdn && (
+
+
+
+
+
+
+ CSR Details: {selectedFqdn.fqdn}
+
+
+
+
+
+ {uploadingCSR ? (
+
+
CSR wird hochgeladen...
+
+ ) : csrError ? (
+
+ ) : csrData ? (
+
+
+
Subject
+
+ {csrData.subject}
+
+
+
+
+
+
Public Key Algorithm
+
{csrData.publicKeyAlgorithm}
+
+
+
Signature Algorithm
+
{csrData.signatureAlgorithm}
+
+
+
Key Size
+
{csrData.keySize} bits
+
+
+
Upload Timestamp
+
+ {new Date(csrData.createdAt).toLocaleString('de-DE')}
+
+
+
+
+ {csrData.dnsNames && csrData.dnsNames.length > 0 && (
+
+
DNS Names (SAN)
+
+ {csrData.dnsNames.map((dns, idx) => (
+ - {dns}
+ ))}
+
+
+ )}
+
+ {csrData.emailAddresses && csrData.emailAddresses.length > 0 && (
+
+
Email Addresses
+
+ {csrData.emailAddresses.map((email, idx) => (
+ - {email}
+ ))}
+
+
+ )}
+
+ {csrData.ipAddresses && csrData.ipAddresses.length > 0 && (
+
+
IP Addresses
+
+ {csrData.ipAddresses.map((ip, idx) => (
+ - {ip}
+ ))}
+
+
+ )}
+
+ {csrData.uris && csrData.uris.length > 0 && (
+
+
URIs
+
+ {csrData.uris.map((uri, idx) => (
+ - {uri}
+ ))}
+
+
+ )}
+
+ {csrData.extensions && csrData.extensions.length > 0 && (
+
+
Requested Extensions:
+
+ {csrData.extensions.map((ext, idx) => (
+
+
+
+ {ext.name || ext.oid || ext.id}:
+
+ {ext.critical && (
+
+ critical
+
+ )}
+
+ {ext.description && (
+
+ {ext.description.includes('\n') ? (
+ ext.description.split('\n').map((line, i) => (
+
+ {line.trim()}
+
+ ))
+ ) : (
+ ext.description.split(', ').map((item, i) => (
+
+ {item}
+
+ ))
+ )}
+
+ )}
+ {!ext.description && ext.purposes && ext.purposes.length > 0 && (
+
+ {ext.purposes.map((purpose, pIdx) => (
+
+ {purpose}
+
+ ))}
+
+ )}
+ {!ext.description && !ext.purposes && (
+
+ {ext.value}
+
+ )}
+
+ ))}
+
+
+ )}
+
+
+
CSR PEM
+
+ {csrData.csrPem}
+
+
+
+ ) : (
+
+
+ Kein CSR für diesen FQDN vorhanden.
+
+
+ Laden Sie einen CSR über den Upload-Button hoch.
+
+
+ )}
+
+
+
+
+
+
+ )}
+
+ {/* Delete Confirmation Modal */}
+ {showDeleteModal && fqdnToDelete && (
+
+
+
+
+
+
+ Möchten Sie den FQDN {fqdnToDelete.fqdn} wirklich löschen?
+
+
+ Diese Aktion kann nicht rückgängig gemacht werden.
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Sign CSR Modal */}
+ {showSignCSRModal && selectedFqdn && (
+
+
+
+
+
CSR signieren lassen
+
+
+
+ {/* Step Indicator */}
+
+ {[1, 2, 3, 4].map((step) => (
+
+
= step
+ ? 'bg-purple-600 border-purple-600 text-white'
+ : 'border-slate-600 text-slate-400'
+ }`}>
+ {signCSRStep > step ? (
+
+ ) : (
+
{step}
+ )}
+
+ {step < 4 && (
+
step ? 'bg-purple-600' : 'bg-slate-600'
+ }`} />
+ )}
+
+ ))}
+
+
+ {/* Step 1: CSR Details */}
+ {signCSRStep === 1 && (
+
+
1. CSR Details überprüfen
+ {csrData ? (
+
+
+
Subject
+
{csrData.subject}
+
+
+
+
Public Key Algorithm
+
{csrData.publicKeyAlgorithm}
+
+
+
Key Size
+
{csrData.keySize} bits
+
+
+ {csrData.dnsNames && csrData.dnsNames.length > 0 && (
+
+
DNS Names
+
+ {csrData.dnsNames.map((dns, idx) => (
+ - {dns}
+ ))}
+
+
+ )}
+
+ ) : (
+
Kein CSR gefunden für diesen FQDN.
+ )}
+
+
+
+
+ )}
+
+ {/* Step 2: Provider Selection */}
+ {signCSRStep === 2 && (
+
+
2. Provider auswählen
+
+ {providers.length > 0 ? (
+ providers.map((provider) => (
+
+ ))
+ ) : (
+
Keine aktivierten Provider verfügbar.
+ )}
+
+
+
+
+
+
+ )}
+
+ {/* Step 3: Quick Check */}
+ {signCSRStep === 3 && (
+
+
3. Provider-Verbindung testen
+ {selectedProvider && (
+
+
{selectedProvider.displayName}
+
{selectedProvider.description}
+
+ )}
+
+ {providerTestResult && (
+
+
+ {providerTestResult.message || (providerTestResult.success ? 'Verbindung erfolgreich' : 'Verbindung fehlgeschlagen')}
+
+
+ )}
+
+
+
+
+
+ )}
+
+ {/* Step 4: Submit */}
+ {signCSRStep === 4 && (
+
+
4. CSR einreichen
+
+
Bereit zum Einreichen:
+
+ - • FQDN: {selectedFqdn?.fqdn}
+ - • Provider: {selectedProvider?.displayName}
+ - • CSR: {csrData?.subject}
+
+
+
+ {signResult && !signResult.success && (
+
+
+ {signResult.message || 'Fehler beim Signieren'}
+
+
+ )}
+
+
+ {signResult?.success ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
+
+
+ )}
+
+ {/* Certificates Modal */}
+ {showCertificatesModal && selectedFqdn && (
+
+
+
+
+
Zertifikate für {selectedFqdn.fqdn}
+
+
+
+ {loadingCertificates ? (
+
Lade Zertifikate...
+ ) : certificates.length === 0 ? (
+
Keine Zertifikate vorhanden
+ ) : (
+
+
+
Zertifikat-History
+
+ {certificates.length} {certificates.length === 1 ? 'Zertifikat' : 'Zertifikate'} gefunden
+
+
+ {certificates.map((cert, index) => (
+
+
+
+
+
+ #{certificates.length - index}
+
+
CA-Zertifikat-ID: {cert.certificateId}
+
+
+
+ Interne UID:{' '}
+ {cert.id}
+
+
+ Erstellt:{' '}
+ {new Date(cert.createdAt).toLocaleString('de-DE')}
+
+
+ Status:{' '}
+
+ {cert.status}
+
+
+ {cert.providerId && (
+
+ Provider:{' '}
+ {cert.providerId}
+
+ )}
+
+
+
+
+ {cert.certificatePEM && (
+
+
Zertifikat (PEM):
+
+ {cert.certificatePEM}
+
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+ )}
+
+ )
+}
+
+export default SpaceDetail
+
diff --git a/frontend/src/pages/Spaces.jsx b/frontend/src/pages/Spaces.jsx
new file mode 100644
index 0000000..a67551d
--- /dev/null
+++ b/frontend/src/pages/Spaces.jsx
@@ -0,0 +1,460 @@
+import { useState, useEffect } from 'react'
+import { useNavigate } from 'react-router-dom'
+
+const Spaces = () => {
+ const navigate = useNavigate()
+ const [spaces, setSpaces] = useState([])
+ const [showForm, setShowForm] = useState(false)
+ const [formData, setFormData] = useState({
+ name: '',
+ description: ''
+ })
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState('')
+ const [fetchError, setFetchError] = useState('')
+ const [showDeleteModal, setShowDeleteModal] = useState(false)
+ const [spaceToDelete, setSpaceToDelete] = useState(null)
+ const [fqdnCount, setFqdnCount] = useState(0)
+ const [confirmChecked, setConfirmChecked] = useState(false)
+ const [deleteFqdnsChecked, setDeleteFqdnsChecked] = useState(false)
+ const [copiedId, setCopiedId] = useState(null)
+
+ useEffect(() => {
+ fetchSpaces()
+ }, [])
+
+ const fetchSpaces = async () => {
+ try {
+ setFetchError('')
+ const response = await fetch('/api/spaces')
+ if (response.ok) {
+ const data = await response.json()
+ // Stelle sicher, dass data ein Array ist
+ setSpaces(Array.isArray(data) ? data : [])
+ } else {
+ const errorText = `Fehler beim Abrufen der Spaces: ${response.status}`
+ console.error(errorText)
+ setFetchError(errorText)
+ setSpaces([])
+ }
+ } catch (err) {
+ const errorText = 'Fehler beim Abrufen der Spaces. Bitte stellen Sie sicher, dass das Backend läuft.'
+ console.error('Error fetching spaces:', err)
+ setFetchError(errorText)
+ setSpaces([])
+ }
+ }
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ setError('')
+ setLoading(true)
+
+ if (!formData.name.trim()) {
+ setError('Bitte geben Sie einen Namen ein.')
+ setLoading(false)
+ return
+ }
+
+ try {
+ const response = await fetch('/api/spaces', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(formData),
+ })
+
+ if (response.ok) {
+ const newSpace = await response.json()
+ setSpaces([...spaces, newSpace])
+ setFormData({ name: '', description: '' })
+ setShowForm(false)
+ } else {
+ const errorData = await response.json()
+ setError(errorData.error || 'Fehler beim Erstellen des Space')
+ }
+ } catch (err) {
+ setError('Fehler beim Erstellen des Space')
+ console.error('Error creating space:', err)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleChange = (e) => {
+ setFormData({
+ ...formData,
+ [e.target.name]: e.target.value
+ })
+ }
+
+ const handleDelete = async (space) => {
+ // Hole die Anzahl der FQDNs für diesen Space
+ let count = 0
+ try {
+ const countResponse = await fetch(`/api/spaces/${space.id}/fqdns/count`)
+ if (countResponse.ok) {
+ const countData = await countResponse.json()
+ count = countData.count || 0
+ }
+ } catch (err) {
+ console.error('Error fetching FQDN count:', err)
+ count = 0
+ }
+
+ // Setze State und öffne Modal erst nach dem Laden der FQDN-Anzahl
+ setFqdnCount(count)
+ setSpaceToDelete(space)
+ setConfirmChecked(false)
+ setDeleteFqdnsChecked(false)
+ setShowDeleteModal(true)
+ }
+
+ const confirmDelete = async () => {
+ if (!confirmChecked || !spaceToDelete) {
+ return
+ }
+
+ // Wenn FQDNs vorhanden sind, muss die Checkbox aktiviert sein
+ if (fqdnCount > 0 && !deleteFqdnsChecked) {
+ alert('Bitte aktivieren Sie die Checkbox, um die FQDNs mitzulöschen.')
+ return
+ }
+
+ try {
+ const url = fqdnCount > 0 && deleteFqdnsChecked
+ ? `/api/spaces/${spaceToDelete.id}?deleteFqdns=true`
+ : `/api/spaces/${spaceToDelete.id}`
+
+ const response = await fetch(url, {
+ method: 'DELETE',
+ })
+
+ if (response.ok) {
+ setSpaces(spaces.filter(space => space.id !== spaceToDelete.id))
+ setShowDeleteModal(false)
+ setSpaceToDelete(null)
+ setConfirmChecked(false)
+ setDeleteFqdnsChecked(false)
+ setFqdnCount(0)
+ } else {
+ const errorText = await response.text()
+ let errorMessage = 'Fehler beim Löschen des Space'
+ try {
+ const errorData = JSON.parse(errorText)
+ errorMessage = errorData.error || errorText
+ } catch {
+ errorMessage = errorText || errorMessage
+ }
+ alert(errorMessage)
+ }
+ } catch (err) {
+ console.error('Error deleting space:', err)
+ alert('Fehler beim Löschen des Space')
+ }
+ }
+
+ const cancelDelete = () => {
+ setShowDeleteModal(false)
+ setSpaceToDelete(null)
+ setConfirmChecked(false)
+ setDeleteFqdnsChecked(false)
+ setFqdnCount(0)
+ }
+
+ const copyToClipboard = async (id, e) => {
+ e.stopPropagation()
+ try {
+ await navigator.clipboard.writeText(id)
+ setCopiedId(id)
+ setTimeout(() => setCopiedId(null), 2000)
+ } catch (err) {
+ console.error('Fehler beim Kopieren:', err)
+ }
+ }
+
+ return (
+
+
+
+
+
Spaces
+
+ Verwalten Sie Ihre Spaces und Arbeitsbereiche.
+
+
+
+
+
+ {/* Create Space Form */}
+ {showForm && (
+
+
+ Neuen Space erstellen
+
+
+
+ )}
+
+ {/* Spaces List */}
+
+
+ Ihre Spaces
+
+ {fetchError && (
+
+
{fetchError}
+
+
+ )}
+ {!fetchError && spaces.length === 0 ? (
+
+ Noch keine Spaces vorhanden. Erstellen Sie Ihren ersten Space!
+
+ ) : (
+
+ {spaces.map((space) => (
+
navigate(`/spaces/${space.id}`)}
+ >
+
+
+
+ {space.name || 'Unbenannt'}
+
+ {space.description && (
+
{space.description}
+ )}
+
+ Erstellt: {space.createdAt ? new Date(space.createdAt).toLocaleString('de-DE') : 'Unbekannt'}
+
+ {space.id && (
+
+ ID: {space.id}
+
+ )}
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ {/* Delete Confirmation Modal */}
+ {showDeleteModal && spaceToDelete && (
+
+
+
+
+
+
+ Möchten Sie den Space {spaceToDelete.name} wirklich löschen?
+
+
+ {fqdnCount > 0 ? (
+
+
+ ⚠️ Dieser Space enthält {fqdnCount} {fqdnCount === 1 ? 'FQDN' : 'FQDNs'}.
+ Der Space kann nur gelöscht werden, wenn Sie die FQDNs mitlöschen.
+
+
+
+ ) : (
+
+ ✓ Dieser Space enthält keine FQDNs und kann gelöscht werden.
+
+ )}
+
+
+ Diese Aktion kann nicht rückgängig gemacht werden.
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ )
+}
+
+export default Spaces
+
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
new file mode 100644
index 0000000..d37737f
--- /dev/null
+++ b/frontend/tailwind.config.js
@@ -0,0 +1,12 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: [
+ "./index.html",
+ "./src/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+}
+
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..a9a53fa
--- /dev/null
+++ b/frontend/vite.config.js
@@ -0,0 +1,16 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 3000,
+ proxy: {
+ '/api': {
+ target: 'http://localhost:8080',
+ changeOrigin: true
+ }
+ }
+ }
+})
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..ee5c21f
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,6 @@
+{
+ "name": "certigo-addon",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {}
+}