Merge pull request 'contribution-mechanic' (#21) from contribution-mechanic into main

Reviewed-on: mstoeck3/cc24-hub#21
This commit is contained in:
Mario Stöckl 2025-07-26 12:42:21 +00:00
commit 86d2370976
54 changed files with 6511 additions and 2522 deletions

6
.astro/content.d.ts vendored
View File

@ -164,11 +164,9 @@ declare module 'astro:content' {
type DataEntryMap = { type DataEntryMap = {
"knowledgebase": Record<string, { "knowledgebase": Record<string, {
id: string; id: string;
render(): Render[".md"]; body?: string;
slug: string;
body: string;
collection: "knowledgebase"; collection: "knowledgebase";
data: InferEntrySchema<"knowledgebase">; data: any;
rendered?: RenderedContent; rendered?: RenderedContent;
filePath?: string; filePath?: string;
}>; }>;

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
{ {
"_variables": { "_variables": {
"lastUpdateCheck": 1752478949435 "lastUpdateCheck": 1753528124767
} }
} }

View File

@ -1,18 +1,39 @@
# AI Configuration # ===========================================
AI_API_ENDPOINT=https://aiendpoint.org # ForensicPathways Environment Configuration
AI_API_KEY=your_apikey_here # ===========================================
AI_MODEL='ai_model_name_here'
# OIDC Configuration # Authentication & OIDC (Required)
OIDC_ENDPOINT=https://oidc-provider.org AUTH_SECRET=change-this-to-a-strong-secret-key-in-production
OIDC_CLIENT_ID=your_oidc_client_id OIDC_ENDPOINT=https://your-oidc-provider.com
OIDC_CLIENT_SECRET=your_oidc_client_secret OIDC_CLIENT_ID=your-oidc-client-id
AUTH_SECRET=your_super_secret_jwt_key_that_should_be_at_least_32_characters_long_for_security OIDC_CLIENT_SECRET=your-oidc-client-secret
AUTHENTICATION_NECESSARY=false # Always set this to true in prod # Auth Scopes - set to true in prod
AUTHENTICATION_NECESSARY_CONTRIBUTIONS=true
AUTHENTICATION_NECESSARY_AI=true
# Application Configuration (Required)
PUBLIC_BASE_URL=https://your-domain.com
NODE_ENV=production
# Application # AI Service Configuration (Required for AI features)
PUBLIC_BASE_URL=http://localhost:4321 AI_MODEL=mistral-large-latest
AI_API_ENDPOINT=https://api.mistral.ai
AI_API_KEY=your-mistral-api-key
AI_RATE_LIMIT_DELAY_MS=1000
NODE_ENV=development # Git Integration (Required for contributions)
GIT_REPO_URL=https://git.cc24.dev/mstoeck3/cc24-hub
GIT_PROVIDER=gitea
GIT_API_ENDPOINT=https://git.cc24.dev/api/v1
GIT_API_TOKEN=your-git-api-token
# File Upload Configuration (Optional)
LOCAL_UPLOAD_PATH=./public/uploads
# Nextcloud Integration (Optional)
NEXTCLOUD_ENDPOINT=https://your-nextcloud.com
NEXTCLOUD_USERNAME=your-username
NEXTCLOUD_PASSWORD=your-password
NEXTCLOUD_UPLOAD_PATH=/kb-media
NEXTCLOUD_PUBLIC_URL=https://your-nextcloud.com/s/

3
.gitignore vendored
View File

@ -81,3 +81,6 @@ src/_data/config.local.yaml
tmp/ tmp/
temp/ temp/
.astro/data-store.json .astro/data-store.json
.astro/settings.json
.astro/data-store.json
.astro/content.d.ts

810
README.md
View File

@ -1,34 +1,78 @@
# CC24-Hub # ForensicPathways
Ein kuratiertes Verzeichnis für digitale Forensik- und Incident-Response-Tools mit KI-gestützten Empfehlungen, entwickelt für die Seminargruppe CC24-w1. Ein kuratiertes Verzeichnis für Digital Forensics und Incident Response (DFIR) Tools, Methoden und Konzepte mit KI-gestützten Workflow-Empfehlungen.
## 🎯 Projektübersicht ## ✨ Funktionen
CC24-Hub bietet eine strukturierte Übersicht über bewährte DFIR-Tools, -Methoden und -Konzepte mit intelligenten Empfehlungsfunktionen. Das Projekt orientiert sich am NIST-Framework (SP 800-86) und kategorisiert nach forensischen Domänen und Untersuchungsphasen. ### 🎯 Hauptansichten
- **Kachelansicht (Grid View):** Übersichtliche Kartenansicht aller Tools/Methoden
- **Matrix-Ansicht:** Interaktive Matrix nach forensischen Domänen und Untersuchungsphasen (NIST Framework)
- **KI-Empfehlungen:** AI-gestützte Workflow-Empfehlungen basierend auf Szenario-Beschreibungen
### Hauptfunktionen ### 🔍 Navigation & Filterung
- **Tag-System:** Intelligente Filterung nach Kategorien und Eigenschaften
- **Volltext-Suche:** Durchsuchen von Namen, Beschreibungen und Tags
- **Domain/Phase-Filter:** Filterung nach forensischen Bereichen und Ermittlungsphasen
- **KI-gestützte Empfehlungen**: Workflow- und Tool-Vorschläge basierend auf forensischen Szenarien ### 📚 Inhaltstypen
- **Drei Kategorien**: Software-Tools, forensische Methoden UND Grundlagenkonzepte - **Software/Tools:** Open Source und proprietäre forensische Software
- **Matrix-Ansicht**: Visualisierung nach Domänen × Prozess-Phasen - **Methoden:** Bewährte forensische Verfahren und Prozesse
- **Erweiterte Filter**: Suche nach Name, Tags, Domäne, Phase, Lizenz - **Konzepte:** Grundlegendes Fachwissen und theoretische Grundlagen
- **CC24-Server Integration**: Direkte SSO-Links zu gehosteten Instanzen
- **Knowledgebase**: Erweiterte Dokumentation mit praktischen Erkenntnissen
- **Konzept-Verlinkung**: Automatische Verknüpfung zwischen Tools und Grundlagenkonzepten
- **Status-Monitoring**: Live-Überwachung verfügbarer Services
- **Responsive Design**: Dark/Light Mode, Mobile-optimiert
## 🛠️ Technischer Stack ### 📖 Knowledgebase
- **Erweiterte Dokumentation:** Detaillierte Artikel zu Tools und Methoden
- **Praktische Anleitungen:** Installation, Konfiguration und Best Practices
- **Markdown-basiert:** Einfache Erstellung und Wartung von Inhalten
- **Framework**: [Astro](https://astro.build/) mit Server-Side Rendering ### 🤝 Contribution-System
- **Backend**: Node.js mit API-Routen für KI und Authentifizierung - **Tool/Methoden-Beiträge:** Webformular für neue Einträge
- **Styling**: Vanilla CSS mit CSS Custom Properties - **Knowledgebase-Artikel:** Artikel-Editor mit Datei-Upload
- **Datenformat**: YAML für Tool-/Methoden-/Konzept-Definitionen - **Git-Integration:** Automatische Issue-Erstellung für Review-Prozess
- **KI-Integration**: Mistral AI über OpenAI-kompatible API - **File-Management:** Nextcloud-Integration für Medien-Uploads
- **Authentifizierung**: OIDC (OpenID Connect) mit JWT-Sessions
- **Node.js**: >=18.0.0
## 🚀 Installation & Deployment ### 🔐 Authentifizierung
- **OIDC-Integration:** Single Sign-On mit OpenID Connect
- **Berechtigungssteuerung:** Schutz für AI-Features und Contribution-System
- **Session-Management:** Sichere JWT-basierte Sessions
## 🛠 Technische Grundlage
- **Framework:** Astro 4.x mit TypeScript
- **Styling:** CSS Custom Properties mit Dark/Light Mode
- **API:** Node.js Backend mit Astro API Routes
- **Datenbank:** YAML-basierte Konfiguration (tools.yaml)
## 📋 Voraussetzungen
- **Node.js:** Version 18.x oder höher
- **npm:** Version 8.x oder höher
- **Nginx:** Für Reverse Proxy (Produktion)
## 🔧 Externe Abhängigkeiten (Optional)
### OIDC Provider
- **Zweck:** Benutzerauthentifizierung
- **Beispiel:** Nextcloud, Keycloak, Auth0
- **Konfiguration:** `OIDC_ENDPOINT`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`
### Nextcloud
- **Zweck:** File-Upload für Knowledgebase-Beiträge
- **Features:** Medien-Management, öffentliche Links
- **Konfiguration:** `NEXTCLOUD_ENDPOINT`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD`
### AI Service (Mistral/OpenAI-kompatibel)
- **Zweck:** KI-gestützte Tool-Empfehlungen
- **Konfiguration:** `AI_API_ENDPOINT`, `AI_API_KEY`, `AI_MODEL`
### Uptime Kuma
- **Zweck:** Status-Monitoring für gehostete Services
- **Integration:** Status-Badges in der Service-Übersicht
### Git Provider (Gitea/GitHub/GitLab)
- **Zweck:** Issue-Erstellung für Contributions
- **Konfiguration:** `GIT_PROVIDER`, `GIT_API_ENDPOINT`, `GIT_API_TOKEN`
## 🚀 Installation
### Lokale Entwicklung ### Lokale Entwicklung
@ -40,6 +84,10 @@ cd cc24-hub
# Dependencies installieren # Dependencies installieren
npm install npm install
# Umgebungsvariablen konfigurieren
cp .env.example .env
# .env bearbeiten (siehe Konfiguration unten)
# Development Server starten # Development Server starten
npm run dev npm run dev
``` ```
@ -48,150 +96,128 @@ Die Seite ist dann unter `http://localhost:4321` verfügbar.
### Produktions-Deployment ### Produktions-Deployment
#### Voraussetzungen #### 1. System vorbereiten
- Ubuntu/Debian server ```bash
- Node.js 18+ # System-Updates
- Nginx sudo apt update && sudo apt upgrade -y
- Domain
- SSL Zertifikat
#### Installationsschritte # Node.js installieren (Ubuntu/Debian)
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
##### 1. Vorbereitung # Nginx installieren
sudo apt install nginx -y
# Systemd für Service-Management
sudo systemctl enable nginx
```
#### 2. Anwendung installieren
```bash ```bash
# Klonen des Repositorys # Klonen des Repositorys
git clone https://git.cc24.dev/mstoeck3/cc24-hub sudo git clone https://git.cc24.dev/mstoeck3/cc24-hub /opt/cc24-hub
cd cc24-hub cd /opt/cc24-hub
# Abhängigkeiten installieren # Abhängigkeiten installieren
npm install sudo npm install
# Production-Build anstoßen # Production-Build erstellen
npm run build sudo npm run build
```
##### 2. Webroot vorbereiten
```bash
# Webroot erstellen und Berechtigungen setzen
sudo mkdir -p /var/www/cc24-hub
sudo chown -R $USER:$USER /var/www/cc24-hub
# Build in Webroot kopieren
sudo cp -r ./dist/* /var/www/cc24-hub/
sudo cp ./src/data/tools.yaml /var/www/cc24-hub/src/data/
sudo cp package.json /var/www/cc24-hub/
# Prod-Abhängigkeiten installieren
cd /var/www/cc24-hub
npm install --omit=dev
# Berechtigungen setzen # Berechtigungen setzen
sudo chown -R www-data:www-data /var/www/cc24-hub sudo chown -R www-data:www-data /opt/cc24-hub
``` ```
##### 3. Umgebungsvariablen setzen #### 3. Umgebungsvariablen konfigurieren
Erstelle `/var/www/cc24-hub/.env`: Erstelle `/opt/cc24-hub/.env`:
```bash ```bash
# AI Konfiguration # === GRUNDKONFIGURATION ===
AI_API_ENDPOINT=https://llm.mikoshi.de # hier geeigneten Endpunkt setzen NODE_ENV=production
AI_API_KEY=your_ai_api_key PUBLIC_BASE_URL=https://ihre-domain.de
AI_MODEL=mistral/mistral-small-latest # hier geeignetes KI-Modell wählen
# Authentifizierung ("false" setzen für Tests, oder wenn kostenlose KI verwendet wird) # === AI-KONFIGURATION (Optional) ===
AI_API_ENDPOINT=https://api.mistral.ai/v1
AI_API_KEY=your_mistral_api_key
AI_MODEL=mistral-small-latest
AI_RATE_LIMIT_DELAY_MS=2000
# === AUTHENTIFIZIERUNG ===
AUTHENTICATION_NECESSARY=true AUTHENTICATION_NECESSARY=true
OIDC_ENDPOINT=https://cloud.cc24.dev OIDC_ENDPOINT=https://ihr-oidc-provider.de
OIDC_CLIENT_ID=your_oidc_client_id OIDC_CLIENT_ID=cc24-hub-client
OIDC_CLIENT_SECRET=your_oidc_client_secret OIDC_CLIENT_SECRET=your_super_secret_client_secret
AUTH_SECRET=your_super_secret_jwt_key_min_32_chars AUTH_SECRET=your_jwt_secret_min_32_characters_long
# Public Configuration # === NEXTCLOUD (Optional) ===
PUBLIC_BASE_URL=https://your-domain.com # hier die URL setzen, mit der von außen zugegriffen wird NEXTCLOUD_ENDPOINT=https://ihre-nextcloud.de
NEXTCLOUD_USERNAME=cc24-hub-user
NEXTCLOUD_PASSWORD=nextcloud_app_password
NEXTCLOUD_UPLOAD_PATH=/kb-media
NEXTCLOUD_PUBLIC_URL=https://ihre-nextcloud.de/s
# === GIT-INTEGRATION (Optional) ===
GIT_PROVIDER=gitea
GIT_API_ENDPOINT=https://ihr-git-server.de/api/v1
GIT_API_TOKEN=your_git_api_token
GIT_REPO_URL=https://ihr-git-server.de/user/cc24-hub
# === UPLOAD-KONFIGURATION ===
LOCAL_UPLOAD_PATH=/opt/cc24-hub/public/uploads
``` ```
```bash ```bash
# .env sichern # Berechtigungen sichern
sudo chmod 600 /var/www/cc24-hub/.env sudo chmod 600 /opt/cc24-hub/.env
sudo chown www-data:www-data /var/www/cc24-hub/.env sudo chown www-data:www-data /opt/cc24-hub/.env
``` ```
##### 4. Systemd-Service erstellen #### 4. Nginx konfigurieren
Create `/etc/systemd/system/cc24-hub.service`:
```ini
[Unit]
Description=CC24-Hub DFIR Tool Directory
After=network.target
Wants=network.target
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/var/www/cc24-hub
ExecStart=/usr/bin/node server/entry.mjs
Restart=always
RestartSec=5
StartLimitInterval=60s
StartLimitBurst=3
# Environment
Environment=NODE_ENV=production
Environment=PORT=3000
# Security
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/var/www/cc24-hub
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=cc24-hub
[Install]
WantedBy=multi-user.target
```
##### 5. Nginx Reverse Proxy konfigurieren
Erstelle `/etc/nginx/sites-available/cc24-hub`: Erstelle `/etc/nginx/sites-available/cc24-hub`:
```nginx ```nginx
server { server {
listen 80; listen 80;
server_name your-domain.com; server_name ihre-domain.de;
# Redirect HTTP to HTTPS
return 301 https://$server_name$request_uri; return 301 https://$server_name$request_uri;
} }
server { server {
listen 443 ssl http2; listen 443 ssl http2;
server_name your-domain.com; server_name ihre-domain.de;
# SSL Configuration (adjust paths for your certificates) # SSL Konfiguration (Let's Encrypt empfohlen)
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; ssl_certificate /etc/letsencrypt/live/ihre-domain.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/ihre-domain.de/privkey.pem;
# SSL Security
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# Security Headers # Security Headers
add_header X-Frame-Options DENY; add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff; add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block"; add_header X-XSS-Protection "1; mode=block";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header Referrer-Policy "strict-origin-when-cross-origin";
# Proxy to Node.js application # Static Files
location / { location / {
proxy_pass http://localhost:3000; try_files $uri $uri/ @nodejs;
root /opt/cc24-hub/dist;
index index.html;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?|ttf)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
# API Routes to Node.js
location @nodejs {
proxy_pass http://localhost:4321;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade'; proxy_set_header Connection 'upgrade';
@ -204,359 +230,247 @@ server {
proxy_connect_timeout 75s; proxy_connect_timeout 75s;
} }
# Optional: Serve static assets directly (performance optimization) # Upload limit
location /_astro/ { client_max_body_size 50M;
proxy_pass http://localhost:3000;
expires 1y;
add_header Cache-Control "public, immutable";
}
} }
``` ```
##### 6. Daemon starten und Autostart setzen
```bash ```bash
# Enable Nginx site # Site aktivieren
sudo ln -s /etc/nginx/sites-available/cc24-hub /etc/nginx/sites-enabled/ sudo ln -s /etc/nginx/sites-available/cc24-hub /etc/nginx/sites-enabled/
sudo nginx -t sudo nginx -t
sudo systemctl reload nginx sudo systemctl reload nginx
```
# Enable and start CC24-Hub service #### 5. Systemd Service einrichten
Erstelle `/etc/systemd/system/cc24-hub.service`:
```ini
[Unit]
Description=ForensicPathways DFIR Guide
After=network.target nginx.service
Wants=nginx.service
[Service]
Type=exec
User=www-data
Group=www-data
WorkingDirectory=/opt/cc24-hub
Environment=NODE_ENV=production
ExecStart=/usr/bin/node ./dist/server/entry.mjs
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
# Security
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/opt/cc24-hub
CapabilityBoundingSet=
# Resource Limits
LimitNOFILE=65536
MemoryMax=512M
[Install]
WantedBy=multi-user.target
```
```bash
# Service aktivieren und starten
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable cc24-hub sudo systemctl enable cc24-hub
sudo systemctl start cc24-hub sudo systemctl start cc24-hub
# Check status # Status prüfen
sudo systemctl status cc24-hub sudo systemctl status cc24-hub
``` ```
##### 7. Deployment verifizieren ## 🔧 Konfiguration
### Minimalkonfiguration (ohne Auth)
```bash ```bash
# Check application logs # Nur für Tests geeignet
sudo journalctl -u cc24-hub -f AUTHENTICATION_NECESSARY=false
PUBLIC_BASE_URL=http://localhost:4321
# Check if app is responding
curl http://localhost:3000
# Check external access
curl https://your-domain.com
``` ```
#### OIDC Konfigurieren ### Tools-Datenbank
Nextcloud OIDC Einstellungen (sollte auch mit anderen OIDC-Anwendungen klappen): Die Tools werden in `src/data/tools.yaml` verwaltet. Vollständiges Beispiel:
- **Redirect URI**: `https://your-domain.com/auth/callback`
- **Logout URI**: `https://your-domain.com`
## 🔧 Datenformat & Kategorien
Die CC24-Hub verwaltet drei Kategorien von Einträgen in `src/data/tools.yaml`:
### 1. Software-Tools
```yaml ```yaml
tools: tools:
- name: "Autopsy" - name: Autopsy
icon: "📦" type: software # software|method|concept
type: "software" description: >-
description: "Die führende Open-Source-Alternative zu kommerziellen Forensik-Suiten" Die führende Open-Source-Alternative zu kommerziellen Forensik-Suiten mit
domains: ["incident-response", "law-enforcement"] intuitiver grafischer Oberfläche. Besonders stark in der Timeline-Analyse,
phases: ["examination", "analysis"] Keyword-Suche und dem Carving gelöschter Dateien. Die modulare
platforms: ["Windows", "Linux"] Plugin-Architektur erlaubt Erweiterungen für spezielle
skillLevel: "intermediate" Untersuchungsszenarien.
accessType: "download" icon: 📦
url: "https://www.autopsy.com/" skillLevel: intermediate # novice|beginner|intermediate|advanced|expert
projectUrl: "https://autopsy.cc24.dev" # CC24-Server URL (optional) url: https://www.autopsy.com/
license: "Apache 2.0" domains:
knowledgebase: true # Hat erweiterte Dokumentation - incident-response
related_concepts: ["Hash Functions & Digital Signatures", "SQL Query Fundamentals"] # Verknüpfung zu Konzepten - static-investigations
tags: ["gui", "filesystem", "timeline-analysis"] - malware-analysis
statusUrl: "https://status.example.com/badge/1/status" # Status-Badge URL (optional) - mobile-forensics
``` - cloud-forensics
phases:
- examination
- analysis
platforms:
- Windows
- Linux
related_concepts:
- SQL Query Fundamentals
- Hash Functions & Digital Signatures
accessType: download # download|web|api|cli|service
license: Apache 2.0
knowledgebase: false # true für erweiterte Dokumentation
tags:
- gui
- filesystem
- timeline-analysis
- carving
- artifact-extraction
- keyword-search
# Optional: Für gehostete Services
projectUrl: https://autopsy.ihre-domain.de
statusUrl: https://status.ihre-domain.de/api/badge/1/status
### 2. Forensische Methoden # Beispiel Methode
- name: Live Response Methodology
type: method
description: >-
Strukturierte Vorgehensweise zur Sammlung volatiler Daten
von laufenden Systemen ohne Shutdown.
icon: 📋
skillLevel: advanced
url: https://www.sans.org/white-papers/live-response/
domains:
- incident-response
phases:
- data-collection
related_concepts:
- Memory Forensics Fundamentals
tags:
- volatile-data
- live-analysis
- methodology
knowledgebase: true
```yaml # Beispiel Konzept
- name: "Live Memory Acquisition Procedure" - name: Hash Functions & Digital Signatures
icon: "📋" type: concept
type: "method" description: >-
description: "Standardisiertes Verfahren zur forensisch korrekten Akquisition des Arbeitsspeichers" Kryptographische Grundlagen für Datenintegrität und
domains: ["incident-response", "law-enforcement"] Authentifizierung in der digitalen Forensik.
phases: ["data-collection"] icon: 🔐
platforms: [] # Methoden haben keine Plattformen skillLevel: intermediate
skillLevel: "advanced" url: https://en.wikipedia.org/wiki/Cryptographic_hash_function
accessType: null domains:
url: "https://www.nist.gov/publications/guide-integrating-forensic-techniques" - incident-response
projectUrl: null - static-investigations
license: null - malware-analysis
phases:
- data-collection
- examination
tags:
- cryptography
- data-integrity
- evidence-preservation
knowledgebase: false knowledgebase: false
related_concepts: null # Können optional Konzepte verknüpfen
tags: ["memory-acquisition", "volatile-evidence", "procedure"] # Konfiguration der Domänen
domains:
- id: incident-response
name: Incident Response & Breach-Untersuchung
- id: static-investigations
name: Datenträgerforensik & Ermittlungen
- id: malware-analysis
name: Malware-Analyse & Reverse Engineering
- id: mobile-forensics
name: Mobile Geräte & App-Forensik
- id: cloud-forensics
name: Cloud & Virtuelle Umgebungen
# Konfiguration der Phasen (NIST Framework)
phases:
- id: data-collection
name: Datensammlung
description: Imaging, Acquisition, Remote Collection Tools
- id: examination
name: Auswertung
description: Parsing, Extraction, Initial Analysis Tools
- id: analysis
name: Analyse
description: Deep Analysis, Correlation, Visualization Tools
- id: reporting
name: Bericht & Präsentation
description: Documentation, Visualization, Presentation Tools
# Domänenübergreifende Kategorien
domain-agnostic-software:
- id: collaboration-general
name: Übergreifend & Kollaboration
description: Cross-cutting tools and collaboration platforms
- id: specific-os
name: Betriebssysteme
description: Operating Systems which focus on forensics
``` ```
### 3. Grundlagenkonzepte (NEU) ## 📦 Updates
```yaml
- name: "Regular Expressions (Regex)"
icon: "🔤"
type: "concept"
description: "Pattern matching language for searching, extracting, and manipulating text"
domains: ["incident-response", "malware-analysis"]
phases: ["examination", "analysis"]
platforms: [] # Konzepte haben keine Plattformen
skillLevel: "intermediate"
accessType: null
url: "https://regexr.com/"
projectUrl: null
license: null
knowledgebase: true # Erweiterte Erklärung in Knowledgebase
related_concepts: null # Konzepte verweisen nicht auf andere Konzepte
tags: ["pattern-matching", "text-processing", "log-analysis"]
```
### Verfügbare Kategorien
**Domänen:**
- `incident-response` - Incident Response & Breach-Untersuchung
- `law-enforcement` - Strafverfolgung & Kriminalermittlung
- `malware-analysis` - Malware-Analyse & Reverse Engineering
- `fraud-investigation` - Betrugs- & Finanzkriminalität
- `network-forensics` - Netzwerk-Forensik & Traffic-Analyse
- `mobile-forensics` - Mobile Geräte & App-Forensik
- `cloud-forensics` - Cloud & Virtuelle Umgebungen
- `ics-forensics` - Industrielle Kontrollsysteme (ICS/SCADA)
**Phasen (NIST SP 800-86):**
- `data-collection` - Datensammlung
- `examination` - Auswertung
- `analysis` - Analyse
- `reporting` - Bericht & Präsentation
**Domain-agnostic Kategorien:**
- `collaboration-general` - Übergreifend & Kollaboration
- `specific-os` - Betriebssysteme
## 📚 Knowledgebase-System
### Erweiterte Dokumentation erstellen
Die Knowledgebase bietet detaillierte Artikel für Tools, Methoden und Konzepte. So erstellen Sie neue Einträge:
#### 1. Knowledgebase-Flag setzen
Setzen Sie in `src/data/tools.yaml` das Flag:
```yaml
knowledgebase: true
```
#### 2. Artikel-Datei erstellen
Erstellen Sie eine Markdown-Datei in `src/content/knowledgebase/`:
**Dateiname-Schema:** `[tool-name-slug].md`
**Beispiel:** `src/content/knowledgebase/autopsy.md`
```markdown
---
title: "Autopsy - Umfassende Forensik-Suite"
tool_name: "Autopsy"
description: "Detaillierte Anleitung und Best Practices für Autopsy"
last_updated: 2024-01-15
author: "CC24-Team"
difficulty: "intermediate"
categories: ["filesystem-analysis", "timeline-analysis"]
tags: ["gui", "windows", "linux", "open-source"]
sections:
overview: true
installation: true
configuration: true
usage_examples: true
best_practices: true
troubleshooting: true
advanced_topics: false
review_status: "published"
---
# Übersicht
Autopsy ist eine grafische Benutzeroberfläche für The Sleuth Kit (TSK) und bietet...
## Installation
### Windows
1. Download der neuesten Version von [autopsy.com](https://www.autopsy.com/)
2. Ausführung des Installers mit Administratorrechten
3. ...
## Konfiguration
### Grundeinstellungen
- Arbeitsverzeichnis festlegen
- Hash-Algorithmen auswählen
- ...
## Verwendungsbeispiele
### Fall 1: Gelöschte Dateien wiederherstellen
1. Neuen Fall erstellen
2. Image hinzufügen
3. ...
## Best Practices
- Immer Hash-Verifikation durchführen
- Regelmäßige Backups der Case-Datenbank
- ...
## Troubleshooting
### Problem: Autopsy startet nicht
**Lösung:** Java-Version überprüfen...
### Problem: Langsame Performance
**Lösung:** RAM-Zuteilung erhöhen...
## Weiterführende Themen
- Integration mit externen Tools
- Custom Modules entwickeln
- ...
```
#### 3. Schema-Validierung
Das System validiert automatisch folgende Felder:
**Pflichtfelder:**
- `title`: Anzeigename des Artikels
- `tool_name`: Exakter Name aus tools.yaml
- `description`: Kurze Beschreibung
- `last_updated`: Datum der letzten Aktualisierung
- `difficulty`: `novice|beginner|intermediate|advanced|expert`
**Optionale Felder:**
- `author`: Standard "CC24-Team"
- `categories`: Array von Kategorien
- `tags`: Array von Tags
- `sections`: Welche Abschnitte enthalten sind
- `review_status`: `draft|review|published` (Standard: `published`)
#### 4. Automatische Verlinkung
- Artikel sind automatisch über `/knowledgebase/[tool-slug]` erreichbar
- Links werden automatisch in Tool-Details angezeigt
- Suchfunktion indiziert Artikel-Inhalte
### Konzept-Verlinkung
Tools können mit Grundlagenkonzepten verknüpft werden:
```yaml
# Tool-Definition
- name: "Autopsy"
related_concepts: ["Hash Functions & Digital Signatures", "SQL Query Fundamentals"]
# Konzept-Definition
- name: "Hash Functions & Digital Signatures"
type: "concept"
description: "Cryptographic principles for data integrity verification"
```
Die KI-Empfehlungen nutzen diese Verlinkungen für Hintergrundwissen-Empfehlungen.
## 🤖 KI-Integration
### Workflow-Empfehlungen
Beschreibung forensischer Szenarien für maßgeschneiderte Workflows mit phasenbasierten Tool-Empfehlungen und Prioritätsbewertung.
### Tool-spezifische Empfehlungen
Konkrete Tool-Vorschläge für spezifische Probleme mit detaillierten Begründungen, Implementierungsansätzen und Vor-/Nachteilen.
### Konzept-Integration
Die KI berücksichtigt automatisch verknüpfte Grundlagenkonzepte und empfiehlt relevantes Hintergrundwissen.
**API-Endpunkt:** `/api/ai/query`
- **Rate Limiting**: 10 Anfragen pro Minute pro Benutzer
- **Modi**: `workflow` (Szenario-basiert) oder `tool` (Problem-spezifisch)
- **Authentifizierung**: Optional konfigurierbar via `AUTHENTICATION_NECESSARY`
## 🔐 Authentifizierung
OIDC-Integration mit JWT-Sessions:
- **6 Stunden Gültigkeit**
- **HTTP-Only Cookies** mit CSRF-Schutz
- **Nextcloud/Keycloak kompatibel**
**Relevante Dateien:**
- `src/utils/auth.ts` - Kern-Authentifizierungslogik
- `src/pages/api/auth/` - Auth-API-Endpunkte
## 📁 Datei-Referenz
### Wichtige Konfigurationsdateien
- `src/data/tools.yaml` - Hauptdatenbank für Tools, Methoden und Konzepte
- `src/content/config.ts` - Schema für Knowledgebase-Artikel
- `src/utils/dataService.js` - Datenverarbeitungslogik
- `src/styles/global.css` - Zentrale Stylesheet-Definitionen
### Content-Verzeichnisse
- `src/content/knowledgebase/` - Knowledgebase-Artikel (Markdown)
- `src/components/` - Wiederverwendbare UI-Komponenten
- `src/pages/api/` - Backend-API-Endpunkte
## 🤝 Beitragen
### Tool/Methode/Konzept hinzufügen
**Option 1: Direkte YAML-Bearbeitung**
1. Fork des Repositories erstellen
2. `src/data/tools.yaml` bearbeiten
3. Bei Bedarf Knowledgebase-Artikel erstellen
4. Pull Request mit Beschreibung erstellen
**Option 2: Web-Editor verwenden**
1. YAML-Editor öffnen (`/dfir_yaml_editor.html`)
2. Eintrag hinzufügen (Tool/Methode/Konzept)
3. YAML exportieren und in Pull Request einreichen
### Knowledgebase-Artikel erweitern
1. Tool in `tools.yaml` identifizieren
2. `knowledgebase: true` setzen
3. Artikel in `src/content/knowledgebase/[slug].md` erstellen
4. Schema-Validierung beachten
5. Pull Request einreichen
### Korrekturen & Verbesserungen
- Bug Reports und Feature Requests über Issues melden
- Code-Beiträge über Pull Requests willkommen
- Dokumentation und Übersetzungen erwünscht
## 🐛 Troubleshooting
**KI-Empfehlungen funktionieren nicht:**
- `.env` Datei korrekt konfiguriert?
- `AUTHENTICATION_NECESSARY=false` für Tests setzen
- API-Endpoint erreichbar?
**Authentifizierung schlägt fehl:**
- OIDC-Endpoints korrekt?
- Redirect-URIs im OIDC-Provider registriert?
- `AUTH_SECRET` mindestens 32 Zeichen?
**Knowledgebase-Artikel werden nicht angezeigt:**
- `knowledgebase: true` in tools.yaml gesetzt?
- Markdown-Datei existiert in `src/content/knowledgebase/`?
- Schema-Validierung erfolgreich?
**Logs prüfen:**
```bash ```bash
# Anwendungs-Logs # Repository aktualisieren
sudo journalctl -u cc24-hub -f cd /opt/cc24-hub
sudo git pull
# Development-Modus # Dependencies aktualisieren
npm run dev sudo npm install
# Rebuild
sudo npm run build
# Service neustarten
sudo systemctl restart cc24-hub
``` ```
## 💾 Backup
Wichtige Dateien für Backup:
```bash
/opt/cc24-hub/src/data/tools.yaml
/opt/cc24-hub/.env
/etc/nginx/sites-available/cc24-hub
/etc/systemd/system/cc24-hub.service
```
## 🤝 Beiträge
Contributions sind willkommen! Bitte:
1. Issue im Repository erstellen
2. Feature-Branch erstellen
3. Pull Request öffnen
4. Tests durchführen
## 📞 Support
Bei Problemen oder Fragen:
- **Issues:** [Repository Issues](https://git.cc24.dev/mstoeck3/cc24-hub/issues)
- **Dokumentation:** Siehe `/knowledgebase` auf der Website
## 📄 Lizenz
Dieses Projekt steht unter der **BSD-3-Clause** Lizenz.

View File

@ -14,5 +14,6 @@ export default defineConfig({
server: { server: {
port: 4321, port: 4321,
host: true host: true
} },
allowImportingTsExtensions: true
}); });

File diff suppressed because it is too large Load Diff

View File

@ -7,20 +7,19 @@
"start": "astro dev", "start": "astro dev",
"build": "astro build", "build": "astro build",
"preview": "astro preview", "preview": "astro preview",
"astro": "astro", "astro": "astro"
"check:health": "curl -f http://localhost:4321/health || exit 1"
}, },
"dependencies": { "dependencies": {
"@astrojs/node": "^9.3.0", "@astrojs/node": "^9.3.0",
"astro": "^5.12.0", "astro": "^5.12.3",
"cookie": "^0.6.0", "cookie": "^1.0.2",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"jose": "^5.2.0", "jose": "^5.2.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@types/cookie": "^0.6.0",
"@types/js-yaml": "^4.0.9" "@types/js-yaml": "^4.0.9"
}, },
"engines": { "engines": {

View File

@ -64,7 +64,7 @@ const domainAgnosticSoftware = data['domain-agnostic-software'] || [];
<line x1="12" y1="8" x2="12" y2="12"/> <line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/> <line x1="12" y1="16" x2="12.01" y2="16"/>
</svg> </svg>
Ihre Anfrage wird an mistral.ai übertragen und unterliegt deren Ihre Anfrage wird über die kostenlose API von mistral.ai übertragen, wird für KI-Training verwendet und unterliegt deren
<a href="https://mistral.ai/privacy-policy/" target="_blank" rel="noopener noreferrer" style="color: var(--color-primary); text-decoration: underline;">Datenschutzrichtlinien</a> <a href="https://mistral.ai/privacy-policy/" target="_blank" rel="noopener noreferrer" style="color: var(--color-primary); text-decoration: underline;">Datenschutzrichtlinien</a>
</p> </p>
</div> </div>
@ -82,6 +82,7 @@ const domainAgnosticSoftware = data['domain-agnostic-software'] || [];
</div> </div>
</div> </div>
<!-- This should be your loading section in AIQueryInterface.astro -->
<!-- Loading State --> <!-- Loading State -->
<div id="ai-loading" class="ai-loading" style="display: none; text-align: center; padding: 2rem;"> <div id="ai-loading" class="ai-loading" style="display: none; text-align: center; padding: 2rem;">
<div style="display: inline-block; margin-bottom: 1rem;"> <div style="display: inline-block; margin-bottom: 1rem;">
@ -92,6 +93,32 @@ const domainAgnosticSoftware = data['domain-agnostic-software'] || [];
</svg> </svg>
</div> </div>
<p id="loading-text" style="color: var(--color-text-secondary);">Analysiere Szenario und generiere Empfehlungen...</p> <p id="loading-text" style="color: var(--color-text-secondary);">Analysiere Szenario und generiere Empfehlungen...</p>
<!-- Queue Status Display - THIS SECTION SHOULD BE PRESENT -->
<div id="queue-status" style="margin-top: 1rem; padding: 1rem; background-color: var(--color-bg-secondary); border-radius: 0.5rem; border: 1px solid var(--color-border); display: none;">
<div style="display: flex; align-items: center; justify-content: center; gap: 1rem; margin-bottom: 0.75rem;">
<div style="display: flex; align-items: center; gap: 0.5rem;">
<div id="queue-position-badge" style="width: 24px; height: 24px; background-color: var(--color-primary); color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 0.875rem;">1</div>
<span style="font-weight: 500;">Position in Warteschlange</span>
</div>
</div>
<div style="font-size: 0.875rem; color: var(--color-text-secondary); text-align: center;">
<div id="queue-length-info" style="margin-bottom: 0.5rem;">
<span id="queue-length">0</span> Anfrage(n) in der Warteschlange
</div>
<div id="estimated-time-info">
Geschätzte Wartezeit: <span id="estimated-time">--</span>
</div>
<div id="task-id-info" style="margin-top: 0.5rem; font-family: monospace; font-size: 0.75rem; opacity: 0.7;">
Task-ID: <span id="current-task-id">--</span>
</div>
</div>
<!-- Progress bar -->
<div style="margin-top: 1rem; background-color: var(--color-bg-tertiary); border-radius: 0.25rem; height: 4px; overflow: hidden;">
<div id="queue-progress" style="height: 100%; background-color: var(--color-primary); width: 0%; transition: width 0.3s ease;"></div>
</div>
</div>
</div> </div>
<!-- Error State --> <!-- Error State -->
@ -240,7 +267,7 @@ document.addEventListener('DOMContentLoaded', () => {
aiInput.addEventListener('input', updateCharacterCount); aiInput.addEventListener('input', updateCharacterCount);
updateCharacterCount(); updateCharacterCount();
// Submit handler // Submit handler with enhanced queue feedback
const handleSubmit = async () => { const handleSubmit = async () => {
const query = aiInput.value.trim(); const query = aiInput.value.trim();
@ -254,15 +281,79 @@ document.addEventListener('DOMContentLoaded', () => {
return; return;
} }
// Generate task ID for tracking
const taskId = `ai_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
// Hide previous results and errors // Hide previous results and errors
aiResults.style.display = 'none'; aiResults.style.display = 'none';
aiError.style.display = 'none'; aiError.style.display = 'none';
aiLoading.style.display = 'block'; aiLoading.style.display = 'block';
// Show queue status section
const queueStatus = document.getElementById('queue-status');
const taskIdDisplay = document.getElementById('current-task-id');
if (queueStatus && taskIdDisplay) {
queueStatus.style.display = 'block';
taskIdDisplay.textContent = taskId;
}
// Disable submit button // Disable submit button
aiSubmitBtn.disabled = true; aiSubmitBtn.disabled = true;
submitBtnText.textContent = currentMode === 'workflow' ? 'Generiere Empfehlungen...' : 'Suche passende Methode...'; submitBtnText.textContent = currentMode === 'workflow' ? 'Generiere Empfehlungen...' : 'Suche passende Methode...';
// Start queue status polling
let statusInterval;
let startTime = Date.now();
const updateQueueStatus = async () => {
try {
const response = await fetch(`/api/ai/queue-status?taskId=${taskId}`);
const data = await response.json();
if (data.success) {
const queueLength = document.getElementById('queue-length');
const estimatedTime = document.getElementById('estimated-time');
const positionBadge = document.getElementById('queue-position-badge');
const progressBar = document.getElementById('queue-progress');
if (queueLength) queueLength.textContent = data.queueLength;
if (estimatedTime) {
if (data.estimatedWaitTime > 0) {
estimatedTime.textContent = formatDuration(data.estimatedWaitTime);
} else {
estimatedTime.textContent = 'Verarbeitung läuft...';
}
}
if (positionBadge && data.currentPosition) {
positionBadge.textContent = data.currentPosition;
// Update progress bar (inverse of position)
if (progressBar && data.queueLength > 0) {
const progress = Math.max(0, ((data.queueLength - data.currentPosition + 1) / data.queueLength) * 100);
progressBar.style.width = `${progress}%`;
}
}
// If processing and no position (request is being handled)
if (data.isProcessing && !data.currentPosition) {
if (positionBadge) positionBadge.textContent = '⚡';
if (progressBar) progressBar.style.width = '100%';
if (estimatedTime) estimatedTime.textContent = 'Verarbeitung läuft...';
}
}
} catch (error) {
console.warn('Queue status update failed:', error);
}
};
// Initial status update
updateQueueStatus();
// Poll every 500ms for status updates
statusInterval = setInterval(updateQueueStatus, 500);
try { try {
const response = await fetch('/api/ai/query', { const response = await fetch('/api/ai/query', {
method: 'POST', method: 'POST',
@ -271,12 +362,16 @@ document.addEventListener('DOMContentLoaded', () => {
}, },
body: JSON.stringify({ body: JSON.stringify({
query, query,
mode: currentMode mode: currentMode,
taskId // Include task ID for backend tracking
}) })
}); });
const data = await response.json(); const data = await response.json();
// Clear status polling
if (statusInterval) clearInterval(statusInterval);
if (!response.ok) { if (!response.ok) {
throw new Error(data.error || `HTTP ${response.status}`); throw new Error(data.error || `HTTP ${response.status}`);
} }
@ -300,6 +395,10 @@ document.addEventListener('DOMContentLoaded', () => {
} catch (error) { } catch (error) {
console.error('AI query failed:', error); console.error('AI query failed:', error);
// Clear status polling
if (statusInterval) clearInterval(statusInterval);
aiLoading.style.display = 'none'; aiLoading.style.display = 'none';
aiError.style.display = 'block'; aiError.style.display = 'block';
@ -314,10 +413,13 @@ document.addEventListener('DOMContentLoaded', () => {
aiErrorMessage.textContent = `Fehler: ${error.message}`; aiErrorMessage.textContent = `Fehler: ${error.message}`;
} }
} finally { } finally {
// Re-enable submit button // Re-enable submit button and hide queue status
aiSubmitBtn.disabled = false; aiSubmitBtn.disabled = false;
const config = modeConfig[currentMode]; const config = modeConfig[currentMode];
submitBtnText.textContent = config.submitText; submitBtnText.textContent = config.submitText;
if (queueStatus) queueStatus.style.display = 'none';
if (statusInterval) clearInterval(statusInterval);
} }
}; };
@ -616,7 +718,7 @@ document.addEventListener('DOMContentLoaded', () => {
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem; vertical-align: middle;"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem; vertical-align: middle;">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/> <path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg> </svg>
Passende Tool-Empfehlungen Passende Empfehlungen
</h3> </h3>
<p style="margin: 0; opacity: 0.9; line-height: 1.5;"> <p style="margin: 0; opacity: 0.9; line-height: 1.5;">
Basierend auf Ihrer Anfrage: "<em>${originalQuery.slice(0, 100)}${originalQuery.length > 100 ? '...' : ''}</em>" Basierend auf Ihrer Anfrage: "<em>${originalQuery.slice(0, 100)}${originalQuery.length > 100 ? '...' : ''}</em>"

View File

@ -0,0 +1,72 @@
---
// src/components/ContributionButton.astro - CLEANED: Removed duplicate auth script
export interface Props {
type: 'edit' | 'new' | 'write';
toolName?: string;
variant?: 'primary' | 'secondary' | 'small';
text?: string;
className?: string;
style?: string;
}
const {
type,
toolName,
variant = 'secondary',
text,
className = '',
style = ''
} = Astro.props;
// Generate appropriate URLs and text based on type
let href: string;
let defaultText: string;
let icon: string;
switch (type) {
case 'edit':
href = `/contribute/tool?edit=${encodeURIComponent(toolName || '')}`;
defaultText = 'Edit';
icon = `<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>`;
break;
case 'new':
href = '/contribute/tool';
defaultText = 'Add Tool';
icon = `<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>`;
break;
case 'write':
href = '/contribute/knowledgebase';
defaultText = 'Write Article';
icon = `<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>`;
break;
default:
href = '/contribute';
defaultText = 'Contribute';
icon = `<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>`;
}
const displayText = text || defaultText;
const buttonClass = `btn btn-${variant} ${className}`.trim();
const iconSize = variant === 'small' ? '14' : '16';
---
<a
href={href}
class={buttonClass}
style={style}
data-contribute-button={type}
data-tool-name={toolName}
title={`${displayText}${toolName ? `: ${toolName}` : ''}`}
>
<svg width={iconSize} height={iconSize} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
<Fragment set:html={icon} />
</svg>
{displayText}
</a>

View File

@ -7,7 +7,7 @@
<div class="footer-content"> <div class="footer-content">
<div> <div>
<p class="text-muted" style="margin: 0;"> <p class="text-muted" style="margin: 0;">
© 2025 CC24-Guide - Lizensiert unter BSD-3-Clause © 2025 ForensicPathways - Lizensiert unter BSD-3-Clause
</p> </p>
</div> </div>
<div style="display: flex; gap: 2rem; align-items: center;"> <div style="display: flex; gap: 2rem; align-items: center;">

View File

@ -1,4 +1,5 @@
--- ---
// src/components/Navigation.astro
import ThemeToggle from './ThemeToggle.astro'; import ThemeToggle from './ThemeToggle.astro';
const currentPath = Astro.url.pathname; const currentPath = Astro.url.pathname;
@ -8,9 +9,9 @@ const currentPath = Astro.url.pathname;
<div class="container"> <div class="container">
<div class="nav-wrapper"> <div class="nav-wrapper">
<a href="/" class="nav-brand"> <a href="/" class="nav-brand">
<img src="/logo-dark.png" alt="CC24-Guide" class="nav-logo nav-logo-light" /> <img src="/logo-dark.png" alt="ForensicPathways" class="nav-logo nav-logo-light" />
<img src="/logo-white.png" alt="CC24-Guide" class="nav-logo nav-logo-dark" /> <img src="/logo-white.png" alt="ForensicPathways" class="nav-logo nav-logo-dark" />
<span style="font-weight: 600; font-size: 1.125rem;">CC24-Guide</span> <span style="font-weight: 600; font-size: 1.125rem;">ForensicPathways</span>
</a> </a>
<ul class="nav-links"> <ul class="nav-links">
@ -24,6 +25,11 @@ const currentPath = Astro.url.pathname;
~/knowledgebase ~/knowledgebase
</a> </a>
</li> </li>
<li>
<a href="/contribute" class={`nav-link ${currentPath.startsWith('/contribute') ? 'active' : ''}`}>
~/contribute
</a>
</li>
<li> <li>
<a href="/status" class={`nav-link ${currentPath === '/status' ? 'active' : ''}`}> <a href="/status" class={`nav-link ${currentPath === '/status' ? 'active' : ''}`}>
~/status ~/status
@ -41,34 +47,3 @@ const currentPath = Astro.url.pathname;
</div> </div>
</div> </div>
</nav> </nav>
<style>
/* Logo theme switching */
.nav-logo-light {
display: block;
}
.nav-logo-dark {
display: none;
}
[data-theme="dark"] .nav-logo-light {
display: none;
}
[data-theme="dark"] .nav-logo-dark {
display: block;
}
/* Make brand clickable */
.nav-brand {
text-decoration: none;
color: inherit;
transition: var(--transition-fast);
}
.nav-brand:hover {
text-decoration: none;
opacity: 0.8;
}
</style>

View File

@ -1,4 +1,6 @@
--- ---
import { createToolSlug } from '../utils/toolHelpers.js';
export interface Props { export interface Props {
toolName: string; toolName: string;
context: 'card' | 'modal-primary' | 'modal-secondary'; context: 'card' | 'modal-primary' | 'modal-secondary';
@ -7,12 +9,8 @@ export interface Props {
const { toolName, context, size = 'small' } = Astro.props; const { toolName, context, size = 'small' } = Astro.props;
// Create URL-safe slug from tool name // AFTER: Single line with centralized function
const toolSlug = toolName.toLowerCase() const toolSlug = createToolSlug(toolName);
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Remove duplicate hyphens
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
const iconSize = size === 'small' ? '14' : '16'; const iconSize = size === 'small' ? '14' : '16';
--- ---

View File

@ -1,4 +1,7 @@
--- ---
// src/components/ToolCard.astro (ENHANCED - Added data attributes for filtering)
import ShareButton from './ShareButton.astro';
export interface Props { export interface Props {
tool: { tool: {
name: string; name: string;
@ -35,86 +38,101 @@ const hasValidProjectUrl = tool.projectUrl !== undefined &&
const hasKnowledgebase = tool.knowledgebase === true; const hasKnowledgebase = tool.knowledgebase === true;
// Determine card styling based on type and hosting status // Determine card styling based on type and hosting status
const cardClass = isConcept ? 'card card-concept tool-card' : const cardClass = isConcept ? 'card card-concept tool-card cursor-pointer' :
isMethod ? 'card card-method tool-card' : isMethod ? 'card card-method tool-card cursor-pointer' :
hasValidProjectUrl ? 'card card-hosted tool-card' : hasValidProjectUrl ? 'card card-hosted tool-card cursor-pointer' :
(tool.license !== 'Proprietary' ? 'card card-oss tool-card' : 'card tool-card'); (tool.license !== 'Proprietary' ? 'card card-oss tool-card cursor-pointer' : 'card tool-card cursor-pointer');
// ENHANCED: Data attributes for filtering
const toolDataAttributes = {
'data-tool-name': tool.name.toLowerCase(),
'data-tool-type': tool.type,
'data-tool-domains': (tool.domains || []).join(','),
'data-tool-phases': (tool.phases || []).join(','),
'data-tool-tags': (tool.tags || []).join(',').toLowerCase(),
'data-tool-platforms': (tool.platforms || []).join(','),
'data-tool-license': tool.license || '',
'data-tool-skill': tool.skillLevel,
'data-tool-description': tool.description.toLowerCase()
};
--- ---
<div class={cardClass} onclick={`window.showToolDetails('${tool.name}')`} style="cursor: pointer; border-left: 4px solid {isMethod ? 'var(--color-method)' : hasValidProjectUrl ? 'var(--color-hosted)' : tool.license !== 'Proprietary' ? 'var(--color-oss)' : 'var(--color-border)'};"> <div
<!-- Card Header with Fixed Height --> class={cardClass}
<div class="tool-card-header"> {...toolDataAttributes}
onclick={`window.showToolDetails('${tool.name}')`}
>
<!-- Card Header with Fixed Height -->
<div class="tool-card-header">
<h3> <h3>
{tool.icon && <span style="margin-right: 0.5rem; font-size: 1.125rem;">{tool.icon}</span>} {tool.icon && <span class="mr-2 text-lg">{tool.icon}</span>}
{tool.name} {tool.name}
</h3> </h3>
<div class="tool-card-badges"> <div class="tool-card-badges">
<!-- Only show CC24-Server and Knowledgebase badges --> <!-- Only show CC24-Server and Knowledgebase badges -->
{!isMethod && hasValidProjectUrl && <span class="badge badge-primary">CC24-Server</span>} {!isMethod && hasValidProjectUrl && <span class="badge badge-primary">CC24-Server</span>}
{hasKnowledgebase && <span class="badge badge-error">📖</span>} {hasKnowledgebase && <span class="badge badge-error">📖</span>}
<ShareButton toolName={tool.name} context="card" size="small" />
</div> </div>
</div> </div>
<!-- Description - Truncated to 2 lines --> <!-- Description - Truncated to 2 lines -->
<p class="text-muted"> <p class="text-muted">
{tool.description} {tool.description}
</p> </p>
<!-- Metadata - Compact Icons with Better Alignment --> <!-- Metadata - Compact Icons with Better Alignment -->
<div class="tool-card-metadata" style="display: flex; align-items: center; gap: 1rem; margin-bottom: 0.75rem; line-height: 1;"> <div class="tool-card-metadata flex items-center gap-4 mb-3" style="line-height: 1;">
<div class="metadata-item" style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: var(--color-text-secondary); flex-shrink: 1; min-width: 0;"> <div class="metadata-item flex items-center gap-2 text-xs text-secondary flex-shrink-1 min-w-0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink: 0;"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="flex-shrink-0">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect> <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="9" y1="9" x2="15" y2="9"></line> <line x1="9" y1="9" x2="15" y2="9"></line>
<line x1="9" y1="15" x2="15" y2="15"></line> <line x1="9" y1="15" x2="15" y2="15"></line>
</svg> </svg>
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;"> <span class="overflow-hidden min-w-0" style="text-overflow: ellipsis; white-space: nowrap;">
{tool.platforms.slice(0, 2).join(', ')}{tool.platforms.length > 2 ? '...' : ''} {tool.platforms.slice(0, 2).join(', ')}{tool.platforms.length > 2 ? '...' : ''}
</span> </span>
</div> </div>
<div class="metadata-item" style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: var(--color-text-secondary); flex-shrink: 1; min-width: 0;"> <div class="metadata-item flex items-center gap-2 text-xs text-secondary flex-shrink-1 min-w-0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink: 0;"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="flex-shrink-0">
<circle cx="12" cy="12" r="10"></circle> <circle cx="12" cy="12" r="10"></circle>
<path d="M12 6v6l4 2"></path> <path d="M12 6v6l4 2"></path>
</svg> </svg>
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;"> <span class="overflow-hidden min-w-0" style="text-overflow: ellipsis; white-space: nowrap;">
{tool.skillLevel} {tool.skillLevel}
</span> </span>
</div> </div>
<div class="metadata-item" style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: var(--color-text-secondary); flex-shrink: 1; min-width: 0;"> <div class="metadata-item flex items-center gap-2 text-xs text-secondary flex-shrink-1 min-w-0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink: 0;"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="flex-shrink-0">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline> <polyline points="14 2 14 8 20 8"></polyline>
</svg> </svg>
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;"> <span class="overflow-hidden min-w-0" style="text-overflow: ellipsis; white-space: nowrap;">
{isMethod ? 'Methode' : tool.license === 'Proprietary' ? 'Prop.' : tool.license?.split(' ')[0]} {isConcept ? 'Konzept' : isMethod ? 'Methode' : tool.license === 'Proprietary' ? 'Prop.' : tool.license?.split(' ')[0]}
</span> </span>
</div> </div>
</div> </div>
<!-- Tags - Two Lines with Fade --> <!-- Tags - Two Lines with Fade -->
<div class="tool-tags-container"> <div class="tool-tags-container">
{tool.tags.slice(0, 8).map(tag => ( {tool.tags.slice(0, 8).map(tag => (
<span class="tag">{tag}</span> <span class="tag">{tag}</span>
))} ))}
</div> </div>
<!-- Buttons - Fixed at Bottom --> <!-- Buttons - Fixed at Bottom (NO EDIT BUTTONS - Available in modals) -->
<div class="tool-card-buttons" onclick="event.stopPropagation();"> <div class="tool-card-buttons" onclick="event.stopPropagation();">
{isConcept ? ( {isConcept ? (
<!-- Concept button -->
<a href={tool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary single-button" style="background-color: var(--color-concept); border-color: var(--color-concept);"> <a href={tool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary single-button" style="background-color: var(--color-concept); border-color: var(--color-concept);">
Mehr erfahren Mehr erfahren
</a> </a>
) : isMethod ? ( ) : isMethod ? (
<!-- Method button -->
<a href={tool.projectUrl || tool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary single-button" style="background-color: var(--color-method); border-color: var(--color-method);"> <a href={tool.projectUrl || tool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary single-button" style="background-color: var(--color-method); border-color: var(--color-method);">
Zur Methode Zur Methode
</a> </a>
) : hasValidProjectUrl ? ( ) : hasValidProjectUrl ? (
<!-- Two buttons for hosted tools -->
<div class="button-row"> <div class="button-row">
<a href={tool.url} target="_blank" rel="noopener noreferrer" class="btn btn-secondary"> <a href={tool.url} target="_blank" rel="noopener noreferrer" class="btn btn-secondary">
Homepage Homepage
@ -124,10 +142,10 @@ const cardClass = isConcept ? 'card card-concept tool-card' :
</a> </a>
</div> </div>
) : ( ) : (
<!-- Single button for external tools -->
<a href={tool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary single-button"> <a href={tool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary single-button">
Software-Homepage Software-Homepage
</a> </a>
)} )}
</div> </div>
</div>
</div> </div>

View File

@ -1,11 +1,9 @@
--- ---
import { getToolsData } from '../utils/dataService.js'; import { getToolsData } from '../utils/dataService.js';
// Load tools data // Load tools data
const data = await getToolsData(); const data = await getToolsData();
const domains = data.domains; const domains = data.domains;
const phases = data.phases; const phases = data.phases;
@ -106,7 +104,6 @@ const sortedTags = Object.entries(tagFrequency)
</div> </div>
<!-- View Toggle --> <!-- View Toggle -->
<!--<div style="display: flex; gap: 1rem; margin-bottom: 1.5rem; align-items: center; flex-wrap: wrap;">-->
<div style="display: flex; gap: 1rem; margin-bottom: 1.5rem; align-items: center;"> <div style="display: flex; gap: 1rem; margin-bottom: 1.5rem; align-items: center;">
<button class="btn btn-secondary view-toggle active" style="height:50px" data-view="grid">Kachelansicht</button> <button class="btn btn-secondary view-toggle active" style="height:50px" data-view="grid">Kachelansicht</button>
<button class="btn btn-secondary view-toggle" style="height:50px" data-view="matrix">Matrix-Ansicht</button> <button class="btn btn-secondary view-toggle" style="height:50px" data-view="matrix">Matrix-Ansicht</button>
@ -127,7 +124,6 @@ const sortedTags = Object.entries(tagFrequency)
</div> </div>
</div> </div>
<script define:vars={{ toolsData: data.tools, tagFrequency, sortedTags }}> <script define:vars={{ toolsData: data.tools, tagFrequency, sortedTags }}>
// Store tools data globally for filtering // Store tools data globally for filtering
window.toolsData = toolsData; window.toolsData = toolsData;
@ -149,26 +145,6 @@ const sortedTags = Object.entries(tagFrequency)
let selectedPhase = ''; let selectedPhase = '';
let isTagCloudExpanded = false; let isTagCloudExpanded = false;
// Check authentication status and show/hide AI button
async function checkAuthAndShowAIButton() {
try {
const response = await fetch('/api/auth/status');
const data = await response.json();
// Show AI button if authentication is not required OR if user is authenticated
if (!data.authRequired || data.authenticated) {
if (aiViewToggle) {
aiViewToggle.style.display = 'inline-flex';
}
}
} catch (error) {
console.log('Auth check failed, AI button remains hidden');
}
}
// Call auth check on page load
checkAuthAndShowAIButton();
// Initialize tag cloud state // Initialize tag cloud state
function initTagCloud() { function initTagCloud() {
const visibleCount = 22; const visibleCount = 22;
@ -298,7 +274,7 @@ const sortedTags = Object.entries(tagFrequency)
} }
} }
// Filter function // ENHANCED: Filter function with better performance for show/hide pattern
function filterTools() { function filterTools() {
const searchTerm = searchInput.value.toLowerCase(); const searchTerm = searchInput.value.toLowerCase();
const selectedDomain = domainSelect.value; const selectedDomain = domainSelect.value;
@ -310,7 +286,7 @@ const sortedTags = Object.entries(tagFrequency)
const phases = tool.phases || []; const phases = tool.phases || [];
const tags = tool.tags || []; const tags = tool.tags || [];
// Search filter // Search filter - more comprehensive
if (searchTerm && !( if (searchTerm && !(
tool.name.toLowerCase().includes(searchTerm) || tool.name.toLowerCase().includes(searchTerm) ||
tool.description.toLowerCase().includes(searchTerm) || tool.description.toLowerCase().includes(searchTerm) ||
@ -329,12 +305,12 @@ const sortedTags = Object.entries(tagFrequency)
return false; return false;
} }
// Proprietary filter (skip for methods since they don't have licenses) // Proprietary filter (skip for methods and concepts since they don't have licenses)
if (!includeProprietary && !isMethod(tool) && tool.license === 'Proprietary') { if (!includeProprietary && !isMethod(tool) && tool.type !== 'concept' && tool.license === 'Proprietary') {
return false; return false;
} }
// Tag filter // Tag filter - ensure all selected tags are present
if (selectedTags.size > 0 && !Array.from(selectedTags).every(tag => tags.includes(tag))) { if (selectedTags.size > 0 && !Array.from(selectedTags).every(tag => tags.includes(tag))) {
return false; return false;
} }
@ -344,6 +320,7 @@ const sortedTags = Object.entries(tagFrequency)
// Update matrix highlighting // Update matrix highlighting
updateMatrixHighlighting(); updateMatrixHighlighting();
// Emit custom event with filtered results // Emit custom event with filtered results
window.dispatchEvent(new CustomEvent('toolsFiltered', { detail: filtered })); window.dispatchEvent(new CustomEvent('toolsFiltered', { detail: filtered }));
} }

View File

@ -2,8 +2,6 @@
import { getToolsData } from '../utils/dataService.js'; import { getToolsData } from '../utils/dataService.js';
import ShareButton from './ShareButton.astro'; import ShareButton from './ShareButton.astro';
// Load tools data // Load tools data
const data = await getToolsData(); const data = await getToolsData();
@ -37,17 +35,17 @@ domains.forEach((domain: any) => {
<div id="matrix-container" class="matrix-wrapper" style="display: none;"> <div id="matrix-container" class="matrix-wrapper" style="display: none;">
<!-- Domain-Agnostic Software Sections --> <!-- Domain-Agnostic Software Sections -->
{domainAgnosticTools.map((sectionData: any, index: number) => ( {domainAgnosticTools.map((sectionData: any, index: number) => (
<div id={`domain-agnostic-section-${sectionData.section.id}`} class="card collaboration-section-collapsed" style="margin-bottom: 1.5rem; border-left: 4px solid var(--color-accent);"> <div id={`domain-agnostic-section-${sectionData.section.id}`} class="card collaboration-section-collapsed mb-6 border-l-4" style="border-left-color: var(--color-accent);">
<div class="collaboration-header" onclick={`toggleDomainAgnosticSection('${sectionData.section.id}')`} style="cursor: pointer; display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.1rem;"> <div class="collaboration-header cursor-pointer flex items-center gap-3" onclick={`toggleDomainAgnosticSection('${sectionData.section.id}')`} style="margin-bottom: 0.1rem;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="2" style="margin-right: 0.5rem;"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="2" class="mr-2">
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/> <path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="8.5" cy="7" r="4"/> <circle cx="8.5" cy="7" r="4"/>
<line x1="20" y1="8" x2="20" y2="14"/> <line x1="20" y1="8" x2="20" y2="14"/>
<line x1="23" y1="11" x2="17" y2="11"/> <line x1="23" y1="11" x2="17" y2="11"/>
</svg> </svg>
<h3 style="margin: 0; color: var(--color-accent); font-size: 1.125rem;"> <h3 class="m-0 text-lg" style="color: var(--color-accent);">
{sectionData.section.name} {sectionData.section.name}
<span id={`count-${sectionData.section.id}`} class="badge" style="background-color: var(--color-text-secondary); color: var(--color-bg); margin-left: 0.5rem; font-size: 0.75rem;"> <span id={`count-${sectionData.section.id}`} class="badge text-xs" style="background-color: var(--color-text-secondary); color: var(--color-bg); margin-left: 0.5rem;">
{sectionData.tools.length} {sectionData.tools.length}
</span> </span>
</h3> </h3>
@ -65,14 +63,14 @@ domains.forEach((domain: any) => {
tool.projectUrl !== "" && tool.projectUrl !== "" &&
tool.projectUrl.trim() !== ""; tool.projectUrl.trim() !== "";
return ( return (
<div class={`collaboration-tool-compact ${hasValidProjectUrl ? 'hosted' : tool.license !== 'Proprietary' ? 'oss' : ''}`} <div class={`collaboration-tool-compact cursor-pointer ${hasValidProjectUrl ? 'hosted' : tool.license !== 'Proprietary' ? 'oss' : ''}`}
onclick={`window.showToolDetails('${tool.name}')`}> onclick={`window.showToolDetails('${tool.name}')`}>
<div class="tool-compact-header"> <div class="tool-compact-header">
<h4 style="margin: 0; font-size: 0.875rem; font-weight: 600;"> <h4 class="m-0 text-sm font-semibold">
{tool.icon && <span style="margin-right: 0.5rem;">{tool.icon}</span>} {tool.icon && <span class="mr-2">{tool.icon}</span>}
{tool.name} {tool.name}
</h4> </h4>
<div style="display: flex; gap: 0.25rem;"> <div class="flex gap-1">
{hasValidProjectUrl && <span class="badge badge--mini badge-primary">CC24-Server</span>} {hasValidProjectUrl && <span class="badge badge--mini badge-primary">CC24-Server</span>}
{tool.knowledgebase === true && <span class="badge badge--mini badge-error">📖</span>} {tool.knowledgebase === true && <span class="badge badge--mini badge-error">📖</span>}
</div> </div>
@ -80,7 +78,7 @@ domains.forEach((domain: any) => {
<p class="text-muted"> <p class="text-muted">
{tool.description} {tool.description}
</p> </p>
<div style="display: flex; gap: 0.75rem; font-size: 0.6875rem; color: var(--color-text-secondary);"> <div class="flex gap-3 text-xs" style="color: var(--color-text-secondary);">
<span>{tool.platforms.join(', ')}</span> <span>{tool.platforms.join(', ')}</span>
<span>•</span> <span>•</span>
<span>{tool.skillLevel}</span> <span>{tool.skillLevel}</span>
@ -95,7 +93,7 @@ domains.forEach((domain: any) => {
<!-- DFIR Tools Matrix --> <!-- DFIR Tools Matrix -->
<div id="dfir-matrix-section"> <div id="dfir-matrix-section">
<h2 style="margin-bottom: 1rem; color: var(--color-text);">MATRIX</h2> <h2 class="mb-4" style="color: var(--color-text);">MATRIX</h2>
<table class="matrix-table"> <table class="matrix-table">
<thead> <thead>
<tr> <tr>
@ -128,7 +126,7 @@ domains.forEach((domain: any) => {
title={`${tool.name}${tool.knowledgebase === true ? ' (KB verfügbar)' : ''}`} title={`${tool.name}${tool.knowledgebase === true ? ' (KB verfügbar)' : ''}`}
> >
{tool.name} {tool.name}
{tool.knowledgebase === true && <span style="margin-left: 0.25rem; font-size: 0.6875rem;">📖</span>} {tool.knowledgebase === true && <span class="text-xs" style="margin-left: 0.25rem;">📖</span>}
</span> </span>
); );
})} })}
@ -146,12 +144,15 @@ domains.forEach((domain: any) => {
<!-- Primary Modal --> <!-- Primary Modal -->
<div class="tool-details" id="tool-details-primary"> <div class="tool-details" id="tool-details-primary">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 1rem;"> <div class="flex justify-between items-start mb-4">
<h2 id="tool-name-primary" style="margin: 0;">Tool Name</h2> <h2 id="tool-name-primary" class="m-0">Tool Name</h2>
<div style="display: flex; align-items: center; gap: 0.5rem;"> <div class="flex items-center gap-2">
<div id="share-button-primary" style="display: none;"> <div id="share-button-primary" style="display: none;">
<!-- Share button will be populated by JavaScript --> <!-- Share button will be populated by JavaScript -->
</div> </div>
<div id="contribute-button-primary" style="display: none;">
<!-- Contribution button will be populated by JavaScript -->
</div>
<button class="btn-icon" onclick="window.hideToolDetails('primary')"> <button class="btn-icon" onclick="window.hideToolDetails('primary')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line> <line x1="18" y1="6" x2="6" y2="18"></line>
@ -163,23 +164,26 @@ domains.forEach((domain: any) => {
<p id="tool-description-primary" class="text-muted"></p> <p id="tool-description-primary" class="text-muted"></p>
<div id="tool-badges-primary" style="display: flex; gap: 0.5rem; margin-bottom: 1rem;"></div> <div id="tool-badges-primary" class="flex gap-2 mb-4"></div>
<div id="tool-metadata-primary" style="margin-bottom: 1rem;"></div> <div id="tool-metadata-primary" class="mb-4"></div>
<div id="tool-tags-primary" style="margin-bottom: 1rem;"></div> <div id="tool-tags-primary" class="mb-4"></div>
<div id="tool-links-primary" style="display: flex; gap: 0.5rem; flex-direction: column;"></div> <div id="tool-links-primary" class="flex flex-col gap-2"></div>
</div> </div>
<!-- Secondary Modal --> <!-- Secondary Modal -->
<div class="tool-details" id="tool-details-secondary"> <div class="tool-details" id="tool-details-secondary">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 1rem;"> <div class="flex justify-between items-start mb-4">
<h2 id="tool-name-secondary" style="margin: 0;">Tool Name</h2> <h2 id="tool-name-secondary" class="m-0">Tool Name</h2>
<div style="display: flex; align-items: center; gap: 0.5rem;"> <div class="flex items-center gap-2">
<div id="share-button-secondary" style="display: none;"> <div id="share-button-secondary" style="display: none;">
<!-- Share button will be populated by JavaScript --> <!-- Share button will be populated by JavaScript -->
</div> </div>
<div id="contribute-button-secondary" style="display: none;">
<!-- Contribution button will be populated by JavaScript -->
</div>
<button class="btn-icon" onclick="window.hideToolDetails('secondary')"> <button class="btn-icon" onclick="window.hideToolDetails('secondary')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line> <line x1="18" y1="6" x2="6" y2="18"></line>
@ -191,13 +195,13 @@ domains.forEach((domain: any) => {
<p id="tool-description-secondary" class="text-muted"></p> <p id="tool-description-secondary" class="text-muted"></p>
<div id="tool-badges-secondary" style="display: flex; gap: 0.5rem; margin-bottom: 1rem;"></div> <div id="tool-badges-secondary" class="flex gap-2 mb-4"></div>
<div id="tool-metadata-secondary" style="margin-bottom: 1rem;"></div> <div id="tool-metadata-secondary" class="mb-4"></div>
<div id="tool-tags-secondary" style="margin-bottom: 1rem;"></div> <div id="tool-tags-secondary" class="mb-4"></div>
<div id="tool-links-secondary" style="display: flex; gap: 0.5rem; flex-direction: column;"></div> <div id="tool-links-secondary" class="flex flex-col gap-2"></div>
</div> </div>
<script define:vars={{ toolsData: tools, domainAgnosticSoftware, domainAgnosticTools }}> <script define:vars={{ toolsData: tools, domainAgnosticSoftware, domainAgnosticTools }}>
@ -278,28 +282,9 @@ domains.forEach((domain: any) => {
} }
} }
// ===== SHARING FUNCTIONALITY =====
// Create tool slug from name (same logic as ShareButton.astro)
function createToolSlug(toolName) {
return toolName.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Remove duplicate hyphens
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
}
// Find tool by name or slug
function findTool(identifier) {
return toolsData.find(tool =>
tool.name === identifier ||
createToolSlug(tool.name) === identifier.toLowerCase()
);
}
// Generate share URLs // Generate share URLs
function generateShareURL(toolName, view, modal = null) { function generateShareURL(toolName, view, modal = null) {
const toolSlug = createToolSlug(toolName); const toolSlug = window.createToolSlug(toolName);
const baseUrl = window.location.origin + window.location.pathname; const baseUrl = window.location.origin + window.location.pathname;
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('tool', toolSlug); params.set('tool', toolSlug);
@ -353,10 +338,18 @@ domains.forEach((domain: any) => {
backdrop = document.createElement('div'); backdrop = document.createElement('div');
backdrop.id = 'share-modal-backdrop'; backdrop.id = 'share-modal-backdrop';
backdrop.style.cssText = ` backdrop.style.cssText = `
position: fixed; top: 0; left: 0; right: 0; bottom: 0; position: fixed;
background: rgba(0, 0, 0, 0.5); z-index: 9999; top: 0;
display: flex; align-items: center; justify-content: center; left: 0;
opacity: 0; transition: opacity 0.2s ease; right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
`; `;
document.body.appendChild(backdrop); document.body.appendChild(backdrop);
} }
@ -364,21 +357,27 @@ domains.forEach((domain: any) => {
// Create share dialog // Create share dialog
const dialog = document.createElement('div'); const dialog = document.createElement('div');
dialog.style.cssText = ` dialog.style.cssText = `
background: var(--color-bg); border: 1px solid var(--color-border); background-color: var(--color-bg);
border-radius: 0.75rem; padding: 1.5rem; max-width: 400px; width: 90%; border: 1px solid var(--color-border);
box-shadow: var(--shadow-lg); transform: scale(0.9); transition: transform 0.2s ease; border-radius: 0.5rem;
padding: 1.5rem;
max-width: 28rem;
width: 90%;
box-shadow: var(--shadow-lg);
transform: scale(0.9);
transition: transform 0.2s ease;
`; `;
dialog.innerHTML = ` dialog.innerHTML = `
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem;"> <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem;">
<h3 style="margin: 0; color: var(--color-primary);"> <h3 style="margin: 0; color: var(--color-primary); display: flex; align-items: center;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem; vertical-align: middle;"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/> <circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/> <line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
</svg> </svg>
${toolName} teilen ${toolName} teilen
</h3> </h3>
<button id="close-share-dialog" style="background: none; border: none; cursor: pointer; padding: 0.25rem;color: var(--color-text-secondary)"> <button id="close-share-dialog" class="btn-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/> <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg> </svg>
@ -386,44 +385,54 @@ domains.forEach((domain: any) => {
</div> </div>
<div style="display: flex; flex-direction: column; gap: 0.75rem;"> <div style="display: flex; flex-direction: column; gap: 0.75rem;">
<button class="share-option-btn" data-url="${generateShareURL(toolName, 'grid')}" <button class="share-option-btn" data-url="${generateShareURL(toolName, 'grid')}" style="display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; border: 1px solid var(--color-border); border-radius: 0.5rem; background-color: var(--color-bg); cursor: pointer; transition: var(--transition-fast); text-align: left; width: 100%;" onmouseover="this.style.backgroundColor='var(--color-bg-secondary)'" onmouseout="this.style.backgroundColor='var(--color-bg)'">
style="display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; border: 1px solid var(--color-border); border-radius: 0.5rem; background: var(--color-bg); cursor: pointer; transition: var(--transition-fast); text-align: left; width: 100%;"> <div style="width: 2rem; height: 2rem; background-color: var(--color-primary); border-radius: 0.25rem; display: flex; align-items: center; justify-content: center;">
<div style="width: 32px; height: 32px; background: var(--color-primary); border-radius: 0.25rem; display: flex; align-items: center; justify-content: center;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/> <rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/> <rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
</svg> </svg>
</div> </div>
<div> <div>
<div style="font-weight: 500; margin-bottom: 0.25rem;color: var(--color-text-secondary)">Kachelansicht</div> <div style="font-weight: 500; margin-bottom: 0.25rem; color: var(--color-text);">Kachelansicht</div>
<div style="font-size: 0.8125rem; color: var(--color-text-secondary);">Scrollt zur Karte in der Übersicht</div> <div style="font-size: 0.875rem; color: var(--color-text-secondary);">Scrollt zur Karte in der Übersicht</div>
</div> </div>
</button> </button>
<button class="share-option-btn" data-url="${generateShareURL(toolName, 'matrix')}" <button class="share-option-btn" data-url="${generateShareURL(toolName, 'matrix')}" style="display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; border: 1px solid var(--color-border); border-radius: 0.5rem; background-color: var(--color-bg); cursor: pointer; transition: var(--transition-fast); text-align: left; width: 100%;" onmouseover="this.style.backgroundColor='var(--color-bg-secondary)'" onmouseout="this.style.backgroundColor='var(--color-bg)'">
style="display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; border: 1px solid var(--color-border); border-radius: 0.5rem; background: var(--color-bg); cursor: pointer; transition: var(--transition-fast); text-align: left; width: 100%;"> <div style="width: 2rem; height: 2rem; background-color: var(--color-accent); border-radius: 0.25rem; display: flex; align-items: center; justify-content: center;">
<div style="width: 32px; height: 32px; background: var(--color-accent); border-radius: 0.25rem; display: flex; align-items: center; justify-content: center;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z"/> <rect x="3" y="3" width="4" height="4"/>
<rect x="10" y="3" width="4" height="4"/>
<rect x="17" y="3" width="4" height="4"/>
<rect x="3" y="10" width="4" height="4"/>
<rect x="10" y="10" width="4" height="4"/>
<rect x="17" y="10" width="4" height="4"/>
<rect x="3" y="17" width="4" height="4"/>
<rect x="10" y="17" width="4" height="4"/>
<rect x="17" y="17" width="4" height="4"/>
</svg> </svg>
</div> </div>
<div> <div>
<div style="font-weight: 500; margin-bottom: 0.25rem;color: var(--color-text-secondary)">Matrix-Ansicht</div> <div style="font-weight: 500; margin-bottom: 0.25rem; color: var(--color-text);">Matrix-Ansicht</div>
<div style="font-size: 0.8125rem; color: var(--color-text-secondary);">Zeigt Tool-Position in der Matrix</div> <div style="font-size: 0.875rem; color: var(--color-text-secondary);">Zeigt Tool-Position in der Matrix</div>
</div> </div>
</button> </button>
<button class="share-option-btn" data-url="${generateShareURL(toolName, 'modal', 'primary')}" <button class="share-option-btn" data-url="${generateShareURL(toolName, 'modal', 'primary')}" style="display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; border: 1px solid var(--color-border); border-radius: 0.5rem; background-color: var(--color-bg); cursor: pointer; transition: var(--transition-fast); text-align: left; width: 100%;" onmouseover="this.style.backgroundColor='var(--color-bg-secondary)'" onmouseout="this.style.backgroundColor='var(--color-bg)'">
style="display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; border: 1px solid var(--color-border); border-radius: 0.5rem; background: var(--color-bg); cursor: pointer; transition: var(--transition-fast); text-align: left; width: 100%;"> <div style="width: 2rem; height: 2rem; background-color: var(--color-warning); border-radius: 0.25rem; display: flex; align-items: center; justify-content: center;">
<div style="width: 32px; height: 32px; background: var(--color-warning); border-radius: 0.25rem; display: flex; align-items: center; justify-content: center;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/> <polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg> </svg>
</div> </div>
<div> <div>
<div style="font-weight: 500; margin-bottom: 0.25rem;color: var(--color-text-secondary)">Tool-Details</div> <div style="font-weight: 500; margin-bottom: 0.25rem; color: var(--color-text);">Tool-Details</div>
<div style="font-size: 0.8125rem; color: var(--color-text-secondary);">Öffnet Detail-Fenster direkt</div> <div style="font-size: 0.875rem; color: var(--color-text-secondary);">Öffnet Detail-Fenster direkt</div>
</div> </div>
</button> </button>
</div> </div>
@ -456,16 +465,6 @@ domains.forEach((domain: any) => {
// Share option handlers // Share option handlers
dialog.querySelectorAll('.share-option-btn').forEach(btn => { dialog.querySelectorAll('.share-option-btn').forEach(btn => {
btn.addEventListener('mouseover', () => {
btn.style.backgroundColor = 'var(--color-bg-secondary)';
btn.style.borderColor = 'var(--color-primary)';
});
btn.addEventListener('mouseout', () => {
btn.style.backgroundColor = 'var(--color-bg)';
btn.style.borderColor = 'var(--color-border)';
});
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const url = btn.getAttribute('data-url'); const url = btn.getAttribute('data-url');
copyToClipboard(url, btn); copyToClipboard(url, btn);
@ -506,15 +505,12 @@ domains.forEach((domain: any) => {
} }
// Update modal content // Update modal content
const iconHtml = tool.icon ? `<span style="margin-right: 0.75rem; font-size: 1.5rem;">${tool.icon}</span>` : ''; const iconHtml = tool.icon ? `<span class="mr-3 text-xl">${tool.icon}</span>` : '';
elements.name.innerHTML = `${iconHtml}${tool.name}`; elements.name.innerHTML = `${iconHtml}${tool.name}`;
elements.description.textContent = tool.description; elements.description.textContent = tool.description;
// Badges // Badges
const hasValidProjectUrl = tool.projectUrl !== undefined && const hasValidProjectUrl = window.isToolHosted(tool);
tool.projectUrl !== null &&
tool.projectUrl !== "" &&
tool.projectUrl.trim() !== "";
elements.badges.innerHTML = ''; elements.badges.innerHTML = '';
if (isConcept) { if (isConcept) {
@ -535,7 +531,7 @@ domains.forEach((domain: any) => {
const domainsText = domains.length > 0 ? domains.join(', ') : 'Domain-agnostic'; const domainsText = domains.length > 0 ? domains.join(', ') : 'Domain-agnostic';
const phasesText = phases.join(', '); const phasesText = phases.join(', ');
let metadataHTML = `<div style="display: grid; gap: 0.5rem;">`; let metadataHTML = `<div class="grid gap-2">`;
if (!isConcept) { if (!isConcept) {
metadataHTML += ` metadataHTML += `
@ -558,7 +554,7 @@ domains.forEach((domain: any) => {
// Tags and Related Concepts // Tags and Related Concepts
const tags = tool.tags || []; const tags = tool.tags || [];
let tagsHTML = ` let tagsHTML = `
<div style="display: flex; flex-wrap: wrap; gap: 0.25rem;"> <div class="flex flex-wrap gap-1">
${tags.map(tag => `<span class="tag">${tag}</span>`).join('')} ${tags.map(tag => `<span class="tag">${tag}</span>`).join('')}
</div> </div>
`; `;
@ -569,14 +565,14 @@ domains.forEach((domain: any) => {
const conceptLinks = relatedConcepts.map(conceptName => { const conceptLinks = relatedConcepts.map(conceptName => {
const concept = toolsData.find(t => t.name === conceptName && t.type === 'concept'); const concept = toolsData.find(t => t.name === conceptName && t.type === 'concept');
if (concept) { if (concept) {
return `<button class="tag" style="cursor: pointer; background-color: var(--color-concept-bg); border: 1px solid var(--color-concept); color: var(--color-concept); transition: var(--transition-fast); margin: 0.125rem;" return `<button class="tag cursor-pointer" style="background-color: var(--color-concept-bg); border: 1px solid var(--color-concept); color: var(--color-concept); transition: var(--transition-fast);"
onclick="event.stopPropagation(); window.showToolDetails('${conceptName}', 'secondary')" onclick="event.stopPropagation(); window.showToolDetails('${conceptName}', 'secondary')"
onmouseover="this.style.backgroundColor='var(--color-concept)'; this.style.color='white';" onmouseover="this.style.backgroundColor='var(--color-concept)'; this.style.color='white';"
onmouseout="this.style.backgroundColor='var(--color-concept-bg)'; this.style.color='var(--color-concept)';"> onmouseout="this.style.backgroundColor='var(--color-concept-bg)'; this.style.color='var(--color-concept)';">
${conceptName} ${conceptName}
</button>`; </button>`;
} }
return `<span class="tag" style="background-color: var(--color-bg-tertiary); color: var(--color-text-secondary); margin: 0.125rem;">${conceptName}</span>`; return `<span class="tag" style="background-color: var(--color-bg-tertiary); color: var(--color-text-secondary);">${conceptName}</span>`;
}).join(''); }).join('');
// Check if mobile device // Check if mobile device
@ -584,18 +580,18 @@ domains.forEach((domain: any) => {
const collapseOnMobile = isMobile && relatedConcepts.length > 2; const collapseOnMobile = isMobile && relatedConcepts.length > 2;
tagsHTML += ` tagsHTML += `
<div style="margin-top: 1rem;"> <div class="mt-4">
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;"> <div class="flex items-center gap-2 mb-2">
<strong style="color: var(--color-text);">Verwandte Konzepte:</strong> <strong style="color: var(--color-text);">Verwandte Konzepte:</strong>
${collapseOnMobile ? ` ${collapseOnMobile ? `
<button id="concepts-toggle-${modalType}" <button id="concepts-toggle-${modalType}"
onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? 'block' : 'none'; this.textContent = this.textContent === '▼' ? '▲' : '▼';" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? 'block' : 'none'; this.textContent = this.textContent === '▼' ? '▲' : '▼';"
style="background: none; border: 1px solid var(--color-border); border-radius: 0.25rem; padding: 0.25rem 0.5rem; cursor: pointer; font-size: 0.75rem;"> class="btn-icon text-xs">
</button> </button>
` : ''} ` : ''}
</div> </div>
<div ${collapseOnMobile ? 'style="display: none;"' : ''} style="display: flex; flex-wrap: wrap; gap: 0.25rem;"> <div ${collapseOnMobile ? 'style="display: none;"' : ''} class="flex flex-wrap gap-1">
${conceptLinks} ${conceptLinks}
</div> </div>
</div> </div>
@ -609,40 +605,40 @@ domains.forEach((domain: any) => {
if (isConcept) { if (isConcept) {
linksHTML += ` linksHTML += `
<a href="${tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="width: 100%; background-color: var(--color-concept); border-color: var(--color-concept);"> <a href="${tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-primary w-full" style="background-color: var(--color-concept); border-color: var(--color-concept);">
Mehr erfahren Mehr erfahren
</a> </a>
`; `;
} else if (isMethod) { } else if (isMethod) {
linksHTML += ` linksHTML += `
<a href="${tool.projectUrl || tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="width: 100%; background-color: var(--color-method); border-color: var(--color-method);"> <a href="${tool.projectUrl || tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-primary w-full" style="background-color: var(--color-method); border-color: var(--color-method);">
Zur Methode Zur Methode
</a> </a>
`; `;
} else if (hasValidProjectUrl) { } else if (hasValidProjectUrl) {
linksHTML += ` linksHTML += `
<div style="display: flex; gap: 0.5rem;"> <div class="flex gap-2">
<a href="${tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-secondary" style="flex: 1;"> <a href="${tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-secondary flex-1">
Homepage Homepage
</a> </a>
<a href="${tool.projectUrl}" target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="flex: 1;"> <a href="${tool.projectUrl}" target="_blank" rel="noopener noreferrer" class="btn btn-primary flex-1">
Zugreifen Zugreifen
</a> </a>
</div> </div>
`; `;
} else { } else {
linksHTML += ` linksHTML += `
<a href="${tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="width: 100%;"> <a href="${tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-primary w-full">
Software-Homepage Software-Homepage
</a> </a>
`; `;
} }
if (tool.knowledgebase === true) { if (tool.knowledgebase === true) {
const kbId = tool.name.toLowerCase().replace(/\s+/g, '-'); const kbId = window.createToolSlug(tool.name);
linksHTML += ` linksHTML += `
<a href="/knowledgebase#kb-${kbId}" class="btn btn-secondary" style="width: 100%; margin-top: 0.5rem;"> <a href="/knowledgebase#kb-${kbId}" class="btn btn-secondary w-full mt-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="mr-2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/> <polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/> <line x1="16" y1="13" x2="8" y2="13"/>
@ -659,7 +655,7 @@ domains.forEach((domain: any) => {
// ===== POPULATE SHARE BUTTON ===== // ===== POPULATE SHARE BUTTON =====
const shareButtonContainer = document.getElementById(`share-button-${modalType}`); const shareButtonContainer = document.getElementById(`share-button-${modalType}`);
if (shareButtonContainer) { if (shareButtonContainer) {
const toolSlug = createToolSlug(tool.name); const toolSlug = window.createToolSlug(tool.name);
shareButtonContainer.innerHTML = ` shareButtonContainer.innerHTML = `
<button class="share-btn share-btn--medium" <button class="share-btn share-btn--medium"
data-tool-name="${tool.name}" data-tool-name="${tool.name}"
@ -680,6 +676,26 @@ domains.forEach((domain: any) => {
shareButtonContainer.style.display = 'block'; shareButtonContainer.style.display = 'block';
} }
// ===== POPULATE CONTRIBUTION BUTTON =====
const contributeButtonContainer = document.getElementById(`contribute-button-${modalType}`);
if (contributeButtonContainer) {
contributeButtonContainer.innerHTML = `
<a href="/contribute/tool?edit=${encodeURIComponent(tool.name)}"
class="btn-icon"
data-contribute-button="edit"
data-tool-name="${tool.name}"
title="Edit ${tool.name}"
aria-label="Edit ${tool.name}"
onclick="event.stopPropagation();">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</a>
`;
contributeButtonContainer.style.display = 'block';
}
// Show modals and update layout // Show modals and update layout
const overlay = document.getElementById('modal-overlay'); const overlay = document.getElementById('modal-overlay');
const primaryModal = document.getElementById('tool-details-primary'); const primaryModal = document.getElementById('tool-details-primary');
@ -703,29 +719,34 @@ domains.forEach((domain: any) => {
const primaryModal = document.getElementById('tool-details-primary'); const primaryModal = document.getElementById('tool-details-primary');
const secondaryModal = document.getElementById('tool-details-secondary'); const secondaryModal = document.getElementById('tool-details-secondary');
if (modalType === 'both' || modalType === 'all') { if (modalType === 'both' || modalType === 'all') {
if (primaryModal) { if (primaryModal) {
primaryModal.classList.remove('active'); primaryModal.classList.remove('active');
// Hide share button
const shareButtonPrimary = document.getElementById('share-button-primary'); const shareButtonPrimary = document.getElementById('share-button-primary');
const contributeButtonPrimary = document.getElementById('contribute-button-primary');
if (shareButtonPrimary) shareButtonPrimary.style.display = 'none'; if (shareButtonPrimary) shareButtonPrimary.style.display = 'none';
if (contributeButtonPrimary) contributeButtonPrimary.style.display = 'none';
} }
if (secondaryModal) { if (secondaryModal) {
secondaryModal.classList.remove('active'); secondaryModal.classList.remove('active');
// Hide share button
const shareButtonSecondary = document.getElementById('share-button-secondary'); const shareButtonSecondary = document.getElementById('share-button-secondary');
const contributeButtonSecondary = document.getElementById('contribute-button-secondary');
if (shareButtonSecondary) shareButtonSecondary.style.display = 'none'; if (shareButtonSecondary) shareButtonSecondary.style.display = 'none';
if (contributeButtonSecondary) contributeButtonSecondary.style.display = 'none';
} }
if (overlay) overlay.classList.remove('active');
document.body.classList.remove('modals-side-by-side');
} else if (modalType === 'primary' && primaryModal) { } else if (modalType === 'primary' && primaryModal) {
primaryModal.classList.remove('active'); primaryModal.classList.remove('active');
const shareButtonPrimary = document.getElementById('share-button-primary'); const shareButtonPrimary = document.getElementById('share-button-primary');
const contributeButtonPrimary = document.getElementById('contribute-button-primary');
if (shareButtonPrimary) shareButtonPrimary.style.display = 'none'; if (shareButtonPrimary) shareButtonPrimary.style.display = 'none';
if (contributeButtonPrimary) contributeButtonPrimary.style.display = 'none';
} else if (modalType === 'secondary' && secondaryModal) { } else if (modalType === 'secondary' && secondaryModal) {
secondaryModal.classList.remove('active'); secondaryModal.classList.remove('active');
const shareButtonSecondary = document.getElementById('share-button-secondary'); const shareButtonSecondary = document.getElementById('share-button-secondary');
const contributeButtonSecondary = document.getElementById('contribute-button-secondary');
if (shareButtonSecondary) shareButtonSecondary.style.display = 'none'; if (shareButtonSecondary) shareButtonSecondary.style.display = 'none';
if (contributeButtonSecondary) contributeButtonSecondary.style.display = 'none';
} }
// Check if any modal is still active // Check if any modal is still active
@ -790,10 +811,7 @@ domains.forEach((domain: any) => {
} }
const isMethod = tool.type === 'method'; const isMethod = tool.type === 'method';
const hasValidProjectUrl = tool.projectUrl !== undefined && const hasValidProjectUrl = window.isToolHosted(tool);
tool.projectUrl !== null &&
tool.projectUrl !== "" &&
tool.projectUrl.trim() !== "";
const domains = tool.domains || []; const domains = tool.domains || [];
const phases = tool.phases || []; const phases = tool.phases || [];

View File

@ -4,26 +4,18 @@ const knowledgebaseCollection = defineCollection({
type: 'content', type: 'content',
schema: z.object({ schema: z.object({
title: z.string(), title: z.string(),
tool_name: z.string(),
description: z.string(), description: z.string(),
last_updated: z.date(), last_updated: z.date(),
author: z.string().default('CC24-Team'),
difficulty: z.enum(['novice', 'beginner', 'intermediate', 'advanced', 'expert']), tool_name: z.string().optional(),
related_tools: z.array(z.string()).default([]),
author: z.string().default('Anon'),
difficulty: z.enum(['novice', 'beginner', 'intermediate', 'advanced', 'expert']).optional(),
categories: z.array(z.string()).default([]), categories: z.array(z.string()).default([]),
tags: z.array(z.string()).default([]), tags: z.array(z.string()).default([]),
sections: z.object({
overview: z.boolean().default(true), published: z.boolean().default(true),
installation: z.boolean().default(false),
configuration: z.boolean().default(false),
usage_examples: z.boolean().default(true),
best_practices: z.boolean().default(true),
troubleshooting: z.boolean().default(false),
advanced_topics: z.boolean().default(false),
}).default({}),
review_status: z.enum(['draft', 'review', 'published']).default('published'),
}) })
}); });
export const collections = {
knowledgebase: knowledgebaseCollection,
};

View File

@ -3,7 +3,7 @@ title: "Kali Linux - Die Hacker-Distribution für Forensik & Penetration Testing
tool_name: "Kali Linux" tool_name: "Kali Linux"
description: "Leitfaden zur Installation, Nutzung und Best Practices für Kali Linux die All-in-One-Plattform für Security-Profis." description: "Leitfaden zur Installation, Nutzung und Best Practices für Kali Linux die All-in-One-Plattform für Security-Profis."
last_updated: 2025-07-20 last_updated: 2025-07-20
author: "CC24-Team" author: "Claude 4 Sonnet"
difficulty: "intermediate" difficulty: "intermediate"
categories: ["incident-response", "forensics", "penetration-testing"] categories: ["incident-response", "forensics", "penetration-testing"]
tags: ["live-boot", "tool-collection", "penetration-testing", "forensics-suite", "virtualization", "arm-support"] tags: ["live-boot", "tool-collection", "penetration-testing", "forensics-suite", "virtualization", "arm-support"]

View File

@ -3,9 +3,9 @@ title: "MISP - Plattform für Threat Intelligence Sharing"
tool_name: "MISP" tool_name: "MISP"
description: "Das Rückgrat des modernen Threat-Intelligence-Sharings mit über 40.000 aktiven Instanzen weltweit." description: "Das Rückgrat des modernen Threat-Intelligence-Sharings mit über 40.000 aktiven Instanzen weltweit."
last_updated: 2025-07-20 last_updated: 2025-07-20
author: "CC24-Team" author: "Claude 4 Sonnet"
difficulty: "intermediate" difficulty: "intermediate"
categories: ["incident-response", "law-enforcement", "malware-analysis", "network-forensics", "cloud-forensics"] categories: ["incident-response", "static-investigations", "malware-analysis", "network-forensics", "cloud-forensics"]
tags: ["web-based", "threat-intelligence", "api", "correlation", "ioc-sharing", "automation"] tags: ["web-based", "threat-intelligence", "api", "correlation", "ioc-sharing", "automation"]
sections: sections:
overview: true overview: true

View File

@ -3,7 +3,7 @@ title: "Nextcloud - Sichere Kollaborationsplattform"
tool_name: "Nextcloud" tool_name: "Nextcloud"
description: "Detaillierte Anleitung und Best Practices für Nextcloud in forensischen Einsatzszenarien" description: "Detaillierte Anleitung und Best Practices für Nextcloud in forensischen Einsatzszenarien"
last_updated: 2025-07-20 last_updated: 2025-07-20
author: "CC24-Team" author: "Claude 4 Sonnet"
difficulty: "novice" difficulty: "novice"
categories: ["collaboration-general"] categories: ["collaboration-general"]
tags: ["web-based", "collaboration", "file-sharing", "api", "encryption", "document-management"] tags: ["web-based", "collaboration", "file-sharing", "api", "encryption", "document-management"]

View File

@ -3,7 +3,7 @@ title: "Regular Expressions (Regex) Musterbasierte Textanalyse"
tool_name: "Regular Expressions (Regex)" tool_name: "Regular Expressions (Regex)"
description: "Pattern matching language für Suche, Extraktion und Manipulation von Text in forensischen Analysen." description: "Pattern matching language für Suche, Extraktion und Manipulation von Text in forensischen Analysen."
last_updated: 2025-07-20 last_updated: 2025-07-20
author: "CC24-Team" author: "Claude 4 Sonnet"
difficulty: "intermediate" difficulty: "intermediate"
categories: ["incident-response", "malware-analysis", "network-forensics", "fraud-investigation"] categories: ["incident-response", "malware-analysis", "network-forensics", "fraud-investigation"]
tags: ["pattern-matching", "text-processing", "log-analysis", "string-manipulation", "search-algorithms"] tags: ["pattern-matching", "text-processing", "log-analysis", "string-manipulation", "search-algorithms"]

View File

@ -3,7 +3,7 @@ title: "Velociraptor Skalierbare Endpoint-Forensik mit VQL"
tool_name: "Velociraptor" tool_name: "Velociraptor"
description: "Detaillierte Anleitung und Best Practices für Velociraptor Remote-Forensik der nächsten Generation" description: "Detaillierte Anleitung und Best Practices für Velociraptor Remote-Forensik der nächsten Generation"
last_updated: 2025-07-20 last_updated: 2025-07-20
author: "CC24-Team" author: "Claude 4 Sonnet"
difficulty: "advanced" difficulty: "advanced"
categories: ["incident-response", "malware-analysis", "network-forensics"] categories: ["incident-response", "malware-analysis", "network-forensics"]
tags: ["web-based", "endpoint-monitoring", "artifact-extraction", "scripting", "live-forensics", "hunting"] tags: ["web-based", "endpoint-monitoring", "artifact-extraction", "scripting", "live-forensics", "hunting"]

View File

@ -20,7 +20,7 @@ tools:
icon: 📦 icon: 📦
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- malware-analysis - malware-analysis
- mobile-forensics - mobile-forensics
- cloud-forensics - cloud-forensics
@ -49,7 +49,7 @@ tools:
Formatunterstützung. Formatunterstützung.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- malware-analysis - malware-analysis
- network-forensics - network-forensics
phases: phases:
@ -88,7 +88,7 @@ tools:
Kollaborations-Lösungen am Markt. Kollaborations-Lösungen am Markt.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- malware-analysis - malware-analysis
- network-forensics - network-forensics
phases: phases:
@ -125,7 +125,7 @@ tools:
SIEMs, Firewalls und andere Sicherheitssysteme. SIEMs, Firewalls und andere Sicherheitssysteme.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- malware-analysis - malware-analysis
- network-forensics - network-forensics
- cloud-forensics - cloud-forensics
@ -159,7 +159,7 @@ tools:
mehreren Analysten und Millionen von Zeitstempeln. mehreren Analysten und Millionen von Zeitstempeln.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- network-forensics - network-forensics
- cloud-forensics - cloud-forensics
phases: phases:
@ -235,7 +235,7 @@ tools:
für Behörden und Großunternehmen interessant. für Behörden und Großunternehmen interessant.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- mobile-forensics - mobile-forensics
- cloud-forensics - cloud-forensics
phases: phases:
@ -271,7 +271,7 @@ tools:
Visualisierung verständlich. Mit Preisen im sechsstelligen Bereich und Visualisierung verständlich. Mit Preisen im sechsstelligen Bereich und
ethischen Bedenken bezüglich der Käuferauswahl nicht unumstritten. ethischen Bedenken bezüglich der Käuferauswahl nicht unumstritten.
domains: domains:
- law-enforcement - static-investigations
- mobile-forensics - mobile-forensics
phases: phases:
- data-collection - data-collection
@ -374,7 +374,7 @@ tools:
unübertroffen. unübertroffen.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- network-forensics - network-forensics
- cloud-forensics - cloud-forensics
phases: phases:
@ -410,7 +410,7 @@ tools:
für CTF-Challenges und tägliche Forensik-Aufgaben gleichermaßen. für CTF-Challenges und tägliche Forensik-Aufgaben gleichermaßen.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- malware-analysis - malware-analysis
- network-forensics - network-forensics
phases: phases:
@ -445,7 +445,7 @@ tools:
Effizienzgewinne bei großen Infrastrukturen sind enorm. Effizienzgewinne bei großen Infrastrukturen sind enorm.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- malware-analysis - malware-analysis
- fraud-investigation - fraud-investigation
- network-forensics - network-forensics
@ -487,7 +487,7 @@ tools:
Untersuchungen. Untersuchungen.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- malware-analysis - malware-analysis
- fraud-investigation - fraud-investigation
phases: phases:
@ -525,7 +525,7 @@ tools:
Monitoring Operations. Monitoring Operations.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- network-forensics - network-forensics
- cloud-forensics - cloud-forensics
phases: phases:
@ -560,7 +560,7 @@ tools:
schnelle Übersichten, an Grenzen bei verschlüsseltem Traffic. schnelle Übersichten, an Grenzen bei verschlüsseltem Traffic.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- malware-analysis - malware-analysis
- network-forensics - network-forensics
phases: phases:
@ -596,7 +596,7 @@ tools:
Dokumenten-Untersuchungen. Dokumenten-Untersuchungen.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- fraud-investigation - fraud-investigation
- mobile-forensics - mobile-forensics
phases: phases:
@ -633,7 +633,7 @@ tools:
ersten Wahl für Behörden. Lizenzkosten im sechsstelligen Bereich ersten Wahl für Behörden. Lizenzkosten im sechsstelligen Bereich
limitieren den Zugang auf Großorganisationen. limitieren den Zugang auf Großorganisationen.
domains: domains:
- law-enforcement - static-investigations
- fraud-investigation - fraud-investigation
phases: phases:
- analysis - analysis
@ -666,7 +666,7 @@ tools:
Organisations-Strukturen. Die Community Edition limitiert auf einen Organisations-Strukturen. Die Community Edition limitiert auf einen
Benutzer - für Teams ist die kommerzielle Version nötig. Benutzer - für Teams ist die kommerzielle Version nötig.
domains: domains:
- law-enforcement - static-investigations
- malware-analysis - malware-analysis
- fraud-investigation - fraud-investigation
- network-forensics - network-forensics
@ -706,7 +706,7 @@ tools:
ermöglicht automatisierte Analysen großer Datensätze. Unverzichtbar wenn ermöglicht automatisierte Analysen großer Datensätze. Unverzichtbar wenn
Fahrzeuge, Drohnen oder mobile Geräte mit Standortdaten involviert sind. Fahrzeuge, Drohnen oder mobile Geräte mit Standortdaten involviert sind.
domains: domains:
- law-enforcement - static-investigations
- fraud-investigation - fraud-investigation
- mobile-forensics - mobile-forensics
phases: phases:
@ -742,7 +742,7 @@ tools:
vom Raspberry Pi für kleine Teams bis zur High-Availability-Installation. vom Raspberry Pi für kleine Teams bis zur High-Availability-Installation.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- malware-analysis - malware-analysis
- fraud-investigation - fraud-investigation
- network-forensics - network-forensics
@ -847,7 +847,7 @@ tools:
überall einsetzbar. überall einsetzbar.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- malware-analysis - malware-analysis
- fraud-investigation - fraud-investigation
- network-forensics - network-forensics
@ -906,7 +906,7 @@ tools:
bedacht werden. bedacht werden.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- malware-analysis - malware-analysis
- fraud-investigation - fraud-investigation
- network-forensics - network-forensics
@ -948,7 +948,7 @@ tools:
für kleine Teams. Ideal für Organisationen, die Blockchain-Analysen ohne für kleine Teams. Ideal für Organisationen, die Blockchain-Analysen ohne
US-Cloud-Abhängigkeit benötigen. US-Cloud-Abhängigkeit benötigen.
domains: domains:
- law-enforcement - static-investigations
- fraud-investigation - fraud-investigation
phases: phases:
- analysis - analysis
@ -981,7 +981,7 @@ tools:
angestaubt in der Oberfläche, aber bewährt in tausenden Gerichtsverfahren. angestaubt in der Oberfläche, aber bewährt in tausenden Gerichtsverfahren.
Freeware, aber nicht open source. Freeware, aber nicht open source.
domains: domains:
- law-enforcement - static-investigations
- incident-response - incident-response
phases: phases:
- data-collection - data-collection
@ -1014,7 +1014,7 @@ tools:
solide Technik unter der Haube hinweg. solide Technik unter der Haube hinweg.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
phases: phases:
- data-collection - data-collection
platforms: platforms:
@ -1046,7 +1046,7 @@ tools:
Updates bei neuen macOS-Versionen. Updates bei neuen macOS-Versionen.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
phases: phases:
- data-collection - data-collection
platforms: platforms:
@ -1077,7 +1077,7 @@ tools:
LEAPP-Familie, ständig aktualisiert für neue Android-Versionen. LEAPP-Familie, ständig aktualisiert für neue Android-Versionen.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- mobile-forensics - mobile-forensics
phases: phases:
- examination - examination
@ -1113,7 +1113,7 @@ tools:
iOS-Änderungen und neuen Artefakten. iOS-Änderungen und neuen Artefakten.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- mobile-forensics - mobile-forensics
phases: phases:
- examination - examination
@ -1147,7 +1147,7 @@ tools:
unverzichtbar bei Unfallrekonstruktionen und Kriminalfällen. Die unverzichtbar bei Unfallrekonstruktionen und Kriminalfällen. Die
Unterstützung für verschiedene Hersteller wächst mit der Community. Unterstützung für verschiedene Hersteller wächst mit der Community.
domains: domains:
- law-enforcement - static-investigations
- ics-forensics - ics-forensics
phases: phases:
- examination - examination
@ -1181,7 +1181,7 @@ tools:
Tool-Sammlung auf dem neuesten Stand. Tool-Sammlung auf dem neuesten Stand.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- malware-analysis - malware-analysis
- fraud-investigation - fraud-investigation
- network-forensics - network-forensics
@ -1217,7 +1217,7 @@ tools:
Zuverlässigkeit für Forensik-Puristen. Zuverlässigkeit für Forensik-Puristen.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
phases: phases:
- data-collection - data-collection
platforms: platforms:
@ -1249,7 +1249,7 @@ tools:
langer Imaging-Vorgänge rettet Nerven und Zeit. langer Imaging-Vorgänge rettet Nerven und Zeit.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
phases: phases:
- data-collection - data-collection
platforms: platforms:
@ -1281,7 +1281,7 @@ tools:
Austausch mit kommerziellen Tools. Austausch mit kommerziellen Tools.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
phases: phases:
- data-collection - data-collection
platforms: platforms:
@ -1313,7 +1313,7 @@ tools:
TestDisk repariert zusätzlich beschädigte Partitionen. TestDisk repariert zusätzlich beschädigte Partitionen.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- fraud-investigation - fraud-investigation
phases: phases:
- examination - examination
@ -1379,7 +1379,7 @@ tools:
Forensik-Suite. Freeware, aber nicht open source. Forensik-Suite. Freeware, aber nicht open source.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
phases: phases:
- examination - examination
platforms: platforms:
@ -1410,7 +1410,7 @@ tools:
einem System vorhanden waren. Die einfache GUI macht es auch für weniger einem System vorhanden waren. Die einfache GUI macht es auch für weniger
technische Ermittler zugänglich. technische Ermittler zugänglich.
domains: domains:
- law-enforcement - static-investigations
- fraud-investigation - fraud-investigation
phases: phases:
- examination - examination
@ -1443,7 +1443,7 @@ tools:
manueller Registry-Analyse und findet oft übersehene Artefakte. manueller Registry-Analyse und findet oft übersehene Artefakte.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- malware-analysis - malware-analysis
phases: phases:
- examination - examination
@ -1575,7 +1575,7 @@ tools:
Tool-Sammlung aktuell. Tool-Sammlung aktuell.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- malware-analysis - malware-analysis
- network-forensics - network-forensics
- mobile-forensics - mobile-forensics
@ -1609,7 +1609,7 @@ tools:
Live-System-Umgebung ermöglicht. Live-System-Umgebung ermöglicht.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- malware-analysis - malware-analysis
- mobile-forensics - mobile-forensics
skillLevel: intermediate skillLevel: intermediate
@ -1639,7 +1639,7 @@ tools:
Neuinstallation. Neuinstallation.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- malware-analysis - malware-analysis
- network-forensics - network-forensics
skillLevel: intermediate skillLevel: intermediate
@ -1670,7 +1670,7 @@ tools:
Ansicht. Ständige Updates für neue Windows-Versionen und Cloud-Artefakte. Ansicht. Ständige Updates für neue Windows-Versionen und Cloud-Artefakte.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
phases: phases:
- examination - examination
- analysis - analysis
@ -1770,7 +1770,7 @@ tools:
ab, Profis schwören darauf. Deutlich günstiger als US-Konkurrenz bei ab, Profis schwören darauf. Deutlich günstiger als US-Konkurrenz bei
vergleichbarer Funktionalität. vergleichbarer Funktionalität.
domains: domains:
- law-enforcement - static-investigations
- incident-response - incident-response
phases: phases:
- examination - examination
@ -1801,7 +1801,7 @@ tools:
Automatisierung. Die Zertifizierung (EnCE) ist in vielen Behörden Automatisierung. Die Zertifizierung (EnCE) ist in vielen Behörden
Einstellungsvoraussetzung. Einstellungsvoraussetzung.
domains: domains:
- law-enforcement - static-investigations
- incident-response - incident-response
phases: phases:
- data-collection - data-collection
@ -1835,7 +1835,7 @@ tools:
gleichzeitig. Für High-Volume-Labs die Investition wert, für gleichzeitig. Für High-Volume-Labs die Investition wert, für
Gelegenheitsnutzer Overkill. Gelegenheitsnutzer Overkill.
domains: domains:
- law-enforcement - static-investigations
- incident-response - incident-response
phases: phases:
- data-collection - data-collection
@ -1894,7 +1894,7 @@ tools:
Netzwerkverbindungen und Verschlüsselungsschlüssel. Netzwerkverbindungen und Verschlüsselungsschlüssel.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- malware-analysis - malware-analysis
phases: phases:
- data-collection - data-collection
@ -1928,7 +1928,7 @@ tools:
Unternehmensumgebungen mit gemischten Betriebssystem-Landschaften. Unternehmensumgebungen mit gemischten Betriebssystem-Landschaften.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- malware-analysis - malware-analysis
phases: phases:
- data-collection - data-collection
@ -1964,7 +1964,7 @@ tools:
erstellt durchsuchbare Ausgabeformate für effiziente Analyse. erstellt durchsuchbare Ausgabeformate für effiziente Analyse.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- malware-analysis - malware-analysis
phases: phases:
- data-collection - data-collection
@ -2060,7 +2060,7 @@ tools:
SHA, and digital signature validation. SHA, and digital signature validation.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- malware-analysis - malware-analysis
- cloud-forensics - cloud-forensics
phases: phases:
@ -2084,8 +2084,8 @@ tools:
domains: domains:
- id: incident-response - id: incident-response
name: Incident Response & Breach-Untersuchung name: Incident Response & Breach-Untersuchung
- id: law-enforcement - id: static-investigations
name: Strafverfolgung & Kriminalermittlung name: Datenträgerforensik & Ermittlungen
- id: malware-analysis - id: malware-analysis
name: Malware-Analyse & Reverse Engineering name: Malware-Analyse & Reverse Engineering
- id: fraud-investigation - id: fraud-investigation

View File

@ -12,7 +12,7 @@
Unternehmensumgebungen mit gemischten Betriebssystem-Landschaften. Unternehmensumgebungen mit gemischten Betriebssystem-Landschaften.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- malware-analysis - malware-analysis
phases: phases:
- data-collection - data-collection
@ -48,7 +48,7 @@
erstellt durchsuchbare Ausgabeformate für effiziente Analyse. erstellt durchsuchbare Ausgabeformate für effiziente Analyse.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- malware-analysis - malware-analysis
phases: phases:
- data-collection - data-collection
@ -144,7 +144,7 @@
SHA, and digital signature validation. SHA, and digital signature validation.
domains: domains:
- incident-response - incident-response
- law-enforcement - static-investigations
- malware-analysis - malware-analysis
- cloud-forensics - cloud-forensics
phases: phases:
@ -168,8 +168,8 @@
domains: domains:
- id: incident-response - id: incident-response
name: Incident Response & Breach-Untersuchung name: Incident Response & Breach-Untersuchung
- id: law-enforcement - id: static-investigations
name: Strafverfolgung & Kriminalermittlung name: Datenträgerforensik & Ermittlungen
- id: malware-analysis - id: malware-analysis
name: Malware-Analyse & Reverse Engineering name: Malware-Analyse & Reverse Engineering
- id: fraud-investigation - id: fraud-investigation

11
src/env.d.ts vendored
View File

@ -18,6 +18,17 @@ declare global {
switchToAIView?: () => void; switchToAIView?: () => void;
clearTagFilters?: () => void; clearTagFilters?: () => void;
clearAllFilters?: () => void; clearAllFilters?: () => void;
// CONSOLIDATED: Tool utility functions
createToolSlug: (toolName: string) => string;
findToolByIdentifier: (tools: any[], identifier: string) => any | undefined;
isToolHosted: (tool: any) => boolean;
// CONSOLIDATED: Auth utility functions (now in BaseLayout)
checkClientAuth: () => Promise<{authenticated: boolean; authRequired: boolean; expires?: string}>;
requireClientAuth: (callback?: () => void, returnUrl?: string) => Promise<boolean>;
showIfAuthenticated: (selector: string) => Promise<void>;
setupAuthButtons: (selector?: string) => void;
} }
} }

View File

@ -8,7 +8,7 @@ export interface Props {
description?: string; description?: string;
} }
const { title, description = 'CC24-Guide - A comprehensive directory of digital forensics and incident response tools' } = Astro.props; const { title, description = 'ForensicPathways - A comprehensive directory of digital forensics and incident response tools' } = Astro.props;
--- ---
<!DOCTYPE html> <!DOCTYPE html>
@ -17,12 +17,197 @@ const { title, description = 'CC24-Guide - A comprehensive directory of digital
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content={description}> <meta name="description" content={description}>
<title>{title} - CC24-Guide</title> <title>{title} - ForensicPathways</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico"> <link rel="icon" type="image/x-icon" href="/favicon.ico">
<script src="/src/scripts/theme.js"></script>
<script> <script>
// Initialize theme immediately to prevent flash // Initialize theme immediately to prevent flash
(window as any).themeUtils?.initTheme(); document.addEventListener('DOMContentLoaded', () => {
// Theme management (consolidated from theme.js)
const THEME_KEY = 'dfir-theme';
function getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function getStoredTheme() {
return localStorage.getItem(THEME_KEY) || 'auto';
}
function applyTheme(theme) {
const effectiveTheme = theme === 'auto' ? getSystemTheme() : theme;
document.documentElement.setAttribute('data-theme', effectiveTheme);
}
function updateThemeToggle(theme) {
document.querySelectorAll('[data-theme-toggle]').forEach(button => {
button.setAttribute('data-current-theme', theme);
});
}
function initTheme() {
const storedTheme = getStoredTheme();
applyTheme(storedTheme);
updateThemeToggle(storedTheme);
}
function toggleTheme() {
const current = getStoredTheme();
const themes = ['light', 'dark', 'auto'];
const currentIndex = themes.indexOf(current);
const nextIndex = (currentIndex + 1) % themes.length;
const nextTheme = themes[nextIndex];
localStorage.setItem(THEME_KEY, nextTheme);
applyTheme(nextTheme);
updateThemeToggle(nextTheme);
}
// Listen for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (getStoredTheme() === 'auto') {
applyTheme('auto');
}
});
(window as any).themeUtils = {
initTheme,
toggleTheme,
getStoredTheme
};
function createToolSlug(toolName) {
if (!toolName || typeof toolName !== 'string') {
console.warn('[toolHelpers] Invalid toolName provided to createToolSlug:', toolName);
return '';
}
return toolName.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Remove duplicate hyphens
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
}
function findToolByIdentifier(tools, identifier) {
if (!identifier || !Array.isArray(tools)) return undefined;
return tools.find(tool =>
tool.name === identifier ||
createToolSlug(tool.name) === identifier.toLowerCase()
);
}
function isToolHosted(tool) {
return tool.projectUrl !== undefined &&
tool.projectUrl !== null &&
tool.projectUrl !== "" &&
tool.projectUrl.trim() !== "";
}
// FIXED: Use type assertions to avoid TypeScript errors
// Make functions available globally for existing code compatibility
(window as any).createToolSlug = createToolSlug;
(window as any).findToolByIdentifier = findToolByIdentifier;
(window as any).isToolHosted = isToolHosted;
// Client-side auth functions (consolidated from client-auth.js)
async function checkClientAuth(context = 'general') {
try {
const response = await fetch('/api/auth/status');
const data = await response.json();
switch (context) {
case 'contributions':
return {
authenticated: data.contributionAuthenticated,
authRequired: data.contributionAuthRequired,
expires: data.expires
};
case 'ai':
return {
authenticated: data.aiAuthenticated,
authRequired: data.aiAuthRequired,
expires: data.expires
};
default:
return {
authenticated: data.authenticated,
authRequired: data.contributionAuthRequired || data.aiAuthRequired,
expires: data.expires
};
}
} catch (error) {
console.error('Auth check failed:', error);
return {
authenticated: false,
authRequired: true
};
}
}
async function requireClientAuth(callback, returnUrl, context = 'general') {
const authStatus = await checkClientAuth(context);
if (authStatus.authRequired && !authStatus.authenticated) {
const targetUrl = returnUrl || window.location.href;
window.location.href = `/api/auth/login?returnTo=${encodeURIComponent(targetUrl)}`;
return false;
} else {
if (typeof callback === 'function') {
callback();
}
return true;
}
}
async function showIfAuthenticated(selector, context = 'general') {
const authStatus = await checkClientAuth(context);
const element = document.querySelector(selector);
if (element) {
element.style.display = (!authStatus.authRequired || authStatus.authenticated)
? 'inline-flex'
: 'none';
}
}
function setupAuthButtons(selector = '[data-contribute-button]') {
document.addEventListener('click', async (e) => {
if (!e.target) return;
const button = (e.target as Element).closest(selector);
if (!button) return;
e.preventDefault();
console.log('[AUTH] Contribute button clicked:', button.getAttribute('data-contribute-button'));
// ENHANCED: Use contributions context
await requireClientAuth(() => {
console.log('[AUTH] Navigation approved, redirecting to:', (button as HTMLAnchorElement).href);
window.location.href = (button as HTMLAnchorElement).href;
}, (button as HTMLAnchorElement).href, 'contributions');
});
}
// Make auth functions available globally
(window as any).checkClientAuth = checkClientAuth;
(window as any).requireClientAuth = requireClientAuth;
(window as any).showIfAuthenticated = showIfAuthenticated;
(window as any).setupAuthButtons = setupAuthButtons;
// Initialize everything
initTheme();
setupAuthButtons('[data-contribute-button]');
const initAIButton = async () => {
await showIfAuthenticated('#ai-view-toggle', 'ai');
};
initAIButton();
console.log('[CONSOLIDATED] All utilities loaded and initialized');
});
</script> </script>
</head> </head>
<body> <body>

View File

@ -2,11 +2,11 @@
import BaseLayout from '../layouts/BaseLayout.astro'; import BaseLayout from '../layouts/BaseLayout.astro';
--- ---
<BaseLayout title="Über das Projekt" description="CC24-Guide - Ein Projekt für die Seminargruppe CC24-w1"> <BaseLayout title="Über das Projekt" description="ForensicPathways - Ein Projekt für die Seminargruppe CC24-w1">
<section style="padding: 2rem 0; max-width: 900px; margin: 0 auto;"> <section style="padding: 2rem 0; max-width: 900px; margin: 0 auto;">
<!-- Hero Section --> <!-- Hero Section -->
<div style="text-align: center; margin-bottom: 3rem; padding: 2rem; background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%); border-radius: 1rem; border: 1px solid var(--color-border);"> <div style="text-align: center; margin-bottom: 3rem; padding: 2rem; background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%); border-radius: 1rem; border: 1px solid var(--color-border);">
<h1 style="margin-bottom: 1rem; font-size: 2.5rem; color: var(--color-primary);">CC24-Guide</h1> <h1 style="margin-bottom: 1rem; font-size: 2.5rem; color: var(--color-primary);">ForensicPathways</h1>
<p style="font-size: 1.25rem; color: var(--color-text-secondary); margin-bottom: 0.5rem;"> <p style="font-size: 1.25rem; color: var(--color-text-secondary); margin-bottom: 0.5rem;">
Forensik im Dienst der Transparenz Forensik im Dienst der Transparenz
</p> </p>
@ -141,7 +141,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
</div> </div>
<p style="margin-bottom: 1rem; line-height: 1.7;"> <p style="margin-bottom: 1rem; line-height: 1.7;">
Falls eine Anwendung nicht wie vorgesehen funktioniert, ihr Unterstützung braucht, der Speicherplatz ausgeht Falls eine Anwendung nicht wie vorgesehen funktioniert, ihr Unterstützung braucht, der Speicherplatz ausgeht
oder sonstige Probleme auftreten: <strong>Schreibt mir einfach auf Signal!</strong> oder sonstige Probleme auftreten: <strong>Schreibt mir einfach auf Signal</strong> oder an <a href="mailto:mstoeck3@hs-mittweida.de">mstoeck3@hs-mittweida.de</a>.
</p> </p>
<!-- Special Note Box --> <!-- Special Note Box -->
@ -179,6 +179,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
</div> </div>
<!-- Contributing Section --> <!-- Contributing Section -->
<!-- Contribution Section -->
<div class="card" style="margin-bottom: 2rem; border-left: 4px solid var(--color-accent);"> <div class="card" style="margin-bottom: 2rem; border-left: 4px solid var(--color-accent);">
<div style="display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem;"> <div style="display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="2"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="2">
@ -187,40 +188,43 @@ import BaseLayout from '../layouts/BaseLayout.astro';
<line x1="20" y1="8" x2="20" y2="14"/> <line x1="20" y1="8" x2="20" y2="14"/>
<line x1="23" y1="11" x2="17" y2="11"/> <line x1="23" y1="11" x2="17" y2="11"/>
</svg> </svg>
<h2 style="margin: 0; color: var(--color-accent);">Mitmachen und Beitragen</h2> <h2 style="margin: 0; color: var(--color-accent);">Mitmachen &amp; Beitragen</h2>
</div> </div>
<div style="display: grid; gap: 1.25rem;"> <div style="display: grid; gap: 1.25rem;">
<!-- Suggestions -->
<div style="background-color: var(--color-bg-secondary); padding: 1.25rem; border-radius: 0.5rem;"> <div style="background-color: var(--color-bg-secondary); padding: 1.25rem; border-radius: 0.5rem;">
<h4 style="margin: 0 0 0.5rem 0; color: var(--color-accent);">🔍 Vorschläge</h4> <h4 style="margin: 0 0 0.5rem 0; color: var(--color-accent);">🔍 Vorschläge</h4>
<p style="margin: 0;"> <p style="margin: 0;">
Ich suche stets nach Ergänzungen für die Liste. Falls euch interessante Tools oder Methoden einfallen Du hast eine Idee, wie wir den Hub erweitern können? Reiche deinen Vorschlag unkompliziert
schreibt mir gerne auf Signal oder an <a href="mailto:mstoeck3@hs-mittweida.de">mstoeck3@hs-mittweida.de</a>. über unsere <a href="/contribute#vorschlaege">/contribute</a>-Seite ein.
</p> </p>
</div> </div>
<!-- Corrections & Updates -->
<div style="background-color: var(--color-bg-secondary); padding: 1.25rem; border-radius: 0.5rem;"> <div style="background-color: var(--color-bg-secondary); padding: 1.25rem; border-radius: 0.5rem;">
<h4 style="margin: 0 0 0.5rem 0; color: var(--color-accent);">🔧 Korrekturen & Updates</h4> <h4 style="margin: 0 0 0.5rem 0; color: var(--color-accent);">🔧 Korrekturen &amp; Updates</h4>
<p style="margin: 0;"> <p style="margin: 0;">
Sollte eine Anwendung/Methode nicht mehr aktuell, veraltet oder falsch repräsentiert sein, Ist eine Anwendung veraltet oder falsch dargestellt? Teile uns das bitte direkt unter
gebt mir unbedingt Bescheid. <a href="/contribute#korrekturen">/contribute</a> mit.
</p> </p>
</div> </div>
<!-- Code Contributions -->
<div style="background-color: var(--color-bg-secondary); padding: 1.25rem; border-radius: 0.5rem;"> <div style="background-color: var(--color-bg-secondary); padding: 1.25rem; border-radius: 0.5rem;">
<h4 style="margin: 0 0 0.5rem 0; color: var(--color-accent);">💻 Code-Beiträge</h4> <h4 style="margin: 0 0 0.5rem 0; color: var(--color-accent);">💻 CodeBeiträge</h4>
<p style="margin-bottom: 0.75rem;"> <p style="margin-bottom: 0.75rem;">
Ihr könnt auch direkt am Sourcecode mitarbeiten: Möchtest du direkt am Sourcecode mitarbeiten? Schau dir die Anleitung unter
<a href="/contribute#code">/contribute</a> an oder besuche unser Repository:
</p> </p>
<a href="https://git.cc24.dev/mstoeck3/cc24-hub" target="_blank" rel="noopener noreferrer" <a href="https://git.cc24.dev/mstoeck3/cc24-hub" target="_blank" rel="noopener noreferrer"
style="display: inline-flex; align-items: center; gap: 0.5rem; color: var(--color-accent); font-weight: 500;"> style="display: inline-flex; align-items: center; gap: 0.5rem; color: var(--color-accent); font-weight: 500;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/> <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg> </svg>
Git-Repository besuchen GitRepository besuchen
</a> </a>
</div> </div>
<div style="background-color: var(--color-bg-secondary); padding: 1.25rem; border-radius: 0.5rem;"> <div style="background-color: var(--color-bg-secondary); padding: 1.25rem; border-radius: 0.5rem;">
<h4 style="margin: 0 0 0.5rem 0; color: var(--color-accent);">⚡ Unterstützung</h4> <h4 style="margin: 0 0 0.5rem 0; color: var(--color-accent);">⚡ Unterstützung</h4>
<p style="margin: 0;"> <p style="margin: 0;">

View File

@ -1,7 +1,9 @@
// src/pages/api/ai/query.ts // src/pages/api/ai/query.ts
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { getSessionFromRequest, verifySession } from '../../../utils/auth.js'; import { withAPIAuth } from '../../../utils/auth.js';
import { getCompressedToolsDataForAI } from '../../../utils/dataService.js'; import { getCompressedToolsDataForAI } from '../../../utils/dataService.js';
import { apiError, apiServerError, createAuthErrorResponse } from '../../../utils/api.js';
import { enqueueApiCall } from '../../../utils/rateLimitedQueue.js';
export const prerender = false; export const prerender = false;
@ -97,7 +99,7 @@ function createWorkflowSystemPrompt(toolsData: any): string {
related_concepts: tool.related_concepts || [] related_concepts: tool.related_concepts || []
})); }));
// NEW: Include concepts for background knowledge // Include concepts for background knowledge
const conceptsList = toolsData.concepts.map((concept: any) => ({ const conceptsList = toolsData.concepts.map((concept: any) => ({
name: concept.name, name: concept.name,
description: concept.description, description: concept.description,
@ -107,7 +109,7 @@ function createWorkflowSystemPrompt(toolsData: any): string {
tags: concept.tags tags: concept.tags
})); }));
// Get regular phases (no more filtering needed) // Get regular phases
const regularPhases = toolsData.phases || []; const regularPhases = toolsData.phases || [];
// Get domain-agnostic software phases // Get domain-agnostic software phases
@ -159,7 +161,7 @@ FORENSISCHE DOMÄNEN:
${domainsDescription} ${domainsDescription}
WICHTIGE REGELN: WICHTIGE REGELN:
1. Pro Phase 1-3 Tools/Methoden empfehlen (immer mindestens 1 wenn verfügbar) 1. Pro Phase 2-3 Tools/Methoden empfehlen (immer mindestens 2 wenn verfügbar)
2. Tools/Methoden können in MEHREREN Phasen empfohlen werden wenn sinnvoll - versuche ein Tool/Methode für jede Phase zu empfehlen, selbst wenn die Priorität "low" ist. 2. Tools/Methoden können in MEHREREN Phasen empfohlen werden wenn sinnvoll - versuche ein Tool/Methode für jede Phase zu empfehlen, selbst wenn die Priorität "low" ist.
3. Für Reporting-Phase: Visualisierungs- und Dokumentationssoftware einschließen 3. Für Reporting-Phase: Visualisierungs- und Dokumentationssoftware einschließen
4. Gib stets dem spezieller für den Fall geeigneten Werkzeug den Vorzug. 4. Gib stets dem spezieller für den Fall geeigneten Werkzeug den Vorzug.
@ -215,7 +217,7 @@ function createToolSystemPrompt(toolsData: any): string {
related_concepts: tool.related_concepts || [] related_concepts: tool.related_concepts || []
})); }));
// NEW: Include concepts for background knowledge // Include concepts for background knowledge
const conceptsList = toolsData.concepts.map((concept: any) => ({ const conceptsList = toolsData.concepts.map((concept: any) => ({
name: concept.name, name: concept.name,
description: concept.description, description: concept.description,
@ -275,64 +277,36 @@ Antworte NUR mit validen JSON. Keine zusätzlichen Erklärungen außerhalb des J
export const POST: APIRoute = async ({ request }) => { export const POST: APIRoute = async ({ request }) => {
try { try {
// Check if authentication is required
const authRequired = process.env.AUTHENTICATION_NECESSARY !== 'false';
let userId = 'test-user';
if (authRequired) {
// Authentication check // Authentication check
const sessionToken = getSessionFromRequest(request); const authResult = await withAPIAuth(request, 'ai');
if (!sessionToken) { if (!authResult.authenticated) {
return new Response(JSON.stringify({ error: 'Authentication required' }), { return createAuthErrorResponse();
status: 401,
headers: { 'Content-Type': 'application/json' }
});
} }
const session = await verifySession(sessionToken); const userId = authResult.userId;
if (!session) {
return new Response(JSON.stringify({ error: 'Invalid session' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
userId = session.userId;
}
// Rate limiting // Rate limiting
if (!checkRateLimit(userId)) { if (!checkRateLimit(userId)) {
return new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { return apiError.rateLimit('Rate limit exceeded');
status: 429,
headers: { 'Content-Type': 'application/json' }
});
} }
// Parse request body // Parse request body
const body = await request.json(); const body = await request.json();
const { query, mode = 'workflow' } = body; const { query, mode = 'workflow', taskId: clientTaskId } = body;
// Validation
if (!query || typeof query !== 'string') { if (!query || typeof query !== 'string') {
return new Response(JSON.stringify({ error: 'Query required' }), { return apiError.badRequest('Query required');
status: 400,
headers: { 'Content-Type': 'application/json' }
});
} }
if (!['workflow', 'tool'].includes(mode)) { if (!['workflow', 'tool'].includes(mode)) {
return new Response(JSON.stringify({ error: 'Invalid mode. Must be "workflow" or "tool"' }), { return apiError.badRequest('Invalid mode. Must be "workflow" or "tool"');
status: 400,
headers: { 'Content-Type': 'application/json' }
});
} }
// Sanitize input // Sanitize input
const sanitizedQuery = sanitizeInput(query); const sanitizedQuery = sanitizeInput(query);
if (sanitizedQuery.includes('[FILTERED]')) { if (sanitizedQuery.includes('[FILTERED]')) {
return new Response(JSON.stringify({ error: 'Invalid input detected' }), { return apiError.badRequest('Invalid input detected');
status: 400,
headers: { 'Content-Type': 'application/json' }
});
} }
// Load tools database // Load tools database
@ -343,7 +317,12 @@ export const POST: APIRoute = async ({ request }) => {
? createWorkflowSystemPrompt(toolsData) ? createWorkflowSystemPrompt(toolsData)
: createToolSystemPrompt(toolsData); : createToolSystemPrompt(toolsData);
const aiResponse = await fetch(process.env.AI_API_ENDPOINT + '/v1/chat/completions', { // Generate task ID for queue tracking (use client-provided ID if available)
const taskId = clientTaskId || `ai_${userId}_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
// Make AI API call through rate-limited queue
const aiResponse = await enqueueApiCall(() =>
fetch(process.env.AI_API_ENDPOINT + '/v1/chat/completions', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -364,24 +343,20 @@ export const POST: APIRoute = async ({ request }) => {
max_tokens: 2000, max_tokens: 2000,
temperature: 0.3 temperature: 0.3
}) })
}); })
, taskId);
// AI response handling
if (!aiResponse.ok) { if (!aiResponse.ok) {
console.error('AI API error:', await aiResponse.text()); console.error('AI API error:', await aiResponse.text());
return new Response(JSON.stringify({ error: 'AI service unavailable' }), { return apiServerError.unavailable('AI service unavailable');
status: 503,
headers: { 'Content-Type': 'application/json' }
});
} }
const aiData = await aiResponse.json(); const aiData = await aiResponse.json();
const aiContent = aiData.choices?.[0]?.message?.content; const aiContent = aiData.choices?.[0]?.message?.content;
if (!aiContent) { if (!aiContent) {
return new Response(JSON.stringify({ error: 'No response from AI' }), { return apiServerError.unavailable('No response from AI');
status: 503,
headers: { 'Content-Type': 'application/json' }
});
} }
// Parse AI JSON response // Parse AI JSON response
@ -391,10 +366,7 @@ export const POST: APIRoute = async ({ request }) => {
recommendation = JSON.parse(cleanedContent); recommendation = JSON.parse(cleanedContent);
} catch (error) { } catch (error) {
console.error('Failed to parse AI response:', aiContent); console.error('Failed to parse AI response:', aiContent);
return new Response(JSON.stringify({ error: 'Invalid AI response format' }), { return apiServerError.unavailable('Invalid AI response format');
status: 503,
headers: { 'Content-Type': 'application/json' }
});
} }
// Validate tool names and concept names against database // Validate tool names and concept names against database
@ -450,9 +422,11 @@ export const POST: APIRoute = async ({ request }) => {
// Log successful query // Log successful query
console.log(`[AI Query] Mode: ${mode}, User: ${userId}, Query length: ${sanitizedQuery.length}, Tools: ${validatedRecommendation.recommended_tools.length}, Concepts: ${validatedRecommendation.background_knowledge?.length || 0}`); console.log(`[AI Query] Mode: ${mode}, User: ${userId}, Query length: ${sanitizedQuery.length}, Tools: ${validatedRecommendation.recommended_tools.length}, Concepts: ${validatedRecommendation.background_knowledge?.length || 0}`);
// Success response with task ID
return new Response(JSON.stringify({ return new Response(JSON.stringify({
success: true, success: true,
mode, mode,
taskId,
recommendation: validatedRecommendation, recommendation: validatedRecommendation,
query: sanitizedQuery query: sanitizedQuery
}), { }), {
@ -462,9 +436,6 @@ export const POST: APIRoute = async ({ request }) => {
} catch (error) { } catch (error) {
console.error('AI query error:', error); console.error('AI query error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), { return apiServerError.internal('Internal server error');
status: 500,
headers: { 'Content-Type': 'application/json' }
});
} }
}; };

View File

@ -0,0 +1,23 @@
// src/pages/api/ai/queue-status.ts
import type { APIRoute } from 'astro';
import { getQueueStatus } from '../../../utils/rateLimitedQueue.js';
import { apiResponse, apiServerError } from '../../../utils/api.js';
export const prerender = false;
export const GET: APIRoute = async ({ request }) => {
try {
const url = new URL(request.url);
const taskId = url.searchParams.get('taskId');
const status = getQueueStatus(taskId || undefined);
return apiResponse.success({
...status,
timestamp: Date.now()
});
} catch (error) {
console.error('Queue status error:', error);
return apiServerError.internal('Failed to get queue status');
}
};

View File

@ -1,102 +0,0 @@
import type { APIRoute } from 'astro';
import { parse } from 'cookie';
import {
exchangeCodeForTokens,
getUserInfo,
createSession,
createSessionCookie,
logAuthEvent
} from '../../../utils/auth.js';
export const GET: APIRoute = async ({ url, request }) => {
try {
if (process.env.NODE_ENV === 'development') {
console.log('Auth callback processing...');
console.log('Full URL:', url.toString());
console.log('URL pathname:', url.pathname);
console.log('URL search:', url.search);
console.log('URL searchParams:', url.searchParams.toString());
}
// Try different ways to get parameters
const allParams = Object.fromEntries(url.searchParams.entries());
console.log('SearchParams entries:', allParams);
// Also try parsing manually from the search string
const manualParams = new URLSearchParams(url.search);
const manualEntries = Object.fromEntries(manualParams.entries());
console.log('Manual URLSearchParams:', manualEntries);
// Also check request URL
const requestUrl = new URL(request.url);
console.log('Request URL:', requestUrl.toString());
const requestParams = Object.fromEntries(requestUrl.searchParams.entries());
console.log('Request URL params:', requestParams);
const code = url.searchParams.get('code') || requestUrl.searchParams.get('code');
const state = url.searchParams.get('state') || requestUrl.searchParams.get('state');
const error = url.searchParams.get('error') || requestUrl.searchParams.get('error');
console.log('Final extracted values:', { code: !!code, state: !!state, error });
// Handle OIDC errors
if (error) {
logAuthEvent('OIDC error', { error, description: url.searchParams.get('error_description') });
return new Response(null, {
status: 302,
headers: { 'Location': '/?auth=error' }
});
}
if (!code || !state) {
logAuthEvent('Missing code or state parameter', { received: allParams });
return new Response('Invalid callback parameters', { status: 400 });
}
// Verify state parameter
const cookieHeader = request.headers.get('cookie');
const cookies = cookieHeader ? parse(cookieHeader) : {};
const storedStateData = cookies.auth_state ? JSON.parse(decodeURIComponent(cookies.auth_state)) : null;
if (!storedStateData || storedStateData.state !== state) {
logAuthEvent('State mismatch', { received: state, stored: storedStateData?.state });
return new Response('Invalid state parameter', { status: 400 });
}
// Exchange code for tokens
const tokens = await exchangeCodeForTokens(code);
// Get user info
const userInfo = await getUserInfo(tokens.access_token);
// Create session
const sessionToken = await createSession(userInfo.sub || userInfo.preferred_username || 'unknown');
const sessionCookie = createSessionCookie(sessionToken);
logAuthEvent('Authentication successful', {
userId: userInfo.sub || userInfo.preferred_username,
email: userInfo.email
});
// Clear auth state cookie and redirect to intended destination
const returnTo = storedStateData.returnTo || '/';
const clearStateCookie = 'auth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0';
const headers = new Headers();
headers.append('Location', returnTo);
headers.append('Set-Cookie', sessionCookie);
headers.append('Set-Cookie', clearStateCookie);
return new Response(null, {
status: 302,
headers: headers
});
} catch (error) {
logAuthEvent('Callback failed', { error: error.message });
return new Response(null, {
status: 302,
headers: { 'Location': '/?auth=error' }
});
}
};

View File

@ -28,7 +28,7 @@ export const GET: APIRoute = async ({ url, redirect }) => {
} }
}); });
} catch (error) { } catch (error) {
logAuthEvent('Login failed', { error: error.message }); logAuthEvent('Login failed', { error: error instanceof Error ? error.message : 'Unknown error' });
return new Response('Authentication error', { status: 500 }); return new Response('Authentication error', { status: 500 });
} }
}; };

View File

@ -1,104 +1,67 @@
// src/pages/api/auth/process.ts (FIXED - Proper cookie handling)
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { parse } from 'cookie';
import { import {
verifyAuthState,
exchangeCodeForTokens, exchangeCodeForTokens,
getUserInfo, getUserInfo,
createSession, createSessionWithCookie,
createSessionCookie,
logAuthEvent logAuthEvent
} from '../../../utils/auth.js'; } from '../../../utils/auth.js';
import { apiError, apiSpecial, apiWithHeaders, handleAPIRequest } from '../../../utils/api.js';
// Mark as server-rendered
export const prerender = false; export const prerender = false;
export const POST: APIRoute = async ({ request }) => { export const POST: APIRoute = async ({ request }) => {
try { return await handleAPIRequest(async () => {
// Check if there's a body to parse // Parse request body
const contentType = request.headers.get('content-type');
console.log('Request content-type:', contentType);
let body; let body;
try { try {
body = await request.json(); body = await request.json();
} catch (parseError) { } catch (parseError) {
console.error('JSON parse error:', parseError); console.error('JSON parse error:', parseError);
return new Response(JSON.stringify({ success: false, error: 'Invalid JSON' }), { return apiSpecial.invalidJSON();
status: 400,
headers: { 'Content-Type': 'application/json' }
});
} }
const { code, state } = body || {}; const { code, state } = body || {};
console.log('Processing authentication:', { code: !!code, state: !!state });
if (!code || !state) { if (!code || !state) {
logAuthEvent('Missing code or state parameter in process request'); logAuthEvent('Missing code or state parameter in process request');
return new Response(JSON.stringify({ success: false }), { return apiSpecial.missingRequired(['code', 'state']);
status: 400,
headers: { 'Content-Type': 'application/json' }
});
} }
// Verify state parameter // CONSOLIDATED: Single function call replaces 15+ lines of duplicated state verification
const cookieHeader = request.headers.get('cookie'); const stateVerification = verifyAuthState(request, state);
const cookies = cookieHeader ? parse(cookieHeader) : {}; if (!stateVerification.isValid || !stateVerification.stateData) {
const storedStateData = cookies.auth_state ? JSON.parse(decodeURIComponent(cookies.auth_state)) : null; return apiError.badRequest(stateVerification.error || 'Invalid state parameter');
console.log('State verification:', {
received: state,
stored: storedStateData?.state,
match: storedStateData?.state === state
});
if (!storedStateData || storedStateData.state !== state) {
logAuthEvent('State mismatch', { received: state, stored: storedStateData?.state });
return new Response(JSON.stringify({ success: false }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
} }
// Exchange code for tokens // Exchange code for tokens and get user info
console.log('Exchanging code for tokens...');
const tokens = await exchangeCodeForTokens(code); const tokens = await exchangeCodeForTokens(code);
// Get user info
console.log('Getting user info...');
const userInfo = await getUserInfo(tokens.access_token); const userInfo = await getUserInfo(tokens.access_token);
// Create session // CONSOLIDATED: Single function call replaces 10+ lines of session creation
const sessionToken = await createSession(userInfo.sub || userInfo.preferred_username || 'unknown'); const sessionResult = await createSessionWithCookie(userInfo);
const sessionCookie = createSessionCookie(sessionToken);
logAuthEvent('Authentication successful', { logAuthEvent('Authentication successful', {
userId: userInfo.sub || userInfo.preferred_username, userId: sessionResult.userId,
email: userInfo.email email: sessionResult.userEmail
}); });
// Clear auth state cookie // FIXED: Create response with multiple Set-Cookie headers
const clearStateCookie = 'auth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0'; const responseHeaders = new Headers();
const returnTo = storedStateData.returnTo || '/'; responseHeaders.set('Content-Type', 'application/json');
const headers = new Headers(); // Each cookie needs its own Set-Cookie header
headers.append('Content-Type', 'application/json'); responseHeaders.append('Set-Cookie', sessionResult.sessionCookie);
headers.append('Set-Cookie', sessionCookie); responseHeaders.append('Set-Cookie', sessionResult.clearStateCookie);
headers.append('Set-Cookie', clearStateCookie);
return new Response(JSON.stringify({ return new Response(JSON.stringify({
success: true, success: true,
redirectTo: returnTo redirectTo: stateVerification.stateData.returnTo
}), { }), {
status: 200, status: 200,
headers: headers headers: responseHeaders
}); });
} catch (error) { }, 'Authentication processing failed');
console.error('Authentication processing failed:', error);
logAuthEvent('Authentication processing failed', { error: error.message });
return new Response(JSON.stringify({ success: false }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}; };

View File

@ -1,56 +1,22 @@
// src/pages/api/auth/status.ts // src/pages/api/auth/status.ts
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { getSessionFromRequest, verifySession } from '../../../utils/auth.js'; import { withAPIAuth } from '../../../utils/auth.js';
import { apiResponse, handleAPIRequest } from '../../../utils/api.js';
export const prerender = false; export const prerender = false;
export const GET: APIRoute = async ({ request }) => { export const GET: APIRoute = async ({ request }) => {
try { return await handleAPIRequest(async () => {
// Check if authentication is required const contributionAuth = await withAPIAuth(request, 'contributions');
const authRequired = process.env.AUTHENTICATION_NECESSARY !== 'false'; const aiAuth = await withAPIAuth(request, 'ai');
if (!authRequired) { return apiResponse.success({
// If authentication is not required, always return authenticated authenticated: contributionAuth.authenticated || aiAuth.authenticated,
return new Response(JSON.stringify({ contributionAuthRequired: contributionAuth.authRequired,
authenticated: true, aiAuthRequired: aiAuth.authRequired,
authRequired: false contributionAuthenticated: contributionAuth.authenticated,
}), { aiAuthenticated: aiAuth.authenticated,
status: 200, expires: contributionAuth.session?.exp ? new Date(contributionAuth.session.exp * 1000).toISOString() : null
headers: { 'Content-Type': 'application/json' }
}); });
} }, 'Status check failed');
const sessionToken = getSessionFromRequest(request);
if (!sessionToken) {
return new Response(JSON.stringify({
authenticated: false,
authRequired: true
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
const session = await verifySession(sessionToken);
return new Response(JSON.stringify({
authenticated: session !== null,
authRequired: true,
expires: session?.exp ? new Date(session.exp * 1000).toISOString() : null
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
return new Response(JSON.stringify({
authenticated: false,
authRequired: process.env.AUTHENTICATION_NECESSARY !== 'false',
error: 'Session verification failed'
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
}; };

View File

@ -0,0 +1,160 @@
// src/pages/api/contribute/knowledgebase.ts - SIMPLIFIED: Issues only, minimal validation
import type { APIRoute } from 'astro';
import { withAPIAuth } from '../../../utils/auth.js';
import { apiResponse, apiError, apiServerError, handleAPIRequest } from '../../../utils/api.js';
import { GitContributionManager } from '../../../utils/gitContributions.js';
import { z } from 'zod';
export const prerender = false;
// Simple schema - all fields optional except for having some content
const KnowledgebaseContributionSchema = z.object({
toolName: z.string().optional().nullable().transform(val => val || undefined),
title: z.string().optional().nullable().transform(val => val || undefined),
description: z.string().optional().nullable().transform(val => val || undefined),
content: z.string().optional().nullable().transform(val => val || undefined),
externalLink: z.string().url().optional().nullable().catch(undefined),
difficulty: z.enum(['novice', 'beginner', 'intermediate', 'advanced', 'expert']).optional().nullable().catch(undefined),
categories: z.string().transform(str => {
try { return JSON.parse(str); } catch { return []; }
}).pipe(z.array(z.string()).default([])),
tags: z.string().transform(str => {
try { return JSON.parse(str); } catch { return []; }
}).pipe(z.array(z.string()).default([])),
uploadedFiles: z.string().transform(str => {
try { return JSON.parse(str); } catch { return []; }
}).pipe(z.array(z.any()).default([])),
reason: z.string().optional().nullable().transform(val => val || undefined)
});
interface KnowledgebaseContributionData {
toolName?: string;
title?: string;
description?: string;
content?: string;
externalLink?: string;
difficulty?: string;
categories: string[];
tags: string[];
uploadedFiles: any[];
reason?: string;
}
// Rate limiting
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
const RATE_LIMIT_MAX = 3; // Max 3 submissions per hour per user
function checkRateLimit(userEmail: string): boolean {
const now = Date.now();
const userLimit = rateLimitStore.get(userEmail);
if (!userLimit || now > userLimit.resetTime) {
rateLimitStore.set(userEmail, { count: 1, resetTime: now + RATE_LIMIT_WINDOW });
return true;
}
if (userLimit.count >= RATE_LIMIT_MAX) {
return false;
}
userLimit.count++;
return true;
}
function validateKnowledgebaseData(data: KnowledgebaseContributionData): { valid: boolean; errors?: string[] } {
// Very minimal validation - just check that SOMETHING was provided
// Use nullish coalescing to avoid “possibly undefined” errors in strict mode
const hasContent = (data.content ?? '').trim().length > 0;
const hasLink = (data.externalLink ?? '').trim().length > 0;
const hasFiles = Array.isArray(data.uploadedFiles) && data.uploadedFiles.length > 0;
const hasTitle = (data.title ?? '').trim().length > 0;
const hasDescription = (data.description ?? '').trim().length > 0;
if (!hasContent && !hasLink && !hasFiles && !hasTitle && !hasDescription) {
return {
valid: false,
errors: ['Please provide at least a title, description, content, external link, or upload files']
};
}
return { valid: true };
}
export const POST: APIRoute = async ({ request }) => {
return await handleAPIRequest(async () => {
// Check authentication
const authResult = await withAPIAuth(request, 'contributions');
if (authResult.authRequired && !authResult.authenticated) {
return apiError.unauthorized();
}
const userEmail = authResult.session?.email || 'anon@anon.anon';
// Rate limiting
if (!checkRateLimit(userEmail)) {
return apiError.rateLimit('Rate limit exceeded. Please wait before submitting again.');
}
// Parse form data
let formData;
try {
formData = await request.formData();
} catch (error) {
return apiError.badRequest('Invalid form data');
}
const rawData = Object.fromEntries(formData);
// Validate and sanitize data
let validatedData;
try {
validatedData = KnowledgebaseContributionSchema.parse(rawData);
} catch (error) {
if (error instanceof z.ZodError) {
const errorMessages = error.errors.map(err =>
`${err.path.join('.')}: ${err.message}`
);
return apiError.validation('Validation failed', errorMessages);
}
return apiError.badRequest('Invalid request data');
}
// Basic content validation
const contentValidation = validateKnowledgebaseData(validatedData);
if (!contentValidation.valid) {
return apiError.validation('Content validation failed', contentValidation.errors);
}
// Submit as issue via Git
try {
const gitManager = new GitContributionManager();
const result = await gitManager.submitKnowledgebaseContribution({
...validatedData,
submitter: userEmail
});
if (result.success) {
console.log(`[KB CONTRIBUTION] "${validatedData.title || 'Article'}" by ${userEmail} - Issue: ${result.issueUrl}`);
return apiResponse.created({
success: true,
message: result.message,
issueUrl: result.issueUrl,
issueNumber: result.issueNumber
});
} else {
console.error(`[KB CONTRIBUTION FAILED] "${validatedData.title || 'Article'}" by ${userEmail}: ${result.message}`);
return apiServerError.internal(`Contribution failed: ${result.message}`);
}
} catch (error) {
console.error(`[KB GIT ERROR] "${validatedData.title || 'Article'}" by ${userEmail}:`, error);
const errorMessage = error instanceof Error ? error.message : 'Git operation failed';
return apiServerError.internal(`Submission failed: ${errorMessage}`);
}
}, 'Knowledgebase contribution processing failed');
};

View File

@ -0,0 +1,219 @@
// src/pages/api/contribute/tool.ts (UPDATED - Using consolidated API responses)
import type { APIRoute } from 'astro';
import { withAPIAuth } from '../../../utils/auth.js';
import { apiResponse, apiError, apiServerError, apiSpecial, handleAPIRequest } from '../../../utils/api.js';
import { GitContributionManager, type ContributionData } from '../../../utils/gitContributions.js';
import { z } from 'zod';
export const prerender = false;
// Enhanced tool schema for contributions (stricter validation)
const ContributionToolSchema = z.object({
name: z.string().min(1, 'Tool name is required').max(100, 'Tool name too long'),
icon: z.string().optional().nullable(),
type: z.enum(['software', 'method', 'concept'], {
errorMap: () => ({ message: 'Type must be software, method, or concept' })
}),
description: z.string().min(10, 'Description must be at least 10 characters').max(1000, 'Description too long'),
domains: z.array(z.string()).default([]),
phases: z.array(z.string()).default([]),
platforms: z.array(z.string()).default([]),
skillLevel: z.enum(['novice', 'beginner', 'intermediate', 'advanced', 'expert'], {
errorMap: () => ({ message: 'Invalid skill level' })
}),
accessType: z.string().optional().nullable(),
url: z.string().url('Must be a valid URL'),
projectUrl: z.string().url('Must be a valid URL').optional().nullable(),
license: z.string().optional().nullable(),
knowledgebase: z.boolean().optional().nullable(),
'domain-agnostic-software': z.array(z.string()).optional().nullable(),
related_concepts: z.array(z.string()).optional().nullable(),
tags: z.array(z.string()).default([]),
statusUrl: z.string().url('Must be a valid URL').optional().nullable()
});
const ContributionRequestSchema = z.object({
action: z.enum(['add', 'edit'], {
errorMap: () => ({ message: 'Action must be add or edit' })
}),
tool: ContributionToolSchema,
metadata: z.object({
reason: z.string().transform(val => val.trim() === '' ? undefined : val).optional()
}).optional()
});
// Rate limiting
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
const RATE_LIMIT_MAX = 15; // 15 contributions per hour per user
function checkRateLimit(userId: string): boolean {
const now = Date.now();
const userLimit = rateLimitStore.get(userId);
if (!userLimit || now > userLimit.resetTime) {
rateLimitStore.set(userId, { count: 1, resetTime: now + RATE_LIMIT_WINDOW });
return true;
}
if (userLimit.count >= RATE_LIMIT_MAX) {
return false;
}
userLimit.count++;
return true;
}
// Input sanitization
function sanitizeInput(obj: any): any {
if (typeof obj === 'string') {
return obj.trim().slice(0, 1000);
}
if (Array.isArray(obj)) {
return obj.map(sanitizeInput);
}
if (obj && typeof obj === 'object') {
const sanitized: any = {};
for (const [key, value] of Object.entries(obj)) {
sanitized[key] = sanitizeInput(value);
}
return sanitized;
}
return obj;
}
// Tool validation function
async function validateToolData(tool: any, action: string): Promise<{ valid: boolean; errors: string[] }> {
const errors: string[] = [];
try {
// Load existing data for validation
const existingData = { tools: [] }; // Replace with actual data loading
// Check for duplicate names (on add)
if (action === 'add') {
const existingNames = new Set(existingData.tools.map((t: any) => t.name.toLowerCase()));
if (existingNames.has(tool.name.toLowerCase())) {
errors.push('A tool with this name already exists');
}
}
// Type-specific validation
if (tool.type === 'method') {
if (tool.platforms && tool.platforms.length > 0) {
errors.push('Methods should not have platform information');
}
if (tool.license && tool.license !== null) {
errors.push('Methods should not have license information');
}
} else if (tool.type === 'software') {
if (!tool.platforms || tool.platforms.length === 0) {
errors.push('Software tools must specify at least one platform');
}
if (!tool.license) {
errors.push('Software tools must specify a license');
}
}
return { valid: errors.length === 0, errors };
} catch (error) {
console.error('Tool validation failed:', error);
errors.push('Validation failed due to system error');
return { valid: false, errors };
}
}
export const POST: APIRoute = async ({ request }) => {
return await handleAPIRequest(async () => {
// Authentication check
const authResult = await withAPIAuth(request, 'contributions');
if (authResult.authRequired && !authResult.authenticated) {
return apiError.unauthorized();
}
const userId = authResult.session?.userId || 'anonymous';
const userEmail = authResult.session?.email || 'anon@anon.anon';
// Rate limiting
if (!checkRateLimit(userId)) {
return apiError.rateLimit('Rate limit exceeded. Please wait before submitting another contribution.');
}
// Parse and sanitize request body
let body;
try {
const rawBody = await request.text();
if (!rawBody.trim()) {
return apiSpecial.emptyBody();
}
body = JSON.parse(rawBody);
} catch (error) {
return apiSpecial.invalidJSON();
}
// Sanitize input
const sanitizedBody = sanitizeInput(body);
// Validate request structure
let validatedData;
try {
validatedData = ContributionRequestSchema.parse(sanitizedBody);
} catch (error) {
if (error instanceof z.ZodError) {
const errorMessages = error.errors.map(err =>
`${err.path.join('.')}: ${err.message}`
);
return apiError.validation('Validation failed', errorMessages);
}
return apiError.badRequest('Invalid request data');
}
// Additional tool-specific validation
const toolValidation = await validateToolData(validatedData.tool, validatedData.action);
if (!toolValidation.valid) {
return apiError.validation('Tool validation failed', toolValidation.errors);
}
// Prepare contribution data
const contributionData: ContributionData = {
type: validatedData.action,
tool: validatedData.tool,
metadata: {
submitter: userEmail,
reason: validatedData.metadata?.reason
}
};
// CRITICAL FIX: Enhanced error handling for Git operations
try {
// Submit contribution via Git (now creates issue instead of PR)
const gitManager = new GitContributionManager();
const result = await gitManager.submitContribution(contributionData);
if (result.success) {
console.log(`[CONTRIBUTION] Issue created for "${validatedData.tool.name}" by ${userEmail} - Issue: ${result.issueUrl}`);
return apiResponse.created({
success: true,
message: result.message,
issueUrl: result.issueUrl,
issueNumber: result.issueNumber
});
} else {
console.error(`[CONTRIBUTION FAILED] "${validatedData.tool.name}" by ${userEmail}: ${result.message}`);
return apiServerError.internal(`Contribution failed: ${result.message}`);
}
} catch (gitError) {
// CRITICAL: Handle Git operation errors properly
console.error(`[GIT ERROR] ${validatedData.action} "${validatedData.tool.name}" by ${userEmail}:`, gitError);
// Return proper error response
const errorMessage = gitError instanceof Error ? gitError.message : 'Git operation failed';
return apiServerError.internal(`Git operation failed: ${errorMessage}`);
}
}, 'Contribution processing failed');
};

View File

@ -0,0 +1,280 @@
// src/pages/api/upload/media.ts (UPDATED - Using consolidated API responses)
import type { APIRoute } from 'astro';
import { withAPIAuth } from '../../../utils/auth.js';
import { apiResponse, apiError, apiServerError, apiSpecial, handleAPIRequest } from '../../../utils/api.js';
import { NextcloudUploader, isNextcloudConfigured } from '../../../utils/nextcloud.js';
import { promises as fs } from 'fs';
import path from 'path';
import crypto from 'crypto';
export const prerender = false;
interface UploadResult {
success: boolean;
url?: string;
filename?: string;
size?: number;
error?: string;
storage?: 'nextcloud' | 'local';
}
// Configuration
const UPLOAD_CONFIG = {
maxFileSize: 50 * 1024 * 1024, // 50MB
allowedTypes: new Set([
// Images
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
// Videos
'video/mp4', 'video/webm', 'video/ogg', 'video/avi', 'video/mov',
// Documents
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain', 'text/csv', 'application/json'
]),
localUploadPath: process.env.LOCAL_UPLOAD_PATH || './public/uploads',
publicBaseUrl: process.env.PUBLIC_BASE_URL || 'http://localhost:4321'
};
// Rate limiting for uploads
const uploadRateLimit = new Map<string, { count: number; resetTime: number }>();
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
const RATE_LIMIT_MAX = 100; // Max 100 uploads per hour per user
function checkUploadRateLimit(userEmail: string): boolean {
const now = Date.now();
const userLimit = uploadRateLimit.get(userEmail);
if (!userLimit || now > userLimit.resetTime) {
uploadRateLimit.set(userEmail, { count: 1, resetTime: now + RATE_LIMIT_WINDOW });
return true;
}
if (userLimit.count >= RATE_LIMIT_MAX) {
return false;
}
userLimit.count++;
return true;
}
function validateFile(file: File): { valid: boolean; error?: string } {
// File size check
if (file.size > UPLOAD_CONFIG.maxFileSize) {
return {
valid: false,
error: `File too large. Maximum size is ${Math.round(UPLOAD_CONFIG.maxFileSize / 1024 / 1024)}MB`
};
}
// File type check
if (!UPLOAD_CONFIG.allowedTypes.has(file.type)) {
return {
valid: false,
error: `File type ${file.type} not allowed`
};
}
return { valid: true };
}
async function uploadToNextcloud(file: File, userEmail: string): Promise<UploadResult> {
try {
const uploader = new NextcloudUploader();
const result = await uploader.uploadFile(file, userEmail);
return {
success: true,
url: result.url,
filename: result.filename,
size: file.size,
storage: 'nextcloud'
};
} catch (error) {
console.error('Nextcloud upload failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Nextcloud upload failed',
storage: 'nextcloud'
};
}
}
async function uploadToLocal(file: File, userType: string): Promise<UploadResult> {
try {
// Ensure upload directory exists
await fs.mkdir(UPLOAD_CONFIG.localUploadPath, { recursive: true });
// Generate unique filename
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const randomString = crypto.randomBytes(8).toString('hex');
const extension = path.extname(file.name);
const filename = `${timestamp}-${randomString}${extension}`;
// Save file
const filepath = path.join(UPLOAD_CONFIG.localUploadPath, filename);
const buffer = Buffer.from(await file.arrayBuffer());
await fs.writeFile(filepath, buffer);
// Generate public URL
const publicUrl = `${UPLOAD_CONFIG.publicBaseUrl}/uploads/${filename}`;
return {
success: true,
url: publicUrl,
filename: filename,
size: file.size,
storage: 'local'
};
} catch (error) {
console.error('Local upload failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Local upload failed',
storage: 'local'
};
}
}
export const POST: APIRoute = async ({ request }) => {
return await handleAPIRequest(async () => {
const authResult = await withAPIAuth(request, 'contributions');
if (authResult.authRequired && !authResult.authenticated) {
return apiError.unauthorized();
}
const userEmail = authResult.session?.email || 'anon@anon.anon';
if (!checkUploadRateLimit(userEmail)) {
return apiError.rateLimit('Upload rate limit exceeded. Please wait before uploading again.');
}
let formData;
try {
formData = await request.formData();
} catch (error) {
return apiError.badRequest('Invalid form data');
}
const file = formData.get('file') as File;
const type = formData.get('type') as string;
if (!file) {
return apiSpecial.missingRequired(['file']);
}
// Validate file
const validation = validateFile(file);
if (!validation.valid) {
return apiError.badRequest(validation.error!);
}
// Attempt upload (Nextcloud first, then local fallback)
let result: UploadResult;
if (isNextcloudConfigured()) {
result = await uploadToNextcloud(file, userEmail);
// If Nextcloud fails, try local fallback
if (!result.success) {
console.warn('Nextcloud upload failed, trying local fallback:', result.error);
result = await uploadToLocal(file, type);
}
} else {
result = await uploadToLocal(file, type);
}
if (result.success) {
// Log successful upload
console.log(`[MEDIA UPLOAD] ${file.name} (${file.size} bytes) by ${userEmail} -> ${result.storage}: ${result.url}`);
// BEFORE: Manual success response (5 lines)
// return new Response(JSON.stringify(result), {
// status: 200,
// headers: { 'Content-Type': 'application/json' }
// });
// AFTER: Single line with specialized helper
return apiSpecial.uploadSuccess({
url: result.url!,
filename: result.filename!,
size: result.size!,
storage: result.storage!
});
} else {
// Log failed upload
console.error(`[MEDIA UPLOAD FAILED] ${file.name} by ${userEmail}: ${result.error}`);
// BEFORE: Manual error response (5 lines)
// return new Response(JSON.stringify(result), {
// status: 500,
// headers: { 'Content-Type': 'application/json' }
// });
// AFTER: Single line with specialized helper
return apiSpecial.uploadFailed(result.error!);
}
}, 'Media upload processing failed');
};
// GET endpoint for upload status/info
export const GET: APIRoute = async ({ request }) => {
return await handleAPIRequest(async () => {
// Authentication check
const authResult = await withAPIAuth(request);
if (authResult.authRequired && !authResult.authenticated) {
return apiError.unauthorized();
}
// Return upload configuration and status
const nextcloudConfigured = isNextcloudConfigured();
// Check local upload directory
let localStorageAvailable = false;
try {
await fs.access(UPLOAD_CONFIG.localUploadPath);
localStorageAvailable = true;
} catch {
try {
await fs.mkdir(UPLOAD_CONFIG.localUploadPath, { recursive: true });
localStorageAvailable = true;
} catch (error) {
console.warn('Local upload directory not accessible:', error);
}
}
const status = {
storage: {
nextcloud: {
configured: nextcloudConfigured,
primary: nextcloudConfigured
},
local: {
available: localStorageAvailable,
fallback: nextcloudConfigured,
primary: !nextcloudConfigured
}
},
limits: {
maxFileSize: UPLOAD_CONFIG.maxFileSize,
maxFileSizeMB: Math.round(UPLOAD_CONFIG.maxFileSize / 1024 / 1024),
allowedTypes: Array.from(UPLOAD_CONFIG.allowedTypes),
rateLimit: {
maxPerHour: RATE_LIMIT_MAX,
windowMs: RATE_LIMIT_WINDOW
}
},
paths: {
uploadEndpoint: '/api/upload/media',
localPath: localStorageAvailable ? '/uploads' : null
}
};
return apiResponse.success(status);
}, 'Upload status retrieval failed');
};

View File

@ -1,4 +1,5 @@
--- ---
// src/pages/auth/callback.astro - Fixed with Email
// Since server-side URL parameters aren't working, // Since server-side URL parameters aren't working,
// we'll handle this client-side and POST to the API // we'll handle this client-side and POST to the API
--- ---
@ -6,14 +7,57 @@
<html> <html>
<head> <head>
<title>Processing Authentication...</title> <title>Processing Authentication...</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--color-bg, #ffffff);
color: var(--color-text, #000000);
margin: 0;
padding: 0;
}
.container {
text-align: center;
padding: 4rem 2rem;
max-width: 500px;
margin: 0 auto;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error {
color: #e74c3c;
background: #fdf2f2;
padding: 1rem;
border-radius: 0.5rem;
border: 1px solid #e74c3c;
margin-top: 1rem;
}
</style>
</head> </head>
<body> <body>
<div style="text-align: center; padding: 4rem; font-family: sans-serif;"> <div class="container">
<div class="spinner"></div>
<h2>Processing authentication...</h2> <h2>Processing authentication...</h2>
<p>Please wait while we complete your login.</p> <p>Please wait while we complete your login.</p>
<div id="error-message" style="display: none;" class="error"></div>
</div> </div>
<script> <script>
(function() {
// Get URL parameters from client-side // Get URL parameters from client-side
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code'); const code = urlParams.get('code');
@ -22,8 +66,16 @@
console.log('Client-side callback params:', { code: !!code, state: !!state, error }); console.log('Client-side callback params:', { code: !!code, state: !!state, error });
const errorDiv = document.getElementById('error-message') as HTMLElement;
if (error) { if (error) {
if (errorDiv) {
errorDiv.textContent = `Authentication error: ${error}`;
errorDiv.style.display = 'block';
}
setTimeout(() => {
window.location.href = '/?auth=error'; window.location.href = '/?auth=error';
}, 3000);
} else if (code && state) { } else if (code && state) {
// Send the parameters to our API endpoint // Send the parameters to our API endpoint
fetch('/api/auth/process', { fetch('/api/auth/process', {
@ -33,22 +85,40 @@
}, },
body: JSON.stringify({ code, state }) body: JSON.stringify({ code, state })
}) })
.then(response => response.json()) .then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
})
.then(data => { .then(data => {
if (data.success) { if (data.success) {
window.location.href = data.redirectTo || '/'; window.location.href = data.redirectTo || '/';
} else { } else {
window.location.href = '/?auth=error'; throw new Error(data.error || 'Authentication failed');
} }
}) })
.catch(error => { .catch(error => {
console.error('Authentication processing failed:', error); console.error('Authentication processing failed:', error);
if (errorDiv) {
errorDiv.textContent = `Authentication failed: ${error.message}`;
errorDiv.style.display = 'block';
}
setTimeout(() => {
window.location.href = '/?auth=error'; window.location.href = '/?auth=error';
}, 3000);
}); });
} else { } else {
console.error('Missing code or state parameters'); console.error('Missing code or state parameters');
window.location.href = '/?auth=error'; if (errorDiv) {
errorDiv.textContent = 'Missing authentication parameters';
errorDiv.style.display = 'block';
} }
setTimeout(() => {
window.location.href = '/?auth=error';
}, 3000);
}
})();
</script> </script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,253 @@
---
// src/pages/contribute/index.astro - Consolidated Auth
import BaseLayout from '../../layouts/BaseLayout.astro';
import { withAuth } from '../../utils/auth.js'; // Note: .js extension!
export const prerender = false;
// CONSOLIDATED: Replace 15+ lines with single function call
const authResult = await withAuth(Astro, 'contributions');
if (authResult instanceof Response) {
return authResult; // Redirect to login
}
const { authenticated, userEmail, userId } = authResult;
---
<BaseLayout title="Contribute" description="Inhalte zum ForensicPathways beitragen">
<section style="padding: 2rem 0;">
<!-- Header -->
<div style="text-align: center; margin-bottom: 3rem; padding: 2rem; background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%); color: white; border-radius: 1rem; border: 1px solid var(--color-border);">
<h1 style="margin-bottom: 1rem; font-size: 2.5rem;">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.75rem; vertical-align: middle;">
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="8.5" cy="7" r="4"/>
<line x1="20" y1="8" x2="20" y2="14"/>
<line x1="23" y1="11" x2="17" y2="11"/>
</svg>
Zum ForensicPathways beitragen
</h1>
<p style="margin: 0; opacity: 0.9; line-height: 1.6; font-size: 1.125rem;">
Habt ihr Ideen/Ergänzungen zu den dargestellten Tools/Methoden/Konzepten? Oder habt ihr einen umfangreicheren Eintrag für unsere Knowledgebase?
Hier habt ihr die Möglichkeit, direkt beizutragen!
</p>
{userEmail && (
<p style="margin-top: 1rem; opacity: 0.8; font-size: 0.9rem;">
Angemeldet als: <strong>{userEmail}</strong>
</p>
)}
</div>
<!-- Contribution Options -->
<!-- WRAPPER -->
<div
style="
display:grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap:2rem;
align-items:stretch;
margin-bottom: 2rem;
"
>
<!-- src/pages/contribute/index.astro - Replace the Tools/Methods/Concepts card -->
<!-- Tools, Methods & Concepts - IMPROVED UX -->
<div class="card"
style="padding: 2rem; border-left: 4px solid var(--color-primary); transition: var(--transition-fast);
display:flex; flex-direction:column;">
<div style="display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem;">
<div style="width: 48px; height: 48px; background-color: var(--color-primary); border-radius: 0.5rem; display: flex; align-items: center; justify-content: center;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
</div>
<h3 style="margin: 0; color: var(--color-primary); font-size: 1.25rem;">Software, Methoden oder Konzepte</h3>
</div>
<p style="margin-bottom: 1.5rem; line-height: 1.6;">
Ergänzt Software/Tools, forensische Methoden und relevante Konzepte zu unserer Datenbank.
Füllt einfach ein kurzes Formular aus!
</p>
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1.5rem;">
<span class="badge" style="background-color: var(--color-primary); color: white;">Software/Tools</span>
<span class="badge" style="background-color: var(--color-method); color: white;">Methoden</span>
<span class="badge" style="background-color: var(--color-concept); color: white;">Konzepte</span>
</div>
<div style="margin-top:auto; display:flex; flex-direction: column; gap:1rem;">
<a href="/contribute/tool" class="btn btn-primary" style="width: 100%;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Neuer Eintrag
</a>
<!-- IMPROVED: Clear guidance instead of confusing button -->
<div style="background-color: var(--color-bg-secondary); padding: 1.25rem; border-radius: 0.5rem; border-left: 3px solid var(--color-accent);">
<div style="display: flex; align-items: start; gap: 0.75rem;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="2" style="flex-shrink: 0; margin-top: 0.125rem;">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
<div>
<h4 style="margin: 0 0 0.5rem 0; color: var(--color-accent); font-size: 0.9375rem;">Existierenden Eintrag bearbeiten</h4>
<p style="margin: 0; font-size: 0.875rem; line-height: 1.5; color: var(--color-text-secondary);">
Suchen Sie das Tool/Methode/Konzept auf der <a href="/" style="color: var(--color-primary); text-decoration: underline;">Hauptseite</a>,
öffnen Sie die Details und klicken Sie den <strong style="color: var(--color-text);">Edit</strong>-Button.
</p>
<div style="margin-top: 0.75rem;">
<a href="/" class="btn btn-secondary" style="font-size: 0.8125rem; padding: 0.5rem 1rem;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.375rem;">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9,22 9,12 15,12 15,22"/>
</svg>
Zur Hauptseite
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Knowledgebase Articles -->
<div class="card"
style="padding: 2rem; border-left: 4px solid var(--color-accent); cursor: pointer; transition: var(--transition-fast);
display:flex; flex-direction:column;"
onclick="window.location.href='/contribute/knowledgebase'">
<div style="display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem;">
<div style="width: 48px; height: 48px; background-color: var(--color-accent); border-radius: 0.5rem; display: flex; align-items: center; justify-content: center;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
</div>
<h3 style="margin: 0; color: var(--color-accent); font-size: 1.25rem;">Knowledgebase-Artikel</h3>
</div>
<p style="margin-bottom: 1.5rem; line-height: 1.6;">
Wenn ihr einen umfangreicheren Beitrag zu einem Tool, einer Methode oder einem Kozept habt, könnt ihr ihn hier einreichen.
Der Upload von beliebigen Dateien und Unterlagen ist hier möglich, und wird manuell von mir integriert.
</p>
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1.5rem;">
<span class="badge badge-secondary">Installationsanleitungen</span>
<span class="badge badge-secondary">Tutorials</span>
<span class="badge badge-secondary">Best Practices</span>
<span class="badge badge-secondary">Fallstudien</span>
</div>
<div style="margin-top:auto; display:flex; gap:1rem;">
<a href="/contribute/knowledgebase" class="btn btn-accent" style="flex: 1;">Beitrag einreichen</a>
<a href="/knowledgebase" class="btn btn-secondary" style="flex: 1;">Beiträge ansehen</a>
</div>
</div>
<!-- Issues & Improvements -->
<div class="card"
style="padding: 2rem; border-left: 4px solid var(--color-accent); cursor: pointer; transition: var(--transition-fast);
display:flex; flex-direction:column;">
<div style="display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem;">
<div style="width: 48px; height: 48px; background-color: var(--color-warning); border-radius: 0.5rem; display: flex; align-items: center; justify-content: center;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
</div>
<h3 style="margin: 0; color: var(--color-warning); font-size: 1.25rem;">Probleme & Verbesserungen</h3>
</div>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 2rem; align-items: center;">
<div style="display:flex; flex-direction:column;">
<p style="margin-bottom: 1rem; line-height: 1.6;">
Ist euch ein Bug oder eine fehlerhafte Information aufgefallen? Auch wenn es nur Kleinigkeiten sind - hier könnt ihr sie einreichen.
Erstellt direkt einen Issue in unserem Git.
</p>
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
<span class="badge" style="background-color: var(--color-warning); color: white;">Bug Reports</span>
<span class="badge" style="background-color: var(--color-warning); color: white;">Korrekturen</span>
<span class="badge" style="background-color: var(--color-warning); color: white;">Vorschläge</span>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 1rem;">
<a href="https://git.cc24.dev/mstoeck3/cc24-hub/issues/new" target="_blank" rel="noopener noreferrer" class="btn" style="background-color: var(--color-warning); color: white; border-color: var(--color-warning);">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
Problem melden
</a>
</div>
</div>
<!-- Push this actions block down if you add more later -->
<div style="margin-top:auto;"></div>
</div>
</div>
<!-- Guidelines -->
<div class="card" style="margin-bottom: 2rem;">
<h3 style="margin-bottom: 1.5rem; color: var(--color-text);">Richtlinien</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem;">
<div>
<h4 style="margin-bottom: 0.75rem; color: var(--color-primary);">Empfehlungen</h4>
<ul style="margin: 0; padding-left: 1.5rem; line-height: 1.6;">
<li>Informationen sollten stets korrekt und up-to-date sein</li>
<li>Nutzt klare, verständliche Sprache</li>
<li>Nutzt passende Tags und Kategorisierungen</li>
<li>Verifiziert, ob alle Links funktionieren</li>
<li>Testet die Tools/Methoden oder Installationsanleitungen nach Möglichkeit vorher aus</li>
<li>Stellt auf keinen Fall Informationen ein, die nicht öffentlich sein dürfen. Alles wird unter BSD-3-Clause-Veröffentlicht.</li>
</ul>
</div>
<div>
<h4 style="margin-bottom: 0.75rem; color: var(--color-accent);">Review</h4>
<ul style="margin: 0; padding-left: 1.5rem; line-height: 1.6;">
<li>Alle Beiträge werden transparent als Pull Requests in unserem Git veröffentlicht</li>
<li>Die Inforamtionen werden teilweise automatisiert validiert</li>
<li>Manuelle Prüfung innerhalb des Git-Review-Prozesses durch Maintainer</li>
<li>Feedback durch PR-Kommentare, ggf. auch direkt</li>
<li>Der PR wird dann zeitnah veröffentlicht und ist beim nächsten Serverupdate verfügbar</li>
</ul>
</div>
<div>
<h4 style="margin-bottom: 0.75rem; color: var(--color-warning);">Best Practices</h4>
<ul style="margin: 0; padding-left: 1.5rem; line-height: 1.6;">
<li>Vermeidet Duplokate</li>
<li>Versucht, konsistent bei der Benennung, Kategorisierung und Tags zu sein</li>
<li>Schreibt detaillierte Beschreibungen</li>
<li>Inkludiert Screenshots bei komplizierten Guides</li>
<li>Nennt eure Primärquellen</li>
</ul>
</div>
</div>
</div>
<script>
// Add hover effects for cards
document.querySelectorAll('.card[onclick]').forEach((card) => {
const cardEl = card as HTMLElement;
cardEl.addEventListener('mouseenter', function() {
this.style.transform = 'translateY(-2px)';
this.style.boxShadow = '0 8px 32px rgba(0, 0, 0, 0.12)';
});
cardEl.addEventListener('mouseleave', function() {
this.style.transform = 'translateY(0)';
this.style.boxShadow = '';
});
});
</script>
</BaseLayout>

View File

@ -0,0 +1,481 @@
---
// src/pages/contribute/knowledgebase.astro - SIMPLIFIED: Issues only, minimal validation
import BaseLayout from '../../layouts/BaseLayout.astro';
import { withAuth } from '../../utils/auth.js';
import { getToolsData } from '../../utils/dataService.js';
export const prerender = false;
// Check authentication
const authResult = await withAuth(Astro, 'contributions');
if (authResult instanceof Response) {
return authResult;
}
const { authenticated, userEmail, userId } = authResult;
// Load tools for reference (optional dropdown)
const data = await getToolsData();
const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.name));
---
<BaseLayout title="Contribute Knowledge Base Article">
<div class="container" style="max-width: 900px; margin: 0 auto; padding: 2rem 1rem;">
<!-- Header -->
<div style="text-align: center; margin-bottom: 2rem; padding: 2rem; background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%); color: white; border-radius: 1rem;">
<h1 style="margin-bottom: 1rem; font-size: 2rem;">Knowledgebase-Artikel</h1>
<p style="margin: 0.5rem 0; opacity: 0.9;">Danke für deinen Beitrag!</p>
{userEmail && <p style="margin: 0.5rem 0; opacity: 0.8;"><strong>Eingeloggt als:</strong> {userEmail}</p>}
</div>
<!-- Main Form -->
<div class="card">
<form id="kb-form" novalidate>
<!-- Basic Information -->
<div class="form-section">
<h3 class="section-title">Grundinformationen</h3>
<div class="form-grid-2">
<div class="form-group">
<label for="tool-name" class="form-label">Zusammenhang zu Tool / Methode / Konzept (Optional)</label>
<select id="tool-name" name="toolName" class="form-input">
<option value="">Auswählen...</option>
{sortedTools.map(tool => (
<option value={tool.name}>{tool.name} ({tool.type})</option>
))}
</select>
</div>
<div class="form-group">
<label for="difficulty" class="form-label">Schwierigkeitsniveau (Optional)</label>
<select id="difficulty" name="difficulty" class="form-input">
<option value="">Niveau wählen...</option>
<option value="novice">Novice</option>
<option value="beginner">Beginner</option>
<option value="intermediate">Intermediate</option>
<option value="advanced">Advanced</option>
<option value="expert">Expert</option>
</select>
</div>
</div>
<br>
<div class="form-group">
<label for="title" class="form-label">Titel des Artikels (Optional)</label>
<input
type="text"
id="title"
name="title"
maxlength="100"
placeholder="Klarer, deskriptiver Titel"
class="form-input"
/>
</div>
<div class="form-group">
<label for="description" class="form-label">Kurzbeschreibung (Optional)</label>
<textarea
id="description"
name="description"
maxlength="300"
rows="3"
placeholder="Kurze Zusammenfassung, worum es in dem Artikel geht"
class="form-input"
></textarea>
</div>
</div>
<!-- Content -->
<div class="form-section">
<h3 class="section-title">Inhalt</h3>
<div class="form-group">
<label for="content" class="form-label">Inhalt (Optional)</label>
<textarea
id="content"
name="content"
rows="8"
placeholder="Schreibt hier so viel Text, wie ihr wollt. Die Formatierung wird später redaktionell angepasst."
class="form-input"
></textarea>
</div>
<div class="form-group">
<label for="external-link" class="form-label">Link (z.B. Primärquelle) (Optional)</label>
<input
type="url"
id="external-link"
name="externalLink"
placeholder="https://example.com/documentation"
class="form-input"
/>
</div>
</div>
<!-- File Upload -->
<div class="form-section">
<h3 class="section-title">Dateien hochladen</h3>
<div class="form-group">
<label class="form-label">Dokumente, Bilder, Videos (Optional)</label>
<div class="upload-area" id="upload-area">
<input type="file" id="file-input" multiple accept=".pdf,.doc,.docx,.txt,.md,.zip,.png,.jpg,.jpeg,.gif,.mp4,.webm" style="display: none;">
<div class="upload-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<p>Klicken, um Dateien auszuwählen oder Drag&Drop</p>
<small>Die Dateien landen in der CC24-Cloud. Keine Malware.</small>
</div>
</div>
<div id="file-list" class="file-list" style="display: none;">
<h5>Ausgewählte Dateien</h5>
<div id="files-container"></div>
</div>
</div>
</div>
<!-- Additional Information -->
<div class="form-section">
<h3 class="section-title">Zusatzinformation</h3>
<div class="form-grid-2">
<div class="form-group">
<label for="categories" class="form-label">Kategorien (Optional)</label>
<input
type="text"
id="categories"
name="categories"
placeholder="setup, configuration, troubleshooting"
class="form-input"
/>
<small class="form-help">Komma-getrennte Kategorien</small>
</div>
<div class="form-group">
<label for="tags" class="form-label">Tags (Optional)</label>
<input
type="text"
id="tags"
name="tags"
placeholder="installation, docker, linux, windows"
class="form-input"
/>
<small class="form-help">Komma-getrennte Tags</small>
</div>
</div>
<div class="form-group">
<label for="reason" class="form-label">Grund für den Beitrag (Optional)</label>
<textarea
id="reason"
name="reason"
rows="3"
placeholder="Möchtest du sonst noch etwas mitteilen? Welches Problem wurde für dich gelöst??"
class="form-input"
></textarea>
</div>
</div>
<!-- Submit Button -->
<div class="form-actions">
<a href="/" class="btn btn-secondary">Abbruch</a>
<button type="submit" id="submit-btn" class="btn btn-accent">
<span id="submit-text">Abschicken</span>
<span id="submit-spinner" style="display: none;">⏳</span>
</button>
</div>
</form>
</div>
<!-- Success Modal -->
<div id="success-modal"
style="display:none; position:fixed; top:0; left:0; width:100%; height:100%;
background:rgba(0,0,0,.5); z-index:1000; align-items:center; justify-content:center;">
<div class="card" style="max-width:500px; width:90%; margin:2rem; text-align:center;">
<div style="font-size:3rem; margin-bottom:1rem;">✅</div>
<h3 style="margin-bottom:1rem;">Article Submitted!</h3>
<p id="success-message" style="margin-bottom:1.5rem;">
Your knowledgebase article has been submitted as an issue for review by maintainers.
</p>
<div style="display:flex; gap:1rem; justify-content:center;">
<a id="issue-link" href="#" target="_blank" class="btn btn-primary" style="display:none;">View Issue</a>
<a href="/" class="btn btn-secondary">Back to Home</a>
</div>
</div>
</div>
<!-- Message Container -->
<div id="message-container" class="message-container"></div>
</div>
</BaseLayout>
<script>
interface UploadedFile {
id: string;
file: File;
name: string;
uploaded: boolean;
url?: string;
}
declare global {
interface Window {
removeFile: (fileId: string) => void;
}
}
class KnowledgebaseForm {
private uploadedFiles: UploadedFile[] = [];
private isSubmitting = false;
private elements: Record<string, HTMLElement | null> = {};
constructor() {
this.init();
}
private init() {
// Get elements
this.elements = {
form: document.getElementById('kb-form'),
submitBtn: document.getElementById('submit-btn'),
submitText: document.getElementById('submit-text'),
submitSpinner: document.getElementById('submit-spinner'),
fileInput: document.getElementById('file-input'),
uploadArea: document.getElementById('upload-area'),
fileList: document.getElementById('file-list'),
filesContainer: document.getElementById('files-container'),
successModal: document.getElementById('success-modal')
};
if (!this.elements.form || !this.elements.submitBtn) {
console.error('[KB FORM] Critical elements missing');
return;
}
this.setupEventListeners();
this.setupFileUpload();
}
private setupEventListeners() {
// Form submission
this.elements.form?.addEventListener('submit', (e) => {
e.preventDefault();
if (!this.isSubmitting) {
this.handleSubmit();
}
});
}
private setupFileUpload() {
if (!this.elements.fileInput || !this.elements.uploadArea) return;
this.elements.uploadArea.addEventListener('click', () => {
(this.elements.fileInput as HTMLInputElement)?.click();
});
this.elements.uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
this.elements.uploadArea?.classList.add('drag-over');
});
this.elements.uploadArea.addEventListener('dragleave', () => {
this.elements.uploadArea?.classList.remove('drag-over');
});
this.elements.uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
this.elements.uploadArea?.classList.remove('drag-over');
if (e.dataTransfer?.files) {
this.handleFiles(Array.from(e.dataTransfer.files));
}
});
this.elements.fileInput.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement;
if (target?.files) {
this.handleFiles(Array.from(target.files));
}
});
}
private handleFiles(files: File[]) {
files.forEach(file => {
const fileId = Date.now() + '-' + Math.random().toString(36).substr(2, 9);
const newFile: UploadedFile = {
id: fileId,
file,
name: file.name,
uploaded: false
};
this.uploadedFiles.push(newFile);
this.uploadFile(fileId);
});
this.renderFileList();
}
private async uploadFile(fileId: string) {
const fileItem = this.uploadedFiles.find(f => f.id === fileId);
if (!fileItem) return;
const formData = new FormData();
formData.append('file', fileItem.file);
formData.append('type', 'knowledgebase');
try {
const response = await fetch('/api/upload/media', {
method: 'POST',
body: formData
});
if (response.ok) {
const result = await response.json();
fileItem.uploaded = true;
fileItem.url = result.url;
this.renderFileList();
} else {
throw new Error('Upload failed');
}
} catch (error) {
this.showMessage('error', `Failed to upload ${fileItem.name}`);
this.removeFile(fileId);
}
}
private removeFile(fileId: string) {
this.uploadedFiles = this.uploadedFiles.filter(f => f.id !== fileId);
this.renderFileList();
}
private renderFileList() {
if (!this.elements.filesContainer || !this.elements.fileList) return;
if (this.uploadedFiles.length > 0) {
(this.elements.fileList as HTMLElement).style.display = 'block';
(this.elements.filesContainer as HTMLElement).innerHTML = this.uploadedFiles.map(file => `
<div class="file-item">
<div class="file-info">
<strong>${file.name}</strong>
<div class="file-meta">
${(file.file.size / 1024 / 1024).toFixed(2)} MB
${file.uploaded ?
'<span class="file-status success">✓ Uploaded</span>' :
'<span class="file-status pending">⏳ Uploading...</span>'
}
</div>
</div>
<button type="button" onclick="window.removeFile('${file.id}')" class="btn btn-danger btn-small">Remove</button>
</div>
`).join('');
} else {
(this.elements.fileList as HTMLElement).style.display = 'none';
}
}
private async handleSubmit() {
if (this.isSubmitting) return;
this.isSubmitting = true;
// Update UI
(this.elements.submitBtn as HTMLButtonElement).disabled = true;
(this.elements.submitText as HTMLElement).textContent = 'Submitting...';
(this.elements.submitSpinner as HTMLElement).style.display = 'inline';
try {
const formData = new FormData(this.elements.form as HTMLFormElement);
// Process categories and tags
const categoriesValue = (formData.get('categories') as string) || '';
const tagsValue = (formData.get('tags') as string) || '';
const categories = categoriesValue.split(',').map(s => s.trim()).filter(s => s);
const tags = tagsValue.split(',').map(s => s.trim()).filter(s => s);
formData.set('categories', JSON.stringify(categories));
formData.set('tags', JSON.stringify(tags));
// Add uploaded files
formData.set('uploadedFiles', JSON.stringify(this.uploadedFiles.filter(f => f.uploaded)));
const response = await fetch('/api/contribute/knowledgebase', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
this.showSuccess(result);
} else {
throw new Error(result.error || 'Submission failed');
}
} catch (error) {
console.error('[KB FORM] Submission error:', error);
this.showMessage('error', 'Submission failed. Please try again.');
} finally {
this.isSubmitting = false;
(this.elements.submitBtn as HTMLButtonElement).disabled = false;
(this.elements.submitText as HTMLElement).textContent = 'Submit Article';
(this.elements.submitSpinner as HTMLElement).style.display = 'none';
}
}
private showSuccess(result: any) {
const successMessage = document.getElementById('success-message');
const issueLink = document.getElementById('issue-link') as HTMLAnchorElement;
if (successMessage) {
successMessage.textContent = 'Your knowledge base article has been submitted as an issue for review by maintainers.';
}
if (result.issueUrl && issueLink) {
issueLink.href = result.issueUrl;
issueLink.style.display = 'inline-flex';
}
(this.elements.successModal as HTMLElement).style.display = 'flex';
// Reset form
(this.elements.form as HTMLFormElement).reset();
this.uploadedFiles = [];
this.renderFileList();
}
private showMessage(type: 'success' | 'error' | 'warning', message: string) {
const container = document.getElementById('message-container');
if (!container) return;
const messageEl = document.createElement('div');
messageEl.className = `message message-${type}`;
messageEl.textContent = message;
container.appendChild(messageEl);
setTimeout(() => messageEl.remove(), 5000);
}
// Public method for file removal
public removeFileById(fileId: string) {
this.removeFile(fileId);
}
}
// Global instance
let formInstance: KnowledgebaseForm;
// Global function for file removal
window.removeFile = (fileId: string) => {
if (formInstance) {
formInstance.removeFileById(fileId);
}
};
// Initialize form
document.addEventListener('DOMContentLoaded', () => {
formInstance = new KnowledgebaseForm();
});
</script>

View File

@ -0,0 +1,751 @@
---
// src/pages/contribute/tool.astro - COMPLETE REWRITE
import BaseLayout from '../../layouts/BaseLayout.astro';
import { withAuth } from '../../utils/auth.js';
import { getToolsData } from '../../utils/dataService.js';
export const prerender = false;
// Check authentication
const authResult = await withAuth(Astro, 'contributions');
if (authResult instanceof Response) {
return authResult;
}
const { authenticated, userEmail, userId } = authResult;
// Load existing data
const data = await getToolsData();
const domains = data.domains;
const phases = data.phases;
const domainAgnosticSoftware = data['domain-agnostic-software'] || [];
const existingTools = data.tools;
// Check if this is an edit operation
const editToolName = Astro.url.searchParams.get('edit');
const editTool = editToolName ? existingTools.find(tool => tool.name === editToolName) : null;
const isEdit = !!editTool;
---
<BaseLayout title={isEdit ? `Edit ${editTool?.name}` : 'Contribute Tool'}>
<div class="container" style="max-width: 900px; margin: 0 auto; padding: 2rem 1rem;">
<!-- Header -->
<div style="text-align: center; margin-bottom: 2rem; padding: 2rem; background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%); color: white; border-radius: 1rem;">
<h1 style="margin-bottom: 1rem; font-size: 2rem;">{isEdit ? `Edit: ${editTool?.name}` : 'Tool / Methode / Konzept beitragen'}</h1>
<p style="margin: 0.5rem 0; opacity: 0.9;">
{isEdit
? 'Passt die Informationen für dieses Tool/Methode/Konzept an. Dein Beitrag wird als Git-Issue veröffentlicht.'
: 'Füge ein neues Tool, Methode oder Konzept in unsere Datenbank hinzu. Dein Beitrag wird als Git-Issue veröffentlicht.'
}
</p>
{userEmail && <p style="margin: 0.5rem 0; opacity: 0.8;"><strong>Eingeloggt als:</strong> {userEmail}</p>}
</div>
<!-- Validation Error Display -->
<div id="validation-errors" class="card" style="display: none; background-color: var(--color-error); color: white; margin-bottom: 2rem;">
<h3 style="margin: 0 0 1rem 0;">⚠️ Please fix the following issues:</h3>
<ul id="error-list" style="margin: 0; padding-left: 1.5rem;"></ul>
</div>
<!-- Main Form -->
<div class="card">
<form id="contribution-form" novalidate style="padding: 2rem;">
<!-- Basic Information -->
<div style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem;">
<h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Grundlegende Infos</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.5rem;">
<div>
<label for="type" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">Typ <span style="color: var(--color-error);">*</span></label>
<select id="type" name="type" required>
<option value="">Auswählen...</option>
<option value="software" selected={editTool?.type === 'software'}>Software/Tool</option>
<option value="method" selected={editTool?.type === 'method'}>Methode/Prozess</option>
<option value="concept" selected={editTool?.type === 'concept'}>Konzept/Wissen</option>
</select>
</div>
<div>
<label for="skillLevel" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">Skill Level <span style="color: var(--color-error);">*</span></label>
<select id="skillLevel" name="skillLevel" required>
<option value="">Auswählen...</option>
<option value="novice" selected={editTool?.skillLevel === 'novice'}>Novice</option>
<option value="beginner" selected={editTool?.skillLevel === 'beginner'}>Beginner</option>
<option value="intermediate" selected={editTool?.skillLevel === 'intermediate'}>Intermediate</option>
<option value="advanced" selected={editTool?.skillLevel === 'advanced'}>Advanced</option>
<option value="expert" selected={editTool?.skillLevel === 'expert'}>Expert</option>
</select>
</div>
</div>
<div style="margin-bottom: 1.5rem;">
<label for="name" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">Name <span style="color: var(--color-error);">*</span></label>
<input type="text" id="name" name="name" value={editTool?.name || ''}
placeholder="Tool/Method/Concept name" maxlength="100" required />
</div>
<div style="margin-bottom: 1.5rem;">
<label for="icon" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">Icon (Emoji)</label>
<input type="text" id="icon" name="icon" value={editTool?.icon || ''}
placeholder="📦 (optional)" maxlength="10" />
</div>
<div style="margin-bottom: 1.5rem;">
<label for="description" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">Beschreibung <span style="color: var(--color-error);">*</span></label>
<textarea id="description" name="description" rows="4" maxlength="1000" required
placeholder="Klare, kurze Beschreibung, was dein Tool/Methode/Konzept tut, was sein Zweck ist und was es einzigartig macht.">{editTool?.description || ''}</textarea>
<div style="text-align: right; font-size: 0.75rem; color: var(--color-text-secondary); margin-top: 0.25rem;">
<span id="description-count">0</span>/1000
</div>
</div>
<div style="margin-bottom: 1.5rem;">
<label for="url" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">Primary URL <span style="color: var(--color-error);">*</span></label>
<input type="url" id="url" name="url" value={editTool?.url || ''}
placeholder="https://example.com" required />
<small style="display: block; margin-top: 0.25rem; color: var(--color-text-secondary); font-size: 0.8125rem;">Homepage, Dokumentation, oder Primärquelle</small>
</div>
</div>
<!-- Categories -->
<div style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem;">
<h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Kategorien</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem;">
<div>
<label style="display: block; margin-bottom: 0.75rem; font-weight: 600;">Forensische Domänen</label>
<div style="display: grid; gap: 0.5rem;">
{domains.map(domain => (
<label class="checkbox-wrapper">
<input type="checkbox" name="domains" value={domain.id}
checked={editTool?.domains?.includes(domain.id)} />
<span>{domain.name}</span>
</label>
))}
</div>
</div>
<div>
<label style="display: block; margin-bottom: 0.75rem; font-weight: 600;">Phasen der Ermittlung</label>
<div style="display: grid; gap: 0.5rem;">
{phases.map(phase => (
<label class="checkbox-wrapper">
<input type="checkbox" name="phases" value={phase.id}
checked={editTool?.phases?.includes(phase.id)} />
<span>{phase.name}</span>
</label>
))}
</div>
</div>
</div>
</div>
<!-- Software-Specific Fields -->
<div id="software-fields" style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem; display: none;">
<h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Software Details</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; margin-bottom: 1.5rem;">
<div>
<label style="display: block; margin-bottom: 0.75rem; font-weight: 600;">Betrieb auf: <span id="platforms-required" style="color: var(--color-error);">*</span></label>
<div style="display: grid; gap: 0.5rem;">
{['Windows', 'macOS', 'Linux', 'Web', 'Mobile', 'Cross-platform'].map(platform => (
<label class="checkbox-wrapper">
<input type="checkbox" name="platforms" value={platform}
checked={editTool?.platforms?.includes(platform)} />
<span>{platform}</span>
</label>
))}
</div>
</div>
<div>
<label for="license" style="display: block; margin-bottom: 0.75rem; font-weight: 600;">Lizenzmodell <span id="license-required" style="color: var(--color-error);">*</span></label>
<input type="text" id="license" name="license" value={editTool?.license || ''}
placeholder="MIT, Apache 2.0, GPL v3, Proprietary" list="license-options" />
<datalist id="license-options">
<option value="MIT" />
<option value="Apache 2.0" />
<option value="GPL v3" />
<option value="BSD-3-Clause" />
<option value="Proprietary" />
<option value="Open Source" />
</datalist>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem;">
<div>
<label for="accessType" style="display: block; margin-bottom: 0.75rem; font-weight: 600;">Zugriff über:</label>
<select id="accessType" name="accessType">
<option value="">Select access type...</option>
<option value="download" selected={editTool?.accessType === 'download'}>Download</option>
<option value="web" selected={editTool?.accessType === 'web'}>Web Application</option>
<option value="api" selected={editTool?.accessType === 'api'}>API</option>
<option value="cli" selected={editTool?.accessType === 'cli'}>Command Line</option>
<option value="service" selected={editTool?.accessType === 'service'}>Service</option>
</select>
</div>
<div>
<label style="display: block; margin-bottom: 0.75rem; font-weight: 600;">Domänenübergreifende Kategorien</label>
<div style="display: grid; gap: 0.5rem;">
{domainAgnosticSoftware.map(cat => (
<label class="checkbox-wrapper">
<input type="checkbox" name="domainAgnostic" value={cat.id}
checked={editTool?.['domain-agnostic-software']?.includes(cat.id)} />
<span>{cat.name}</span>
</label>
))}
</div>
</div>
</div>
</div>
<!-- Related Concepts -->
<div id="concepts-fields" style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem; display: none;">
<h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Konzepte im Zusammenhang</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.5rem;">
{existingTools.filter(tool => tool.type === 'concept').map(concept => (
<label class="checkbox-wrapper">
<input type="checkbox" name="relatedConcepts" value={concept.name}
checked={editTool?.related_concepts?.includes(concept.name)} />
<span>{concept.name}</span>
</label>
))}
</div>
</div>
<!-- Additional Information -->
<div style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem;">
<h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Zusatzinfos</h3>
<div style="margin-bottom: 1.5rem;">
<label for="tags" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">Tags</label>
<input type="text" id="tags" name="tags" value={editTool?.tags?.join(', ') || ''}
placeholder="Komma-getrennt: Passende Begriffe, nach denen ihr suchen würdet." />
</div>
<div style="margin-bottom: 1.5rem;">
<label class="checkbox-wrapper">
<input type="checkbox" id="knowledgebase" name="knowledgebase"
checked={editTool?.knowledgebase} />
<span>Der Beitrag soll später einen Knowledgebase-Artikel haben</span>
</label>
</div>
<div>
<label for="reason" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">Grund für den Beitrag (Optional)</label>
<textarea id="reason" name="reason" rows="3" maxlength="500"
placeholder="Hier kannst du noch deine Motivation und sonstige Infos beschreiben."></textarea>
<div style="text-align: right; font-size: 0.75rem; color: var(--color-text-secondary); margin-top: 0.25rem;">
<span id="reason-count">0</span>/500
</div>
</div>
</div>
<!-- YAML Preview -->
<div style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem;">
<h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Preview</h3>
<div style="border: 1px solid var(--color-border); border-radius: 0.375rem; overflow: hidden;">
<pre id="yaml-preview" style="background: var(--color-bg-secondary); color: var(--color-text); padding: 1rem; margin: 0; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.8125rem; line-height: 1.4; overflow-x: auto; max-height: 300px;"># YAML preview will appear here</pre>
</div>
</div>
<!-- Submit -->
<div style="display: flex; gap: 1rem; justify-content: flex-end; margin-top: 2rem; padding-top: 2rem; border-top: 1px solid var(--color-border);">
<a href="/" class="btn btn-secondary">Cancel</a>
<button type="submit" id="submit-btn" class="btn btn-primary">
<span id="submit-text">{isEdit ? 'Update Tool' : 'Submit Contribution'}</span>
<span id="submit-spinner" style="display: none; margin-left: 0.5rem;">⏳</span>
</button>
</div>
</form>
</div>
<!-- Success Modal -->
<div id="success-modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); z-index: 1000; align-items: center; justify-content: center;">
<div class="card" style="max-width: 500px; width: 90%; margin: 2rem; text-align: center;">
<div style="font-size: 3rem; margin-bottom: 1rem;">✅</div>
<h3 style="margin-bottom: 1rem;">Contribution Submitted!</h3>
<p id="success-message" style="margin-bottom: 1.5rem;">Your contribution has been submitted successfully.</p>
<div style="display: flex; gap: 1rem; justify-content: center;">
<a id="pr-link" href="#" target="_blank" class="btn btn-primary" style="display: none;">View Pull Request</a>
<a href="/" class="btn btn-secondary">Back to Home</a>
</div>
</div>
</div>
</div>
</BaseLayout>
<script define:vars={{ isEdit, editTool, domains, phases, domainAgnosticSoftware }}>
// FIXED: Prevent duplicate form submissions
console.log('[FORM] Script loaded, initializing...');
class ContributionForm {
constructor() {
this.isEdit = isEdit;
this.editTool = editTool;
this.elements = {};
this.isSubmitting = false; // NEW: Prevent concurrent submissions
this.init();
}
init() {
console.log('[FORM] Starting initialization...');
// Get all form elements
this.elements = {
form: document.getElementById('contribution-form'),
submitBtn: document.getElementById('submit-btn'),
submitText: document.getElementById('submit-text'),
submitSpinner: document.getElementById('submit-spinner'),
typeSelect: document.getElementById('type'),
nameInput: document.getElementById('name'),
descriptionTextarea: document.getElementById('description'),
reasonTextarea: document.getElementById('reason'),
skillLevelSelect: document.getElementById('skillLevel'),
urlInput: document.getElementById('url'),
yamlPreview: document.getElementById('yaml-preview'),
successModal: document.getElementById('success-modal'),
softwareFields: document.getElementById('software-fields'),
conceptsFields: document.getElementById('concepts-fields'),
descriptionCount: document.getElementById('description-count'),
reasonCount: document.getElementById('reason-count'),
validationErrors: document.getElementById('validation-errors'),
errorList: document.getElementById('error-list'),
platformsRequired: document.getElementById('platforms-required'),
licenseRequired: document.getElementById('license-required'),
licenseInput: document.getElementById('license')
};
// Verify critical elements
if (!this.elements.form || !this.elements.submitBtn) {
console.error('[FORM] Critical elements missing!');
return;
}
// FIXED: Check if already initialized
if (this.elements.form.hasAttribute('data-form-initialized')) {
console.log('[FORM] Form already initialized, skipping...');
return;
}
// Mark as initialized
this.elements.form.setAttribute('data-form-initialized', 'true');
console.log('[FORM] Setting up handlers...');
this.setupEventListeners();
this.updateFieldVisibility();
this.setupCharacterCounters();
this.updateYAMLPreview();
console.log('[FORM] Initialization complete!');
}
setupEventListeners() {
// Type change handler
this.elements.typeSelect.addEventListener('change', () => {
this.updateFieldVisibility();
this.updateYAMLPreview();
});
// Form input handlers
this.elements.form.addEventListener('input', () => {
this.debounce(() => this.updateYAMLPreview(), 300);
});
this.elements.form.addEventListener('change', () => {
this.updateYAMLPreview();
});
// FIXED: Single submit handler with double-submission prevention
this.elements.form.addEventListener('submit', (e) => {
e.preventDefault();
e.stopPropagation();
// Prevent double submission
if (this.isSubmitting) {
console.log('[FORM] Submission already in progress, ignoring...');
return;
}
this.handleSubmit();
});
console.log('[FORM] Event listeners attached');
}
updateFieldVisibility() {
const type = this.elements.typeSelect.value;
// Hide all conditional fields
this.elements.softwareFields.style.display = 'none';
this.elements.conceptsFields.style.display = 'none';
// Hide required indicators
if (this.elements.platformsRequired) this.elements.platformsRequired.style.display = 'none';
if (this.elements.licenseRequired) this.elements.licenseRequired.style.display = 'none';
// Show relevant fields based on type
if (type === 'software') {
this.elements.softwareFields.style.display = 'block';
this.elements.conceptsFields.style.display = 'block';
if (this.elements.platformsRequired) this.elements.platformsRequired.style.display = 'inline';
if (this.elements.licenseRequired) this.elements.licenseRequired.style.display = 'inline';
} else if (type === 'method') {
this.elements.conceptsFields.style.display = 'block';
}
console.log('[FORM] Field visibility updated for type:', type);
}
setupCharacterCounters() {
const counters = [
{ element: this.elements.descriptionTextarea, counter: this.elements.descriptionCount, max: 1000 },
{ element: this.elements.reasonTextarea, counter: this.elements.reasonCount, max: 500 }
];
counters.forEach(({ element, counter, max }) => {
if (element && counter) {
const updateCounter = () => {
const count = element.value.length;
counter.textContent = count;
counter.style.color = count > max * 0.9 ? 'var(--color-warning)' : 'var(--color-text-secondary)';
};
element.addEventListener('input', updateCounter);
updateCounter();
}
});
}
updateYAMLPreview() {
if (!this.elements.yamlPreview) return;
try {
const formData = new FormData(this.elements.form);
const tool = {
name: formData.get('name') || 'Tool Name',
type: formData.get('type') || 'software',
description: formData.get('description') || 'Tool description',
domains: formData.getAll('domains'),
phases: formData.getAll('phases'),
skillLevel: formData.get('skillLevel') || 'intermediate',
url: formData.get('url') || 'https://example.com'
};
// Add icon if provided
if (formData.get('icon')) {
tool.icon = formData.get('icon');
}
// Add software-specific fields
if (tool.type === 'software') {
tool.platforms = formData.getAll('platforms');
tool.license = formData.get('license') || 'Unknown';
if (formData.get('accessType')) {
tool.accessType = formData.get('accessType');
}
const domainAgnostic = formData.getAll('domainAgnostic');
if (domainAgnostic.length > 0) {
tool['domain-agnostic-software'] = domainAgnostic;
}
}
// Add optional fields
if (formData.has('knowledgebase')) {
tool.knowledgebase = true;
}
const tags = formData.get('tags');
if (tags) {
tool.tags = tags.split(',').map(t => t.trim()).filter(Boolean);
}
const relatedConcepts = formData.getAll('relatedConcepts');
if (relatedConcepts.length > 0) {
tool.related_concepts = relatedConcepts;
}
// Generate YAML
const yaml = this.generateYAML(tool);
this.elements.yamlPreview.textContent = yaml;
} catch (error) {
console.error('[FORM] YAML preview error:', error);
this.elements.yamlPreview.textContent = '# Error generating preview';
}
}
generateYAML(tool) {
const lines = [];
lines.push(`name: "${tool.name}"`);
if (tool.icon) lines.push(`icon: "${tool.icon}"`);
lines.push(`type: ${tool.type}`);
lines.push(`description: "${tool.description}"`);
lines.push(`domains: [${tool.domains.map(d => `"${d}"`).join(', ')}]`);
lines.push(`phases: [${tool.phases.map(p => `"${p}"`).join(', ')}]`);
lines.push(`skillLevel: ${tool.skillLevel}`);
lines.push(`url: "${tool.url}"`);
if (tool.platforms && tool.platforms.length > 0) {
lines.push(`platforms: [${tool.platforms.map(p => `"${p}"`).join(', ')}]`);
}
if (tool.license) lines.push(`license: "${tool.license}"`);
if (tool.accessType) lines.push(`accessType: ${tool.accessType}`);
if (tool['domain-agnostic-software']) {
lines.push(`domain-agnostic-software: [${tool['domain-agnostic-software'].map(c => `"${c}"`).join(', ')}]`);
}
if (tool.knowledgebase) lines.push(`knowledgebase: true`);
if (tool.tags && tool.tags.length > 0) {
lines.push(`tags: [${tool.tags.map(t => `"${t}"`).join(', ')}]`);
}
if (tool.related_concepts && tool.related_concepts.length > 0) {
lines.push(`related_concepts: [${tool.related_concepts.map(c => `"${c}"`).join(', ')}]`);
}
return lines.join('\n');
}
validateForm() {
const errors = [];
const formData = new FormData(this.elements.form);
// Required field validation
const name = formData.get('name')?.trim();
if (!name) {
errors.push('Tool name is required');
}
const description = formData.get('description')?.trim();
if (!description) {
errors.push('Description is required');
} else if (description.length < 10) {
errors.push('Description must be at least 10 characters long');
}
const skillLevel = formData.get('skillLevel');
if (!skillLevel) {
errors.push('Skill level is required');
}
const type = formData.get('type');
if (!type) {
errors.push('Type is required');
}
const url = formData.get('url')?.trim();
if (!url) {
errors.push('Primary URL is required');
} else {
try {
new URL(url);
} catch {
errors.push('Primary URL must be a valid URL');
}
}
// Software-specific validation
if (type === 'software') {
const platforms = formData.getAll('platforms');
if (platforms.length === 0) {
errors.push('At least one platform is required for software');
}
const license = formData.get('license')?.trim();
if (!license) {
errors.push('License is required for software');
}
}
return errors;
}
showValidationErrors(errors) {
if (errors.length === 0) {
this.elements.validationErrors.style.display = 'none';
return;
}
// Clear previous errors
this.elements.errorList.innerHTML = '';
// Add each error as list item
errors.forEach(error => {
const li = document.createElement('li');
li.textContent = error;
this.elements.errorList.appendChild(li);
});
// Show error container
this.elements.validationErrors.style.display = 'block';
// Scroll to top to show errors
this.elements.validationErrors.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
async handleSubmit() {
console.log('[FORM] Submit handler called!');
// FIXED: Immediate submission lock
if (this.isSubmitting) {
console.log('[FORM] Already submitting, aborting...');
return;
}
this.isSubmitting = true;
// Validate before submitting
const validationErrors = this.validateForm();
if (validationErrors.length > 0) {
console.log('[FORM] Validation failed:', validationErrors);
this.showValidationErrors(validationErrors);
this.isSubmitting = false; // Reset lock
return;
}
// Hide validation errors
this.elements.validationErrors.style.display = 'none';
// Immediate UI feedback
this.elements.submitBtn.disabled = true;
this.elements.submitText.textContent = this.isEdit ? 'Updating...' : 'Submitting...';
this.elements.submitSpinner.style.display = 'inline';
try {
const formData = new FormData(this.elements.form);
// Build submission object
const submission = {
action: this.isEdit ? 'edit' : 'add',
tool: {
name: formData.get('name'),
type: formData.get('type'),
description: formData.get('description'),
domains: formData.getAll('domains'),
phases: formData.getAll('phases'),
skillLevel: formData.get('skillLevel'),
url: formData.get('url'),
tags: formData.get('tags') ?
formData.get('tags').split(',').map(t => t.trim()).filter(Boolean) : []
},
metadata: {
reason: formData.get('reason') || ''
}
};
// Add optional fields
if (formData.get('icon')) submission.tool.icon = formData.get('icon');
if (formData.has('knowledgebase')) submission.tool.knowledgebase = true;
// Add software-specific fields
if (submission.tool.type === 'software') {
submission.tool.platforms = formData.getAll('platforms');
submission.tool.license = formData.get('license');
if (formData.get('accessType')) {
submission.tool.accessType = formData.get('accessType');
}
const domainAgnostic = formData.getAll('domainAgnostic');
if (domainAgnostic.length > 0) {
submission.tool['domain-agnostic-software'] = domainAgnostic;
}
}
// Add related concepts
if (submission.tool.type !== 'concept') {
const related = formData.getAll('relatedConcepts');
if (related.length > 0) {
submission.tool.related_concepts = related;
}
}
console.log('[FORM] Sending submission:', submission);
// Submit to API
const response = await fetch('/api/contribute/tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(submission)
});
console.log('[FORM] Response status:', response.status);
const result = await response.json();
console.log('[FORM] Response data:', result);
if (result.success) {
this.showSuccess(result);
} else {
throw new Error(result.error || 'Submission failed');
}
} catch (error) {
console.error('[FORM] Submission error:', error);
alert(`Submission failed: ${error.message}\n\nPlease try again or contact support if the problem persists.`);
} finally {
// FIXED: Always reset submission state
this.isSubmitting = false;
this.elements.submitBtn.disabled = false;
this.elements.submitText.textContent = this.isEdit ? 'Update Tool' : 'Submit Contribution';
this.elements.submitSpinner.style.display = 'none';
}
}
showSuccess(result) {
// Update success message
const successMessage = document.getElementById('success-message');
if (successMessage) {
successMessage.textContent = `Your ${this.isEdit ? 'update' : 'contribution'} has been submitted as an issue and will be reviewed by maintainers.`;
}
// Show issue link if available
if (result.issueUrl) {
const prLink = document.getElementById('pr-link');
if (prLink) {
prLink.href = result.issueUrl;
prLink.textContent = 'View Issue';
prLink.style.display = 'inline-flex';
}
}
// Show modal
this.elements.successModal.style.display = 'flex';
}
debounce(func, wait) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
}
// FIXED: Single initialization only
function initializeForm() {
const form = document.getElementById('contribution-form');
if (!form) {
console.error('[FORM] Form element not found!');
return;
}
if (form.hasAttribute('data-form-initialized')) {
console.log('[FORM] Form already initialized');
return;
}
console.log('[FORM] Initializing form...');
new ContributionForm();
}
// FIXED: Simple initialization
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeForm);
} else {
initializeForm();
}
console.log('[FORM] Script loaded successfully');
</script>

View File

@ -2,7 +2,7 @@
import BaseLayout from '../layouts/BaseLayout.astro'; import BaseLayout from '../layouts/BaseLayout.astro';
--- ---
<BaseLayout title="Impressum" description="CC24-Guide - Impressum"> <BaseLayout title="Impressum" description="ForensicPathways - Impressum">
<section style="padding: 2rem 0; max-width: 900px; margin: 0 auto;"> <section style="padding: 2rem 0; max-width: 900px; margin: 0 auto;">
<!-- Hero Section --> <!-- Hero Section -->
<div style="text-align: center; margin-bottom: 3rem; padding: 2rem; background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%); border-radius: 1rem; border: 1px solid var(--color-border);"> <div style="text-align: center; margin-bottom: 3rem; padding: 2rem; background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%); border-radius: 1rem; border: 1px solid var(--color-border);">

View File

@ -6,7 +6,6 @@ import ToolMatrix from '../components/ToolMatrix.astro';
import AIQueryInterface from '../components/AIQueryInterface.astro'; import AIQueryInterface from '../components/AIQueryInterface.astro';
import { getToolsData } from '../utils/dataService.js'; import { getToolsData } from '../utils/dataService.js';
// Load tools data // Load tools data
const data = await getToolsData(); const data = await getToolsData();
const tools = data.tools; const tools = data.tools;
@ -16,7 +15,7 @@ const tools = data.tools;
<!-- Hero Section --> <!-- Hero Section -->
<section style="padding: 2rem 0 1rem; border-bottom: 1px solid var(--color-border);"> <section style="padding: 2rem 0 1rem; border-bottom: 1px solid var(--color-border);">
<div style="text-align: center; margin-bottom: 2rem; padding: 2rem; background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%); border-radius: 1rem; border: 1px solid var(--color-border);"> <div style="text-align: center; margin-bottom: 2rem; padding: 2rem; background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%); border-radius: 1rem; border: 1px solid var(--color-border);">
<h1 style="margin-bottom: 1rem; font-size: 1.5rem; color: var(--color-primary);">CC24 // DFIR - Guide</h1> <h1 style="margin-bottom: 1rem; font-size: 1.5rem; color: var(--color-primary);">ForensicPathways</h1>
<p style="font-size: 1.25rem; margin-bottom: 1.125rem; color: var(--color-text);"> <p style="font-size: 1.25rem; margin-bottom: 1.125rem; color: var(--color-text);">
<strong>Das richtige Werkzeug zur richtigen Zeit</strong> in der digitalen Forensik entscheidet oft die Wahl der passenden Methode oder Software über Erfolg oder Misserfolg einer Untersuchung. <strong>Das richtige Werkzeug zur richtigen Zeit</strong> in der digitalen Forensik entscheidet oft die Wahl der passenden Methode oder Software über Erfolg oder Misserfolg einer Untersuchung.
@ -53,6 +52,17 @@ const tools = data.tools;
KI befragen KI befragen
</button> </button>
<!-- Contribution Button -->
<a href="/contribute" class="btn" style="padding: 0.75rem 1.5rem; background-color: var(--color-warning); color: white; border-color: var(--color-warning);" data-contribute-button="new">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="8.5" cy="7" r="4"/>
<line x1="20" y1="8" x2="20" y2="14"/>
<line x1="23" y1="11" x2="17" y2="11"/>
</svg>
Beitragen
</a>
<a href="#filters-section" class="btn btn-secondary" style="padding: 0.75rem 1.5rem;"> <a href="#filters-section" class="btn btn-secondary" style="padding: 0.75rem 1.5rem;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path> <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
@ -67,13 +77,12 @@ const tools = data.tools;
<!-- Filters Section --> <!-- Filters Section -->
<section id="filters-section" style="padding: 2rem 0;"> <section id="filters-section" style="padding: 2rem 0;">
<ToolFilters /> <ToolFilters data={data} />
</section> </section>
<!-- AI Query Interface --> <!-- AI Query Interface -->
<AIQueryInterface /> <AIQueryInterface />
<!-- Tools Grid --> <!-- Tools Grid -->
<section id="tools-grid" style="padding-bottom: 2rem;"> <section id="tools-grid" style="padding-bottom: 2rem;">
<div class="grid-auto-fit" id="tools-container"> <div class="grid-auto-fit" id="tools-container">
@ -89,35 +98,22 @@ const tools = data.tools;
</section> </section>
<!-- Matrix View --> <!-- Matrix View -->
<ToolMatrix /> <ToolMatrix data={data} />
</BaseLayout> </BaseLayout>
<script> <script define:vars={{ toolsData: data.tools }}>
// Extend Window interface for custom properties // Store tools data globally
declare global { window.toolsData = toolsData;
interface Window {
toolsData: any[];
showToolDetails: (toolName: string, modalType?: string) => void;
hideToolDetails: (modalType?: string) => void;
hideAllToolDetails: () => void;
clearAllFilters?: () => void;
restoreAIResults?: () => void;
switchToAIView?: () => void;
showShareDialog: (shareButton: HTMLElement) => void;
navigateToGrid: (toolName: string) => void;
navigateToMatrix: (toolName: string) => void;
}
}
// Handle view changes and filtering // Handle view changes and filtering
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const toolsContainer = document.getElementById('tools-container') as HTMLElement; const toolsContainer = document.getElementById('tools-container');
const toolsGrid = document.getElementById('tools-grid') as HTMLElement; const toolsGrid = document.getElementById('tools-grid');
const matrixContainer = document.getElementById('matrix-container') as HTMLElement; const matrixContainer = document.getElementById('matrix-container');
const aiInterface = document.getElementById('ai-interface') as HTMLElement; const aiInterface = document.getElementById('ai-interface');
const filtersSection = document.getElementById('filters-section') as HTMLElement; const filtersSection = document.getElementById('filters-section');
const noResults = document.getElementById('no-results') as HTMLElement; const noResults = document.getElementById('no-results');
const aiQueryBtn = document.getElementById('ai-query-btn') as HTMLButtonElement; const aiQueryBtn = document.getElementById('ai-query-btn');
// Guard against null elements // Guard against null elements
if (!toolsContainer || !toolsGrid || !matrixContainer || !noResults || !aiInterface || !filtersSection) { if (!toolsContainer || !toolsGrid || !matrixContainer || !noResults || !aiInterface || !filtersSection) {
@ -125,63 +121,20 @@ const tools = data.tools;
return; return;
} }
// Simple sorting function
function sortTools(tools: any[], sortBy = 'default') {
const sorted = [...tools];
switch (sortBy) {
case 'alphabetical':
return sorted.sort((a, b) => a.name.localeCompare(b.name));
case 'difficulty':
const difficultyOrder = { 'novice': 0, 'beginner': 1, 'intermediate': 2, 'advanced': 3, 'expert': 4 };
return sorted.sort((a, b) =>
(difficultyOrder[a.skillLevel as keyof typeof difficultyOrder] || 999) - (difficultyOrder[b.skillLevel as keyof typeof difficultyOrder] || 999)
);
case 'type':
const typeOrder = { 'concept': 0, 'method': 1, 'software': 2 };
return sorted.sort((a, b) =>
(typeOrder[a.type as keyof typeof typeOrder] || 999) - (typeOrder[b.type as keyof typeof typeOrder] || 999)
);
case 'default':
default:
return sorted;
}
}
// Authentication check function
async function checkAuthentication() {
try {
const response = await fetch('/api/auth/status');
const data = await response.json();
return {
authenticated: data.authenticated,
authRequired: data.authRequired
};
} catch (error) {
console.error('Auth check failed:', error);
return {
authenticated: false,
authRequired: true
};
}
}
// AI Query Button Handler
if (aiQueryBtn) { if (aiQueryBtn) {
aiQueryBtn.addEventListener('click', async () => { aiQueryBtn.addEventListener('click', async () => {
const authStatus = await checkAuthentication(); if (typeof window.requireClientAuth === 'function') {
// ENHANCED: Use AI-specific authentication
if (authStatus.authRequired && !authStatus.authenticated) { await window.requireClientAuth(() => switchToView('ai'), `${window.location.pathname}?view=ai`, 'ai');
const returnUrl = `${window.location.pathname}?view=ai`;
window.location.href = `/api/auth/login?returnTo=${encodeURIComponent(returnUrl)}`;
} else { } else {
console.warn('[AUTH] requireClientAuth not available');
switchToView('ai'); switchToView('ai');
} }
}); });
} }
// Function to switch between different views // Function to switch between different views
function switchToView(view: string) { function switchToView(view) {
// Hide all views first // Hide all views first
toolsGrid.style.display = 'none'; toolsGrid.style.display = 'none';
matrixContainer.style.display = 'none'; matrixContainer.style.display = 'none';
@ -203,7 +156,7 @@ const tools = data.tools;
if (window.restoreAIResults) { if (window.restoreAIResults) {
window.restoreAIResults(); window.restoreAIResults();
} }
const aiInput = document.getElementById('ai-query-input') as HTMLTextAreaElement; const aiInput = document.getElementById('ai-query-input');
if (aiInput) { if (aiInput) {
setTimeout(() => aiInput.focus(), 100); setTimeout(() => aiInput.focus(), 100);
} }
@ -237,19 +190,19 @@ const tools = data.tools;
]; ];
elements.forEach(selector => { elements.forEach(selector => {
const element = document.querySelector(selector) as HTMLElement; const element = document.querySelector(selector);
if (element) element.style.display = 'none'; if (element) element.style.display = 'none';
}); });
const allInputs = filtersSection.querySelectorAll('input, select, textarea'); const allInputs = filtersSection.querySelectorAll('input, select, textarea');
allInputs.forEach(input => (input as HTMLElement).style.display = 'none'); allInputs.forEach(input => input.style.display = 'none');
} }
function showFilterControls() { function showFilterControls() {
const domainPhaseContainer = document.querySelector('.domain-phase-container') as HTMLElement; const domainPhaseContainer = document.querySelector('.domain-phase-container');
const searchInput = document.getElementById('search-input') as HTMLElement; const searchInput = document.getElementById('search-input');
const tagCloud = document.querySelector('.tag-cloud') as HTMLElement; const tagCloud = document.querySelector('.tag-cloud');
const tagHeader = document.querySelector('.tag-header') as HTMLElement; const tagHeader = document.querySelector('.tag-header');
const checkboxWrappers = document.querySelectorAll('.checkbox-wrapper'); const checkboxWrappers = document.querySelectorAll('.checkbox-wrapper');
const allInputs = filtersSection.querySelectorAll('input, select, textarea'); const allInputs = filtersSection.querySelectorAll('input, select, textarea');
@ -258,29 +211,15 @@ const tools = data.tools;
if (tagCloud) tagCloud.style.display = 'flex'; if (tagCloud) tagCloud.style.display = 'flex';
if (tagHeader) tagHeader.style.display = 'flex'; if (tagHeader) tagHeader.style.display = 'flex';
allInputs.forEach(input => (input as HTMLElement).style.display = 'block'); allInputs.forEach(input => input.style.display = 'block');
checkboxWrappers.forEach(wrapper => (wrapper as HTMLElement).style.display = 'flex'); checkboxWrappers.forEach(wrapper => wrapper.style.display = 'flex');
} }
// Create tool slug from name // REMOVED: createToolSlug function - now using window.createToolSlug
function createToolSlug(toolName: string): string { // REMOVED: findTool function - now using window.findToolByIdentifier
return toolName.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
// Find tool by name or slug
function findTool(identifier: string) {
return window.toolsData.find(tool =>
tool.name === identifier ||
createToolSlug(tool.name) === identifier.toLowerCase()
);
}
// Navigation functions for sharing // Navigation functions for sharing
window.navigateToGrid = function(toolName: string) { window.navigateToGrid = function(toolName) {
console.log('Navigating to grid for tool:', toolName); console.log('Navigating to grid for tool:', toolName);
// Switch to grid view first // Switch to grid view first
@ -296,7 +235,7 @@ const tools = data.tools;
// Wait for filters to clear and re-render // Wait for filters to clear and re-render
setTimeout(() => { setTimeout(() => {
const toolCards = document.querySelectorAll('.tool-card'); const toolCards = document.querySelectorAll('.tool-card');
let targetCard: Element | null = null; let targetCard = null;
toolCards.forEach(card => { toolCards.forEach(card => {
const cardTitle = card.querySelector('h3'); const cardTitle = card.querySelector('h3');
@ -311,13 +250,12 @@ const tools = data.tools;
if (targetCard) { if (targetCard) {
console.log('Found target card, scrolling...'); console.log('Found target card, scrolling...');
// Cast to Element to fix TypeScript issue targetCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
(targetCard as Element).scrollIntoView({ behavior: 'smooth', block: 'center' }); targetCard.style.animation = 'highlight-flash 2s ease-out';
(targetCard as HTMLElement).style.animation = 'highlight-flash 2s ease-out';
setTimeout(() => { setTimeout(() => {
if (targetCard) { if (targetCard) {
(targetCard as HTMLElement).style.animation = ''; targetCard.style.animation = '';
} }
}, 2000); }, 2000);
} else { } else {
@ -327,7 +265,7 @@ const tools = data.tools;
}, 200); }, 200);
}; };
window.navigateToMatrix = function(toolName: string) { window.navigateToMatrix = function(toolName) {
console.log('Navigating to matrix for tool:', toolName); console.log('Navigating to matrix for tool:', toolName);
// Switch to matrix view // Switch to matrix view
@ -336,7 +274,7 @@ const tools = data.tools;
// Wait for view switch and matrix to render // Wait for view switch and matrix to render
setTimeout(() => { setTimeout(() => {
const toolChips = document.querySelectorAll('.tool-chip'); const toolChips = document.querySelectorAll('.tool-chip');
let firstMatch: Element | null = null; let firstMatch = null;
let matchCount = 0; let matchCount = 0;
toolChips.forEach(chip => { toolChips.forEach(chip => {
@ -344,7 +282,7 @@ const tools = data.tools;
const chipText = chip.textContent?.replace(/📖/g, '').replace(/[^\w\s\-\.]/g, '').trim(); const chipText = chip.textContent?.replace(/📖/g, '').replace(/[^\w\s\-\.]/g, '').trim();
if (chipText === toolName) { if (chipText === toolName) {
// Highlight this occurrence // Highlight this occurrence
(chip as HTMLElement).style.animation = 'highlight-flash 2s ease-out'; chip.style.animation = 'highlight-flash 2s ease-out';
matchCount++; matchCount++;
// Remember the first match for scrolling // Remember the first match for scrolling
@ -354,15 +292,14 @@ const tools = data.tools;
// Clean up animation after it completes // Clean up animation after it completes
setTimeout(() => { setTimeout(() => {
(chip as HTMLElement).style.animation = ''; chip.style.animation = '';
}, 8000); }, 8000);
} }
}); });
if (firstMatch) { if (firstMatch) {
console.log(`Found ${matchCount} occurrences of tool, highlighting all and scrolling to first`); console.log(`Found ${matchCount} occurrences of tool, highlighting all and scrolling to first`);
// Cast to Element to fix TypeScript issue firstMatch.scrollIntoView({ behavior: 'smooth', block: 'center' });
(firstMatch as Element).scrollIntoView({ behavior: 'smooth', block: 'center' });
} else { } else {
console.warn('Tool chip not found in matrix:', toolName); console.warn('Tool chip not found in matrix:', toolName);
} }
@ -384,8 +321,8 @@ const tools = data.tools;
return; return;
} }
// Find the tool by name or slug // Find the tool by name or slug using global function
const tool = findTool(toolParam); const tool = window.findToolByIdentifier(window.toolsData, toolParam);
if (!tool) { if (!tool) {
console.warn('Shared tool not found:', toolParam); console.warn('Shared tool not found:', toolParam);
return; return;
@ -417,160 +354,48 @@ const tools = data.tools;
}, 100); }, 100);
} }
// Handle filtered results // ENHANCED: New filtering logic using show/hide pattern
window.addEventListener('toolsFiltered', (event) => { window.addEventListener('toolsFiltered', (event) => {
const customEvent = event as CustomEvent; const filtered = event.detail;
const filtered = customEvent.detail;
const currentView = document.querySelector('.view-toggle.active')?.getAttribute('data-view'); const currentView = document.querySelector('.view-toggle.active')?.getAttribute('data-view');
if (currentView === 'matrix' || currentView === 'ai') { if (currentView === 'matrix' || currentView === 'ai') {
return; return;
} }
// Clear container // Get all existing tool cards
toolsContainer.innerHTML = ''; const allToolCards = document.querySelectorAll('.tool-card');
const filteredNames = new Set(filtered.map(tool => tool.name.toLowerCase()));
if (filtered.length === 0) { let visibleCount = 0;
allToolCards.forEach(card => {
const toolName = card.getAttribute('data-tool-name');
if (filteredNames.has(toolName)) {
card.style.display = 'block';
visibleCount++;
} else {
card.style.display = 'none';
}
});
// Show/hide no results message
if (visibleCount === 0) {
noResults.style.display = 'block'; noResults.style.display = 'block';
} else { } else {
noResults.style.display = 'none'; noResults.style.display = 'none';
const sortedTools = sortTools(filtered, 'default');
sortedTools.forEach((tool: any) => {
const toolCard = createToolCard(tool);
toolsContainer.appendChild(toolCard);
});
} }
}); });
// Handle view changes // Handle view changes
window.addEventListener('viewChanged', (event) => { window.addEventListener('viewChanged', (event) => {
const customEvent = event as CustomEvent; const view = event.detail;
const view = customEvent.detail;
switchToView(view); switchToView(view);
}); });
// Make switchToView available globally // Make switchToView available globally
window.switchToAIView = () => switchToView('ai'); window.switchToAIView = () => switchToView('ai');
// Tool card creation function
function createToolCard(tool: any): HTMLElement {
const isMethod = tool.type === 'method';
const isConcept = tool.type === 'concept';
const hasValidProjectUrl = tool.projectUrl !== undefined &&
tool.projectUrl !== null &&
tool.projectUrl !== "" &&
tool.projectUrl.trim() !== "";
const hasKnowledgebase = tool.knowledgebase === true;
const cardDiv = document.createElement('div');
const cardClass = isConcept ? 'card card-concept tool-card' :
isMethod ? 'card card-method tool-card' :
hasValidProjectUrl ? 'card card-hosted tool-card' :
(tool.license !== 'Proprietary' ? 'card card-oss tool-card' : 'card tool-card');
cardDiv.className = cardClass;
cardDiv.style.cursor = 'pointer';
cardDiv.onclick = () => window.showToolDetails(tool.name);
// Create tool slug for share button
const toolSlug = createToolSlug(tool.name);
cardDiv.innerHTML = `
<div class="tool-card-header">
<h3>${tool.icon ? `<span style="margin-right: 0.5rem; font-size: 1.125rem;">${tool.icon}</span>` : ''}${tool.name}</h3>
<div class="tool-card-badges">
${!isMethod && !isConcept && hasValidProjectUrl ? '<span class="badge badge-primary">CC24-Server</span>' : ''}
${hasKnowledgebase ? '<span class="badge badge-error">📖</span>' : ''}
<button class="share-btn share-btn--small"
data-tool-name="${tool.name}"
data-tool-slug="${toolSlug}"
data-context="card"
onclick="event.stopPropagation(); window.showShareDialog(this)"
title="${tool.name} teilen"
aria-label="${tool.name} teilen">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="18" cy="5" r="3"/>
<circle cx="6" cy="12" r="3"/>
<circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/>
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
</svg>
</button>
</div>
</div>
<p class="text-muted">
${tool.description}
</p>
<div class="tool-card-metadata" style="display: flex; align-items: center; gap: 1rem; margin-bottom: 0.75rem; line-height: 1;">
<div class="metadata-item" style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: var(--color-text-secondary); flex-shrink: 1; min-width: 0;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink: 0;">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="9" y1="9" x2="15" y2="9"></line>
<line x1="9" y1="15" x2="15" y2="15"></line>
</svg>
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;">
${(tool.platforms || []).slice(0, 2).join(', ')}${tool.platforms && tool.platforms.length > 2 ? '...' : ''}
</span>
</div>
<div class="metadata-item" style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: var(--color-text-secondary); flex-shrink: 1; min-width: 0;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink: 0;">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 6v6l4 2"></path>
</svg>
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;">
${tool.skillLevel}
</span>
</div>
<div class="metadata-item" style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: var(--color-text-secondary); flex-shrink: 1; min-width: 0;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink: 0;">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;">
${isConcept ? 'Konzept' : isMethod ? 'Methode' : tool.license === 'Proprietary' ? 'Prop.' : tool.license?.split(' ')[0] || 'N/A'}
</span>
</div>
</div>
<div class="tool-tags-container">
${(tool.tags || []).slice(0, 8).map((tag: string) => `<span class="tag">${tag}</span>`).join('')}
</div>
<div class="tool-card-buttons" onclick="event.stopPropagation();">
${isConcept ? `
<a href="${tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-primary single-button" style="background-color: var(--color-concept); border-color: var(--color-concept);">
Mehr erfahren
</a>
` : isMethod ? `
<a href="${tool.projectUrl || tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-primary single-button" style="background-color: var(--color-method); border-color: var(--color-method);">
Zur Methode
</a>
` : hasValidProjectUrl ? `
<div class="button-row">
<a href="${tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-secondary">
Homepage
</a>
<a href="${tool.projectUrl}" target="_blank" rel="noopener noreferrer" class="btn btn-primary">
Zugreifen
</a>
</div>
` : `
<a href="${tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-primary single-button">
Software-Homepage
</a>
`}
</div>
`;
return cardDiv;
}
// Initialize URL handling // Initialize URL handling
handleSharedURL(); handleSharedURL();
}); });

View File

@ -1,15 +1,51 @@
--- ---
import BaseLayout from '../layouts/BaseLayout.astro'; import BaseLayout from '../layouts/BaseLayout.astro';
import { getCollection } from 'astro:content';
import { getToolsData } from '../utils/dataService.js'; import { getToolsData } from '../utils/dataService.js';
import ContributionButton from '../components/ContributionButton.astro';
// Load tools data // Load tools data and knowledgebase articles
const data = await getToolsData(); const data = await getToolsData();
const allKnowledgebaseEntries = await getCollection('knowledgebase', (entry) => {
// Only include published articles
return entry.data.published !== false;
});
// Filter tools that have knowledgebase flag set to true // Create unified knowledgebase entries with optional tool association
const knowledgebaseTools = data.tools.filter((tool: any) => tool.knowledgebase === true); const knowledgebaseEntries = allKnowledgebaseEntries.map((entry) => {
const associatedTool = entry.data.tool_name
? data.tools.find((tool: any) => tool.name === entry.data.tool_name)
: null;
// Sort alphabetically by name return {
knowledgebaseTools.sort((a: any, b: any) => a.name.localeCompare(b.name)); // Article metadata
slug: entry.slug,
title: entry.data.title,
description: entry.data.description,
author: entry.data.author,
last_updated: entry.data.last_updated,
difficulty: entry.data.difficulty,
categories: entry.data.categories || [],
tags: entry.data.tags || [],
// Tool association (optional)
tool_name: entry.data.tool_name,
related_tools: entry.data.related_tools || [],
associatedTool,
// Derived properties for consistency with existing UI
name: entry.data.title, // For search compatibility
type: associatedTool?.type || 'article',
icon: associatedTool?.icon || '📖',
platforms: associatedTool?.platforms || [],
skillLevel: entry.data.difficulty || associatedTool?.skillLevel || 'intermediate',
phases: associatedTool?.phases || [],
license: associatedTool?.license
};
});
// Sort alphabetically by title
knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
--- ---
<BaseLayout title="Knowledgebase" description="Extended documentation and insights for DFIR tools"> <BaseLayout title="Knowledgebase" description="Extended documentation and insights for DFIR tools">
@ -17,12 +53,24 @@ knowledgebaseTools.sort((a: any, b: any) => a.name.localeCompare(b.name));
<!-- Header --> <!-- Header -->
<div style="text-align: center; margin-bottom: 3rem; padding: 2rem; background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%); border-radius: 1rem; border: 1px solid var(--color-border);"> <div style="text-align: center; margin-bottom: 3rem; padding: 2rem; background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%); border-radius: 1rem; border: 1px solid var(--color-border);">
<h1 style="margin-bottom: 1rem; font-size: 2.5rem; color: var(--color-primary);">Knowledgebase</h1> <h1 style="margin-bottom: 1rem; font-size: 2.5rem; color: var(--color-primary);">Knowledgebase</h1>
<p style="font-size: 1.25rem; color: var(--color-text-secondary); margin-bottom: 0.5rem;"> <p style="font-size: 1.25rem; color: var(--color-text-secondary); margin-bottom: 1.125rem;">
Erweiterte Dokumentation und Erkenntnisse Erweiterte Dokumentation und Erkenntnisse
</p> </p>
<p style="font-size: 1rem; color: var(--color-text-secondary);"> <p style="font-size: 1rem; color: var(--color-text-secondary); margin-bottom: 1.5rem;">
Praktische Erfahrungen, Konfigurationshinweise und Lektionen aus der Praxis Praktische Erfahrungen, Konfigurationshinweise und Lektionen aus der Praxis
</p> </p>
<!--contribution button -->
<div style="display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap;">
<ContributionButton type="write" variant="primary" text="Artikel schreiben" style="padding: 0.75rem 1.5rem;" />
<a href="#kb-entries" class="btn btn-secondary" style="padding: 0.75rem 1.5rem;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
Artikel durchsuchen
</a>
</div>
</div> </div>
<!-- Search --> <!-- Search -->
@ -35,16 +83,16 @@ knowledgebaseTools.sort((a: any, b: any) => a.name.localeCompare(b.name));
/> />
</div> </div>
<!-- Tools Count --> <!-- Articles Count -->
<div style="text-align: center; margin-bottom: 2rem;"> <div style="text-align: center; margin-bottom: 2rem;">
<p class="text-muted" style="font-size: 0.875rem;"> <p class="text-muted" style="font-size: 0.875rem;">
<span id="visible-count">{knowledgebaseTools.length}</span> von {knowledgebaseTools.length} Einträgen <span id="visible-count">{knowledgebaseEntries.length}</span> von {knowledgebaseEntries.length} Einträgen
</p> </p>
</div> </div>
<!-- Knowledgebase Entries --> <!-- Knowledgebase Entries -->
<div style="max-width: 1000px; margin: 0 auto;"> <div style="max-width: 1000px; margin: 0 auto;">
{knowledgebaseTools.length === 0 ? ( {knowledgebaseEntries.length === 0 ? (
<div class="card" style="text-align: center; padding: 3rem;"> <div class="card" style="text-align: center; padding: 3rem;">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-secondary)" stroke-width="1.5" style="margin: 0 auto 1rem;"> <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-secondary)" stroke-width="1.5" style="margin: 0 auto 1rem;">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
@ -55,53 +103,61 @@ knowledgebaseTools.sort((a: any, b: any) => a.name.localeCompare(b.name));
</svg> </svg>
<h3 style="color: var(--color-text-secondary); margin-bottom: 0.5rem;">Noch keine Knowledgebase-Einträge</h3> <h3 style="color: var(--color-text-secondary); margin-bottom: 0.5rem;">Noch keine Knowledgebase-Einträge</h3>
<p class="text-muted"> <p class="text-muted">
Knowledgebase-Einträge werden automatisch angezeigt, sobald Datenbankeinträge das Attribut "knowledgebase: true" haben. Knowledgebase-Einträge werden automatisch angezeigt, sobald Artikel veröffentlicht werden.
</p> </p>
</div> </div>
) : ( ) : (
<div id="kb-entries"> <div id="kb-entries">
{knowledgebaseTools.map((tool: any, index: number) => { {knowledgebaseEntries.map((entry: any, index: number) => {
const hasValidProjectUrl = tool.projectUrl !== undefined && const hasAssociatedTool = !!entry.associatedTool;
tool.projectUrl !== null && const hasValidProjectUrl = hasAssociatedTool &&
tool.projectUrl !== "" && entry.associatedTool.projectUrl !== undefined &&
tool.projectUrl.trim() !== ""; entry.associatedTool.projectUrl !== null &&
entry.associatedTool.projectUrl !== "" &&
entry.associatedTool.projectUrl.trim() !== "";
const toolSlug = tool.name.toLowerCase() const isMethod = hasAssociatedTool && entry.associatedTool.type === 'method';
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters const isConcept = hasAssociatedTool && entry.associatedTool.type === 'concept';
.replace(/\s+/g, '-') // Replace spaces with hyphens const isStandalone = !hasAssociatedTool;
.replace(/-+/g, '-') // Remove duplicate hyphens
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
return ( return (
<article <article
class="kb-entry card" class="kb-entry card"
id={`kb-${toolSlug}`} id={`kb-${entry.slug}`}
data-tool-name={tool.name.toLowerCase()} data-tool-name={entry.title.toLowerCase()}
data-article-type={isStandalone ? 'standalone' : 'tool-associated'}
> >
<div style="display: flex; justify-content: space-between; align-items: center;"> <div style="display: flex; justify-content: space-between; align-items: center;">
<div style="display: flex; align-items: center; gap: 1rem;"> <div style="display: flex; align-items: center; gap: 1rem;">
<h3 style="margin: 0; color: var(--color-primary);"> <h3 style="margin: 0; color: var(--color-primary);">
{tool.icon && <span style="margin-right: 0.5rem;">{tool.icon}</span>} <span style="margin-right: 0.5rem;">{entry.icon}</span>
{tool.name} {entry.title}
</h3> </h3>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;"> <div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<!-- Type indicator badges --> <!-- Type indicator badges -->
{tool.type === 'concept' && <span class="badge" style="background-color: var(--color-concept); color: white;">Konzept</span>} {isStandalone && <span class="badge" style="background-color: var(--color-accent); color: white;">Artikel</span>}
{tool.type === 'method' && <span class="badge" style="background-color: var(--color-method); color: white;">Methode</span>} {isConcept && <span class="badge" style="background-color: var(--color-concept); color: white;">Konzept</span>}
{tool.type === 'software' && <span class="badge" style="background-color: var(--color-primary); color: white;">Software</span>} {isMethod && <span class="badge" style="background-color: var(--color-method); color: white;">Methode</span>}
{hasAssociatedTool && !isMethod && !isConcept && <span class="badge" style="background-color: var(--color-primary); color: white;">Software</span>}
{hasValidProjectUrl && <span class="badge badge-primary">CC24-Server</span>} {hasValidProjectUrl && <span class="badge badge-primary">CC24-Server</span>}
{tool.license !== 'Proprietary' && tool.type !== 'concept' && tool.type !== 'method' && <span class="badge badge-success">Open Source</span>} {hasAssociatedTool && entry.associatedTool.license !== 'Proprietary' && !isMethod && !isConcept && <span class="badge badge-success">Open Source</span>}
<!-- Difficulty indicator --> <!-- Difficulty indicator -->
{entry.difficulty && (
<span class="badge" style="background-color: var(--color-text-secondary); color: white; font-size: 0.75rem;"> <span class="badge" style="background-color: var(--color-text-secondary); color: white; font-size: 0.75rem;">
{tool.skillLevel || 'intermediate'} {entry.difficulty}
</span> </span>
)}
<!-- Knowledge Base indicator -->
<span class="badge badge-error">📖</span>
</div> </div>
</div> </div>
<!-- Action button --> <!-- Action buttons -->
<a href={`/knowledgebase/${toolSlug}`} class="btn btn-primary" style="font-size: 0.8125rem;"> <div style="display: flex; gap: 0.5rem; align-items: center; flex-shrink: 0;">
<a href={`/knowledgebase/${entry.slug}`} class="btn btn-primary" style="font-size: 0.8125rem;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/> <polyline points="15 3 21 3 21 9"/>
@ -109,34 +165,59 @@ knowledgebaseTools.sort((a: any, b: any) => a.name.localeCompare(b.name));
</svg> </svg>
Artikel öffnen Artikel öffnen
</a> </a>
<!-- Edit button for knowledgebase articles -->
<ContributionButton type="edit" toolName={entry.tool_name || entry.title} variant="secondary" text="Edit" style="font-size: 0.8125rem; padding: 0.5rem 0.75rem;" />
</div>
</div> </div>
<!-- Description --> <!-- Description -->
<p style="margin: 1rem 0; color: var(--color-text-secondary); line-height: 1.5;"> <p style="margin: 1rem 0; color: var(--color-text-secondary); line-height: 1.5;">
{tool.description} {entry.description}
</p> </p>
<!-- Tags and Metadata --> <!-- Metadata and Tags -->
<div style="display: flex; gap: 1rem; flex-wrap: wrap; align-items: center; margin-top: 1rem;"> <div style="display: flex; gap: 1rem; flex-wrap: wrap; align-items: center; margin-top: 1rem;">
{tool.tags && tool.tags.length > 0 && ( <!-- Tags -->
{entry.tags && entry.tags.length > 0 && (
<div style="display: flex; flex-wrap: wrap; gap: 0.25rem;"> <div style="display: flex; flex-wrap: wrap; gap: 0.25rem;">
{tool.tags.map((tag: string) => ( {entry.tags.map((tag: string) => (
<span class="tag" style="font-size: 0.75rem;">{tag}</span> <span class="tag" style="font-size: 0.75rem;">{tag}</span>
))} ))}
</div> </div>
)} )}
{tool.phases && tool.phases.length > 0 && ( <!-- Categories -->
{entry.categories && entry.categories.length > 0 && (
<div style="font-size: 0.8125rem; color: var(--color-text-secondary);"> <div style="font-size: 0.8125rem; color: var(--color-text-secondary);">
<strong>Phasen:</strong> {tool.phases.join(', ')} <strong>Kategorien:</strong> {entry.categories.join(', ')}
</div> </div>
)} )}
{tool.platforms && tool.platforms.length > 0 && tool.type !== 'concept' && tool.type !== 'method' && ( <!-- Tool-specific metadata (only if associated with tool) -->
{hasAssociatedTool && entry.phases && entry.phases.length > 0 && (
<div style="font-size: 0.8125rem; color: var(--color-text-secondary);"> <div style="font-size: 0.8125rem; color: var(--color-text-secondary);">
<strong>Plattformen:</strong> {tool.platforms.join(', ')} <strong>Phasen:</strong> {entry.phases.join(', ')}
</div> </div>
)} )}
{hasAssociatedTool && entry.platforms && entry.platforms.length > 0 && !isMethod && !isConcept && (
<div style="font-size: 0.8125rem; color: var(--color-text-secondary);">
<strong>Plattformen:</strong> {entry.platforms.join(', ')}
</div>
)}
<!-- Related tools for standalone articles -->
{isStandalone && entry.related_tools && entry.related_tools.length > 0 && (
<div style="font-size: 0.8125rem; color: var(--color-text-secondary);">
<strong>Verwandte Tools:</strong> {entry.related_tools.join(', ')}
</div>
)}
<!-- Author and date -->
<div style="font-size: 0.8125rem; color: var(--color-text-secondary); margin-left: auto;">
<strong>Autor:</strong> {entry.author} • <strong>Aktualisiert:</strong> {entry.last_updated.toLocaleDateString('de-DE')}
</div>
</div> </div>
</article> </article>
); );
@ -151,26 +232,19 @@ knowledgebaseTools.sort((a: any, b: any) => a.name.localeCompare(b.name));
<p class="text-muted">Versuchen Sie es mit anderen Suchbegriffen.</p> <p class="text-muted">Versuchen Sie es mit anderen Suchbegriffen.</p>
</div> </div>
</section> </section>
<!-- Floating Action Button -->
<div id="fab-container" style="position: fixed; bottom: 2rem; right: 2rem; z-index: 100; display: none;">
<ContributionButton
type="write"
variant="primary"
text="✍️"
style="border-radius: 50%; width: 56px; height: 56px; display: flex; align-items: center; justify-content: center; box-shadow: var(--shadow-lg); font-size: 1.5rem; padding: 0;"
className="fab-button"
/>
</div>
</BaseLayout> </BaseLayout>
<style>
/* Simplified knowledgebase styles */
.kb-entry {
margin-bottom: 1.5rem;
border-left: 4px solid var(--color-accent);
transition: var(--transition-fast);
}
.kb-entry:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.dark .kb-entry:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
</style>
<script> <script>
// Enhanced knowledgebase functionality with search // Enhanced knowledgebase functionality with search
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
@ -217,5 +291,22 @@ knowledgebaseTools.sort((a: any, b: any) => a.name.localeCompare(b.name));
filterEntries(target.value); filterEntries(target.value);
}); });
} }
// Show floating action button on scroll (optional enhancement)
let lastScrollY = window.scrollY;
const fabContainer = document.getElementById('fab-container');
window.addEventListener('scroll', () => {
if (fabContainer) {
if (window.scrollY > 200 && window.scrollY < lastScrollY) {
// Scrolling up and past threshold
fabContainer.style.display = 'block';
} else {
// Scrolling down or at top
fabContainer.style.display = 'none';
}
lastScrollY = window.scrollY;
}
});
}); });
</script> </script>

View File

@ -26,20 +26,35 @@ const { Content } = await entry.render();
// Load tools data to get the tool details // Load tools data to get the tool details
const data = await getToolsData(); const data = await getToolsData();
const tool = data.tools.find((t: any) => t.name === entry.data.tool_name);
if (!tool) { // UPGRADED: Handle optional tool association
console.warn(`Tool not found for knowledgebase entry: ${entry.data.tool_name}`); const primaryTool = entry.data.tool_name
return Astro.redirect('/knowledgebase'); ? data.tools.find((t: any) => t.name === entry.data.tool_name)
: null;
// UPGRADED: Handle multiple related tools
const relatedTools = entry.data.related_tools
? entry.data.related_tools.map((toolName: string) =>
data.tools.find((t: any) => t.name === toolName)
).filter(Boolean)
: [];
// UPGRADED: Use primary tool or first related tool for styling, fallback to generic
const displayTool = primaryTool || relatedTools[0];
// UPGRADED: Don't redirect - show article even without tool association
if (!displayTool && !entry.data.tool_name && relatedTools.length === 0) {
console.log(`Standalone knowledgebase article: ${entry.slug}`);
} }
// Determine tool type for styling // Determine styling based on tool type or fallback to generic
const isMethod = tool.type === 'method'; const isMethod = displayTool?.type === 'method';
const isConcept = tool.type === 'concept'; const isConcept = displayTool?.type === 'concept';
const hasValidProjectUrl = tool.projectUrl !== undefined && const isStandalone = !displayTool;
tool.projectUrl !== null && const hasValidProjectUrl = displayTool && displayTool.projectUrl !== undefined &&
tool.projectUrl !== "" && displayTool.projectUrl !== null &&
tool.projectUrl.trim() !== ""; displayTool.projectUrl !== "" &&
displayTool.projectUrl.trim() !== "";
--- ---
<BaseLayout title={entry.data.title} description={entry.data.description}> <BaseLayout title={entry.data.title} description={entry.data.description}>
@ -49,7 +64,7 @@ const hasValidProjectUrl = tool.projectUrl !== undefined &&
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 1rem;"> <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 1rem;">
<div style="flex: 1;"> <div style="flex: 1;">
<h1 style="margin: 0 0 0.5rem 0; color: var(--color-primary);"> <h1 style="margin: 0 0 0.5rem 0; color: var(--color-primary);">
{tool.icon && <span style="margin-right: 0.75rem; font-size: 1.5rem;">{tool.icon}</span>} {displayTool?.icon && <span style="margin-right: 0.75rem; font-size: 1.5rem;">{displayTool.icon}</span>}
{entry.data.title} {entry.data.title}
</h1> </h1>
<p style="margin: 0; color: var(--color-text-secondary); font-size: 1.125rem;"> <p style="margin: 0; color: var(--color-text-secondary); font-size: 1.125rem;">
@ -58,32 +73,59 @@ const hasValidProjectUrl = tool.projectUrl !== undefined &&
</div> </div>
<div style="display: flex; flex-direction: column; gap: 0.5rem; align-items: end;"> <div style="display: flex; flex-direction: column; gap: 0.5rem; align-items: end;">
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;"> <div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<!-- UPGRADED: Conditional badges based on tool type or standalone -->
{isStandalone ? (
<span class="badge" style="background-color: var(--color-accent); color: white;">Artikel</span>
) : (
<>
{isConcept && <span class="badge" style="background-color: var(--color-concept); color: white;">Konzept</span>} {isConcept && <span class="badge" style="background-color: var(--color-concept); color: white;">Konzept</span>}
{isMethod && <span class="badge" style="background-color: var(--color-method); color: white;">Methode</span>} {isMethod && <span class="badge" style="background-color: var(--color-method); color: white;">Methode</span>}
{!isMethod && !isConcept && <span class="badge" style="background-color: var(--color-primary); color: white;">Software</span>} {!isMethod && !isConcept && !isStandalone && <span class="badge" style="background-color: var(--color-primary); color: white;">Software</span>}
{!isMethod && !isConcept && hasValidProjectUrl && <span class="badge badge-primary">CC24-Server</span>} {!isMethod && !isConcept && hasValidProjectUrl && <span class="badge badge-primary">CC24-Server</span>}
{!isMethod && !isConcept && tool.license !== 'Proprietary' && <span class="badge badge-success">Open Source</span>} {!isMethod && !isConcept && !isStandalone && displayTool?.license !== 'Proprietary' && <span class="badge badge-success">Open Source</span>}
</>
)}
<span class="badge badge-error">📖</span> <span class="badge badge-error">📖</span>
</div> </div>
</div> </div>
</div> </div>
<!-- Metadata --> <!-- UPGRADED: Flexible metadata section -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--color-border);"> <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--color-border);">
<!-- Difficulty (always shown if present) -->
{entry.data.difficulty && (
<div> <div>
<strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Schwierigkeit</strong> <strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Schwierigkeit</strong>
<p style="margin: 0; font-size: 0.9375rem;">{entry.data.difficulty}</p> <p style="margin: 0; font-size: 0.9375rem;">{entry.data.difficulty}</p>
</div> </div>
)}
<!-- Last Updated (always shown) -->
<div> <div>
<strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Letztes Update</strong> <strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Letztes Update</strong>
<p style="margin: 0; font-size: 0.9375rem;">{entry.data.last_updated.toLocaleDateString('de-DE')}</p> <p style="margin: 0; font-size: 0.9375rem;">{entry.data.last_updated.toLocaleDateString('de-DE')}</p>
</div> </div>
<!-- Author (always shown) -->
<div> <div>
<strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Autor</strong> <strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Autor</strong>
<p style="margin: 0; font-size: 0.9375rem;">{entry.data.author}</p> <p style="margin: 0; font-size: 0.9375rem;">{entry.data.author}</p>
</div> </div>
{entry.data.categories && entry.data.categories.length > 0 && (
<!-- UPGRADED: Show article type -->
<div> <div>
<strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Typ</strong>
<p style="margin: 0; font-size: 0.9375rem;">
{isStandalone ? 'Allgemeiner Artikel' :
isConcept ? 'Konzept-Artikel' :
isMethod ? 'Methoden-Artikel' :
'Software-Artikel'}
</p>
</div>
<!-- UPGRADED: Categories (if present) -->
{entry.data.categories && entry.data.categories.length > 0 && (
<div style="grid-column: 1 / -1;">
<strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Kategorien</strong> <strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Kategorien</strong>
<div style="display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.25rem;"> <div style="display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.25rem;">
{entry.data.categories.map((cat: string) => ( {entry.data.categories.map((cat: string) => (
@ -112,12 +154,30 @@ const hasValidProjectUrl = tool.projectUrl !== undefined &&
</div> </div>
</div> </div>
<!-- Tool Actions --> <!-- UPGRADED: Flexible Tool Actions Section -->
<div class="card" style="margin-top: 2rem; background-color: var(--color-bg-secondary);"> <div class="card" style="margin-top: 2rem; background-color: var(--color-bg-secondary);">
<h3 style="margin: 0 0 1rem 0; color: var(--color-text);">Tool-Aktionen</h3> <h3 style="margin: 0 0 1rem 0; color: var(--color-text);">
{isStandalone ? 'Verwandte Aktionen' : 'Tool-Aktionen'}
</h3>
<div style="display: flex; gap: 1rem; flex-wrap: wrap;"> <div style="display: flex; gap: 1rem; flex-wrap: wrap;">
{isStandalone ? (
<!-- UPGRADED: Standalone article actions -->
<a href="/knowledgebase" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
Weitere Artikel
</a>
) : (
<!-- UPGRADED: Tool-specific actions (existing logic) -->
<>
{isConcept ? ( {isConcept ? (
<a href={tool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="background-color: var(--color-concept); border-color: var(--color-concept);"> <a href={displayTool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="background-color: var(--color-concept); border-color: var(--color-concept);">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/> <polyline points="15 3 21 3 21 9"/>
@ -126,7 +186,7 @@ const hasValidProjectUrl = tool.projectUrl !== undefined &&
Mehr erfahren Mehr erfahren
</a> </a>
) : isMethod ? ( ) : isMethod ? (
<a href={tool.projectUrl || tool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="background-color: var(--color-method); border-color: var(--color-method);"> <a href={displayTool.projectUrl || displayTool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="background-color: var(--color-method); border-color: var(--color-method);">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/> <polyline points="15 3 21 3 21 9"/>
@ -136,7 +196,7 @@ const hasValidProjectUrl = tool.projectUrl !== undefined &&
</a> </a>
) : ( ) : (
<> <>
<a href={tool.url} target="_blank" rel="noopener noreferrer" class="btn btn-secondary"> <a href={displayTool.url} target="_blank" rel="noopener noreferrer" class="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/> <polyline points="15 3 21 3 21 9"/>
@ -145,7 +205,7 @@ const hasValidProjectUrl = tool.projectUrl !== undefined &&
Software-Homepage Software-Homepage
</a> </a>
{hasValidProjectUrl && ( {hasValidProjectUrl && (
<a href={tool.projectUrl} target="_blank" rel="noopener noreferrer" class="btn btn-primary"> <a href={displayTool.projectUrl} target="_blank" rel="noopener noreferrer" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
<circle cx="12" cy="12" r="10"/> <circle cx="12" cy="12" r="10"/>
<path d="M12 16l4-4-4-4"/> <path d="M12 16l4-4-4-4"/>
@ -156,6 +216,38 @@ const hasValidProjectUrl = tool.projectUrl !== undefined &&
)} )}
</> </>
)} )}
</>
)}
<!-- UPGRADED: Show related tools if present -->
{relatedTools.length > 0 && relatedTools.length > (primaryTool ? 1 : 0) && (
<div style="margin-left: auto;">
<details style="position: relative;">
<summary class="btn btn-secondary" style="cursor: pointer; list-style: none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="8.5" cy="7" r="4"/>
<line x1="20" y1="8" x2="20" y2="14"/>
<line x1="23" y1="11" x2="17" y2="11"/>
</svg>
Verwandte Tools ({relatedTools.length})
</summary>
<div style="position: absolute; top: 100%; left: 0; background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1rem; min-width: 200px; z-index: 100; box-shadow: var(--shadow-lg);">
{relatedTools.map((tool: any) => (
<a href={tool.projectUrl || tool.url} target="_blank" rel="noopener noreferrer"
style="display: block; padding: 0.5rem; border-radius: 0.25rem; text-decoration: none; color: var(--color-text); margin-bottom: 0.25rem;"
onmouseover="this.style.backgroundColor='var(--color-bg-secondary)'"
onmouseout="this.style.backgroundColor='transparent'">
{tool.icon && <span style="margin-right: 0.5rem;">{tool.icon}</span>}
{tool.name}
</a>
))}
</div>
</details>
</div>
)}
<!-- Always show return to main page -->
<a href="/" class="btn btn-secondary"> <a href="/" class="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/> <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>

View File

@ -1,64 +0,0 @@
// Theme management
const THEME_KEY = 'dfir-theme';
// Get system preference
function getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
// Get stored theme or default to auto
function getStoredTheme() {
return localStorage.getItem(THEME_KEY) || 'auto';
}
// Apply theme to document
function applyTheme(theme) {
const effectiveTheme = theme === 'auto' ? getSystemTheme() : theme;
document.documentElement.setAttribute('data-theme', effectiveTheme);
}
// Update theme toggle button state
function updateThemeToggle(theme) {
document.querySelectorAll('[data-theme-toggle]').forEach(button => {
button.setAttribute('data-current-theme', theme);
});
}
// Initialize theme on page load
function initTheme() {
const storedTheme = getStoredTheme();
applyTheme(storedTheme);
// Update theme toggle buttons immediately
updateThemeToggle(storedTheme);
}
// Handle theme toggle
function toggleTheme() {
const current = getStoredTheme();
const themes = ['light', 'dark', 'auto'];
const currentIndex = themes.indexOf(current);
const nextIndex = (currentIndex + 1) % themes.length;
const nextTheme = themes[nextIndex];
localStorage.setItem(THEME_KEY, nextTheme);
applyTheme(nextTheme);
updateThemeToggle(nextTheme);
}
// Listen for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (getStoredTheme() === 'auto') {
applyTheme('auto');
}
});
// Initialize when DOM is ready (for safety)
document.addEventListener('DOMContentLoaded', initTheme);
// Export functions for use in Astro components
window.themeUtils = {
initTheme,
toggleTheme,
getStoredTheme
};

View File

@ -279,13 +279,21 @@ input, select, textarea {
background-color: var(--color-bg); background-color: var(--color-bg);
color: var(--color-text); color: var(--color-text);
font-size: 0.875rem; font-size: 0.875rem;
transition: var(--transition-fast); transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
} }
input:focus, select:focus, textarea:focus { input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--color-primary); border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgb(37 99 235 / 10%); box-shadow: 0 0 0 2px rgba(var(--color-primary-rgb), 0.1);
}
/* Form validation states */
input:invalid:not(:focus), textarea:invalid:not(:focus), select:invalid:not(:focus) {
border-color: var(--color-error);
}
input:valid:not(:focus), textarea:valid:not(:focus), select:valid:not(:focus) {
border-color: var(--color-accent);
} }
select { select {
@ -301,14 +309,67 @@ select {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
cursor: pointer;
transition: var(--transition-fast);
user-select: none;
}
.checkbox-wrapper:hover {
background-color: var(--color-bg-secondary);
border-radius: 0.25rem;
}
.checkbox-wrapper input[type="checkbox"] {
margin-right: 0.5rem;
cursor: pointer;
}
/* Scrollable checkbox containers */
.checkbox-container {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--color-border);
border-radius: 0.375rem;
padding: 0.75rem;
background-color: var(--color-bg);
}
.checkbox-container::-webkit-scrollbar {
width: 8px;
}
.checkbox-container::-webkit-scrollbar-track {
background: var(--color-bg-secondary);
border-radius: 4px;
}
.checkbox-container::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 4px;
}
.checkbox-container::-webkit-scrollbar-thumb:hover {
background: var(--color-text-secondary);
} }
input[type="checkbox"] { input[type="checkbox"] {
width: auto; width: 16px;
height: 16px;
accent-color: var(--color-primary);
margin: 0; margin: 0;
cursor: pointer; cursor: pointer;
} }
/* Better focus states for accessibility */
input[type="checkbox"]:focus,
input[type="text"]:focus,
input[type="url"]:focus,
textarea:focus,
select:focus {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Consolidated Card System */ /* Consolidated Card System */
.card { .card {
background-color: var(--color-bg); background-color: var(--color-bg);
@ -469,6 +530,10 @@ input[type="checkbox"] {
background: linear-gradient(to right, transparent 0%, var(--color-method-bg) 70%); background: linear-gradient(to right, transparent 0%, var(--color-method-bg) 70%);
} }
.card-concept .tool-tags-container::after {
background: linear-gradient(to right, transparent 0%, var(--color-method-bg) 70%);
}
.tool-card-buttons { .tool-card-buttons {
margin-top: auto; margin-top: auto;
flex-shrink: 0; flex-shrink: 0;
@ -685,6 +750,7 @@ input[type="checkbox"] {
width: 100%; width: 100%;
height: 100%; height: 100%;
background-color: rgb(0 0 0 / 50%); background-color: rgb(0 0 0 / 50%);
backdrop-filter: blur(2px);
z-index: 999; z-index: 999;
} }
@ -843,6 +909,27 @@ input[type="checkbox"] {
transform: translateY(-1px); transform: translateY(-1px);
} }
/* Loading state improvements */
.btn.loading {
opacity: 0.7;
pointer-events: none;
position: relative;
}
.btn.loading::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 16px;
height: 16px;
margin: -8px 0 0 -8px;
border: 2px solid transparent;
border-top: 2px solid currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
}
/* Collaboration Tools */ /* Collaboration Tools */
.collaboration-tools-compact { .collaboration-tools-compact {
display: flex; display: flex;
@ -944,7 +1031,7 @@ Collaboration Section Collapse */
} }
.ai-loading, .ai-error, .ai-results { .ai-loading, .ai-error, .ai-results {
animation: fadeIn 0.3s ease-in; animation: fadeIn 0.3s ease-in-out;
} }
.ai-mode-toggle { .ai-mode-toggle {
@ -1278,6 +1365,16 @@ Collaboration Section Collapse */
position: relative; position: relative;
transition: var(--transition-medium); transition: var(--transition-medium);
} }
/*
.kb-entry {
margin-bottom: 1.5rem;
border-left: 4px solid var(--color-accent);
transition: var(--transition-fast);
}*/
.kb-entry:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.kb-entry:target { animation: highlight-flash 2s ease-out; } .kb-entry:target { animation: highlight-flash 2s ease-out; }
@ -1292,6 +1389,9 @@ Collaboration Section Collapse */
padding: 1.5rem; padding: 1.5rem;
border-radius: 0.5rem; border-radius: 0.5rem;
} }
.dark .kb-entry:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.kb-expand-icon svg { transition: var(--transition-medium); } .kb-expand-icon svg { transition: var(--transition-medium); }
@ -1328,12 +1428,23 @@ footer {
max-height: 0; max-height: 0;
padding-top: 0; padding-top: 0;
margin-top: 0; margin-top: 0;
transform: translateY(-10px);
} }
to { to {
opacity: 1; opacity: 1;
max-height: 1000px; max-height: 1000px;
padding-top: 1rem; padding-top: 1rem;
margin-top: 1rem; margin-top: 1rem;
transform: translateY(0);
}
}
@keyframes shimmer {
0% {
opacity: 0.8;
}
100% {
opacity: 1;
} }
} }
@ -1407,9 +1518,16 @@ Strobing borders: Bright colored borders that change with each keyframe
Higher opacity: More saturated colors (up to 100% on yellow) Higher opacity: More saturated colors (up to 100% on yellow)
This will literally assault the user's retinas. They'll need sunglasses to look at their shared tools! 🌈💥👁🗨*/ This will literally assault the user's retinas. They'll need sunglasses to look at their shared tools! 🌈💥👁🗨*/
@keyframes pulse { @keyframes pulse {
0%, 100% { opacity: 1; } 0%, 100% {
50% { opacity: 0.5; } transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.05);
opacity: 0.8;
}
} }
@keyframes fadeInUp { @keyframes fadeInUp {
@ -1423,6 +1541,11 @@ This will literally assault the user's retinas. They'll need sunglasses to look
} }
} }
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Consolidated Responsive Design */ /* Consolidated Responsive Design */
@media (width <= 1200px) { @media (width <= 1200px) {
.modals-side-by-side #tool-details-primary.active, .modals-side-by-side #tool-details-primary.active,
@ -1503,6 +1626,10 @@ This will literally assault the user's retinas. They'll need sunglasses to look
width: 95%; width: 95%;
max-width: none; max-width: none;
} }
.form-grid.two-columns {
grid-template-columns: 1fr;
}
} }
@media (width <= 640px) { @media (width <= 640px) {
@ -1555,6 +1682,15 @@ This will literally assault the user's retinas. They'll need sunglasses to look
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 0.5rem; gap: 0.5rem;
} }
.card {
padding: 1rem;
}
.form-grid {
gap: 0.75rem;
}
.checkbox-container {
max-height: 150px;
}
} }
@media (width <= 480px) { @media (width <= 480px) {
@ -1713,3 +1849,227 @@ This will literally assault the user's retinas. They'll need sunglasses to look
.share-btn svg { .share-btn svg {
flex-shrink: 0; flex-shrink: 0;
} }
/* === LAYOUT UTILITIES === */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.flex-row { flex-direction: row; }
.flex-wrap { flex-wrap: wrap; }
.flex-1 { flex: 1; }
.flex-shrink-0 { flex-shrink: 0; }
.flex-shrink-1 { flex-shrink: 1; }
/* Alignment */
.items-center { align-items: center; }
.items-start { align-items: flex-start; }
.items-end { align-items: flex-end; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.justify-end { justify-content: flex-end; }
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
/* Grid */
.grid { display: grid; }
.grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
.grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
.grid-auto-fit-sm { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
.grid-auto-fit-md { grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
/* === SPACING UTILITIES === */
.gap-0 { gap: 0; }
.gap-1 { gap: 0.25rem; }
.gap-2 { gap: 0.5rem; }
.gap-3 { gap: 0.75rem; }
.gap-4 { gap: 1rem; }
.gap-5 { gap: 1.25rem; }
.gap-6 { gap: 1.5rem; }
.gap-8 { gap: 2rem; }
/* Margin */
.m-0 { margin: 0; }
.mb-0 { margin-bottom: 0; }
.mb-1 { margin-bottom: 0.25rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-3 { margin-bottom: 0.75rem; }
.mb-4 { margin-bottom: 1rem; }
.mb-6 { margin-bottom: 1.5rem; }
.mb-8 { margin-bottom: 2rem; }
.mt-auto { margin-top: auto; }
.mr-2 { margin-right: 0.5rem; }
.mr-3 { margin-right: 0.75rem; }
/* Padding */
.p-0 { padding: 0; }
.p-4 { padding: 1rem; }
.p-6 { padding: 1.5rem; }
.p-8 { padding: 2rem; }
/* === TYPOGRAPHY UTILITIES === */
.text-xs { font-size: 0.75rem; }
.text-sm { font-size: 0.875rem; }
.text-base { font-size: 1rem; }
.text-lg { font-size: 1.125rem; }
.text-xl { font-size: 1.25rem; }
.text-2xl { font-size: 1.5rem; }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
/* === VISUAL UTILITIES === */
.rounded { border-radius: 0.375rem; }
.rounded-md { border-radius: 0.5rem; }
.rounded-lg { border-radius: 0.75rem; }
.rounded-full { border-radius: 9999px; }
.shadow-sm { box-shadow: var(--shadow-sm); }
.shadow-md { box-shadow: var(--shadow-md); }
.shadow-lg { box-shadow: var(--shadow-lg); }
.border { border: 1px solid var(--color-border); }
.border-l-4 { border-left: 4px solid var(--color-border); }
.bg-primary { background-color: var(--color-primary); }
.bg-secondary { background-color: var(--color-bg-secondary); }
.bg-tertiary { background-color: var(--color-bg-tertiary); }
.text-primary { color: var(--color-primary); }
.text-secondary { color: var(--color-text-secondary); }
.text-white { color: white; }
/* === POSITION & SIZING === */
.relative { position: relative; }
.absolute { position: absolute; }
.fixed { position: fixed; }
.w-full { width: 100%; }
.h-full { height: 100%; }
.min-w-0 { min-width: 0; }
.overflow-hidden { overflow: hidden; }
.cursor-pointer { cursor: pointer; }
/* === COMMON COMBINATIONS === */
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.flex-start {
display: flex;
align-items: center;
}
.field-help {
font-size: 0.8125rem;
color: var(--color-text-secondary);
line-height: 1.4;
}
/* Improved field error styling */
.field-error {
color: var(--color-error);
font-size: 0.8125rem;
margin-top: 0.25rem;
display: flex;
align-items: center;
gap: 0.25rem;
}
.field-error::before {
content: "⚠";
font-size: 0.75rem;
}
/* Form section improvements */
.form-section {
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1.5rem;
background-color: var(--color-bg);
}
.form-section h3 {
margin-top: 0;
margin-bottom: 1rem;
color: var(--color-primary);
font-size: 1.125rem;
}
/* Success/warning notices in forms */
.form-notice {
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
border-left: 3px solid;
}
.form-notice.success {
background-color: var(--color-oss-bg);
border-left-color: var(--color-accent);
color: var(--color-text);
}
.form-notice.warning {
background-color: var(--color-hosted-bg);
border-left-color: var(--color-warning);
color: var(--color-text);
}
.form-notice.info {
background-color: var(--color-bg-secondary);
border-left-color: var(--color-primary);
color: var(--color-text);
}
/* Better form grid layout */
.form-grid {
display: grid;
gap: 1rem;
}
.form-grid.two-columns {
grid-template-columns: 1fr 1fr;
}
/* Better spacing for form elements */
.form-group {
margin-bottom: 1.5rem;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: var(--color-text);
}
.form-label.required::after {
content: " *";
color: var(--color-error);
}
#queue-status {
animation: slideDown 0.3s ease-out;
}
#queue-position-badge {
animation: pulse 2s infinite;
transition: all 0.3s ease;
}
#queue-progress {
background: linear-gradient(90deg, var(--color-primary), var(--color-accent));
animation: shimmer 2s ease-in-out infinite alternate;
}

157
src/utils/api.ts Normal file
View File

@ -0,0 +1,157 @@
// src/utils/api.ts
// Standard JSON headers for all API responses
const JSON_HEADERS = {
'Content-Type': 'application/json'
} as const;
/**
* Base function to create consistent API responses
* All other response helpers use this internally
*/
export function createAPIResponse(data: any, status: number = 200, additionalHeaders?: Record<string, string>): Response {
const headers = additionalHeaders
? { ...JSON_HEADERS, ...additionalHeaders }
: JSON_HEADERS;
return new Response(JSON.stringify(data), {
status,
headers
});
}
/**
* Success responses (2xx status codes)
*/
export const apiResponse = {
// 200 - Success with data
success: (data: any = { success: true }): Response =>
createAPIResponse(data, 200),
// 201 - Created (for contribution submissions, uploads, etc.)
created: (data: any = { success: true }): Response =>
createAPIResponse(data, 201),
// 202 - Accepted (for async operations)
accepted: (data: any = { success: true, message: 'Request accepted for processing' }): Response =>
createAPIResponse(data, 202)
};
/**
* Client error responses (4xx status codes)
*/
export const apiError = {
// 400 - Bad Request
badRequest: (message: string = 'Bad request', details?: string[]): Response =>
createAPIResponse({
success: false,
error: message,
...(details && { details })
}, 400),
// 401 - Unauthorized
unauthorized: (message: string = 'Authentication required'): Response =>
createAPIResponse({ success: false, error: message }, 401),
// 403 - Forbidden
forbidden: (message: string = 'Access denied'): Response =>
createAPIResponse({ success: false, error: message }, 403),
// 404 - Not Found
notFound: (message: string = 'Resource not found'): Response =>
createAPIResponse({ success: false, error: message }, 404),
// 422 - Unprocessable Entity (validation errors)
validation: (message: string = 'Validation failed', details?: string[]): Response =>
createAPIResponse({
success: false,
error: message,
...(details && { details })
}, 422),
// 429 - Rate Limited
rateLimit: (message: string = 'Rate limit exceeded. Please wait before trying again.'): Response =>
createAPIResponse({ success: false, error: message }, 429)
};
/**
* Server error responses (5xx status codes)
*/
export const apiServerError = {
// 500 - Internal Server Error
internal: (message: string = 'Internal server error'): Response =>
createAPIResponse({ success: false, error: message }, 500),
// 502 - Bad Gateway (external service issues)
badGateway: (message: string = 'External service error'): Response =>
createAPIResponse({ success: false, error: message }, 502),
// 503 - Service Unavailable
unavailable: (message: string = 'Service temporarily unavailable'): Response =>
createAPIResponse({ success: false, error: message }, 503),
// 504 - Gateway Timeout
timeout: (message: string = 'Request timeout'): Response =>
createAPIResponse({ success: false, error: message }, 504)
};
/**
* Specialized response helpers for common patterns
*/
export const apiSpecial = {
// JSON parsing error
invalidJSON: (): Response =>
apiError.badRequest('Invalid JSON in request body'),
// Missing required fields
missingRequired: (fields: string[]): Response =>
apiError.badRequest(`Missing required fields: ${fields.join(', ')}`),
// Empty request body
emptyBody: (): Response =>
apiError.badRequest('Request body cannot be empty'),
// File upload responses
uploadSuccess: (data: { url: string; filename: string; size: number; storage: string }): Response =>
apiResponse.created(data),
uploadFailed: (error: string): Response =>
apiServerError.internal(`Upload failed: ${error}`),
// Contribution responses
contributionSuccess: (data: { prUrl?: string; branchName?: string; message: string }): Response =>
apiResponse.created({ success: true, ...data }),
contributionFailed: (error: string): Response =>
apiServerError.internal(`Contribution failed: ${error}`)
};
export const apiWithHeaders = {
// Success with custom headers (e.g., Set-Cookie)
successWithHeaders: (data: any, headers: Record<string, string>): Response =>
createAPIResponse(data, 200, headers),
// Redirect response
redirect: (location: string, temporary: boolean = true): Response =>
new Response(null, {
status: temporary ? 302 : 301,
headers: { 'Location': location }
})
};
export async function handleAPIRequest<T>(
operation: () => Promise<T>,
errorMessage: string = 'Request processing failed'
): Promise<T | Response> {
try {
return await operation();
} catch (error) {
console.error(`API Error: ${errorMessage}:`, error);
return apiServerError.internal(errorMessage);
}
}
export const createAuthErrorResponse = apiError.unauthorized;
export const createBadRequestResponse = apiError.badRequest;
export const createSuccessResponse = apiResponse.success;

View File

@ -1,10 +1,50 @@
import { SignJWT, jwtVerify, type JWTPayload } from 'jose'; // src/utils/auth.js (FIXED - Cleaned up and enhanced debugging)
import { serialize, parse } from 'cookie'; import type { AstroGlobal } from 'astro';
import crypto from 'crypto';
import { config } from 'dotenv'; import { config } from 'dotenv';
import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
import { serialize, parse as parseCookie } from 'cookie';
// Load environment variables // Load environment variables
config(); config();
// JWT session constants
const SECRET_KEY = new TextEncoder().encode(
process.env.AUTH_SECRET ||
'cc24-hub-default-secret-key-change-in-production'
);
const SESSION_DURATION = 6 * 60 * 60; // 6 hours in seconds
// Types
export interface SessionData {
userId: string;
email: string;
authenticated: boolean;
exp: number;
}
export interface AuthContext {
authenticated: boolean;
session: SessionData | null;
userEmail: string;
userId: string;
}
export type AuthContextType = 'contributions' | 'ai' | 'general';
export interface UserInfo {
sub?: string;
preferred_username?: string;
email?: string;
given_name?: string;
family_name?: string;
}
export interface AuthStateData {
state: string;
returnTo: string;
}
// Environment variables - use runtime access for server-side // Environment variables - use runtime access for server-side
function getEnv(key: string): string { function getEnv(key: string): string {
const value = process.env[key]; const value = process.env[key];
@ -14,90 +54,98 @@ function getEnv(key: string): string {
return value; return value;
} }
const SECRET_KEY = new TextEncoder().encode(getEnv('AUTH_SECRET')); // Session management functions
const SESSION_DURATION = 6 * 60 * 60; // 6 hours in seconds export function getSessionFromRequest(request: Request): string | null {
const cookieHeader = request.headers.get('cookie');
console.log('[DEBUG] Cookie header:', cookieHeader ? 'present' : 'missing');
export interface SessionData { if (!cookieHeader) return null;
userId: string;
authenticated: boolean; const cookies = parseCookie(cookieHeader);
exp: number; console.log('[DEBUG] Parsed cookies:', Object.keys(cookies));
console.log('[DEBUG] Session cookie found:', !!cookies.session);
return cookies.session || null;
} }
// Create a signed JWT session token export async function verifySession(sessionToken: string): Promise<SessionData | null> {
export async function createSession(userId: string): Promise<string> { try {
const exp = Math.floor(Date.now() / 1000) + SESSION_DURATION; console.log('[DEBUG] Verifying session token, length:', sessionToken.length);
const { payload } = await jwtVerify(sessionToken, SECRET_KEY);
console.log('[DEBUG] JWT verification successful, payload keys:', Object.keys(payload));
return await new SignJWT({ // Validate payload structure and cast properly
if (
typeof payload.userId === 'string' &&
typeof payload.email === 'string' &&
typeof payload.authenticated === 'boolean' &&
typeof payload.exp === 'number'
) {
console.log('[DEBUG] Session validation successful for user:', payload.userId);
return {
userId: payload.userId,
email: payload.email,
authenticated: payload.authenticated,
exp: payload.exp
};
}
console.log('[DEBUG] Session payload validation failed, payload:', payload);
return null;
} catch (error) {
console.log('[DEBUG] Session verification failed:', error.message);
return null;
}
}
export async function createSession(userId: string, email: string): Promise<string> {
const exp = Math.floor(Date.now() / 1000) + SESSION_DURATION;
console.log('[DEBUG] Creating session for user:', userId, 'exp:', exp);
const token = await new SignJWT({
userId, userId,
email,
authenticated: true, authenticated: true,
exp exp
}) })
.setProtectedHeader({ alg: 'HS256' }) .setProtectedHeader({ alg: 'HS256' })
.setExpirationTime(exp) .setExpirationTime(exp)
.sign(SECRET_KEY); .sign(SECRET_KEY);
console.log('[DEBUG] Session token created, length:', token.length);
return token;
} }
// Verify and decode a session token export function createSessionCookie(sessionToken: string): string {
export async function verifySession(token: string): Promise<SessionData | null> {
try {
const { payload } = await jwtVerify(token, SECRET_KEY);
// Validate payload structure and cast properly
if (
typeof payload.userId === 'string' &&
typeof payload.authenticated === 'boolean' &&
typeof payload.exp === 'number'
) {
return {
userId: payload.userId,
authenticated: payload.authenticated,
exp: payload.exp
};
}
return null;
} catch (error) {
console.log('Session verification failed:', error);
return null;
}
}
// Get session from request cookies
export function getSessionFromRequest(request: Request): string | null {
const cookieHeader = request.headers.get('cookie');
if (!cookieHeader) return null;
const cookies = parse(cookieHeader);
return cookies.session || null;
}
// Create session cookie
export function createSessionCookie(token: string): string {
const publicBaseUrl = getEnv('PUBLIC_BASE_URL'); const publicBaseUrl = getEnv('PUBLIC_BASE_URL');
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
const isSecure = publicBaseUrl.startsWith('https://') || isProduction; const isSecure = publicBaseUrl.startsWith('https://') || isProduction;
return serialize('session', token, { const cookie = serialize('session', sessionToken, {
httpOnly: true,
secure: isSecure,
sameSite: 'strict', // More secure than 'lax'
maxAge: SESSION_DURATION,
path: '/'
});
}
// Clear session cookie
export function clearSessionCookie(): string {
const publicBaseUrl = getEnv('PUBLIC_BASE_URL');
const isSecure = publicBaseUrl.startsWith('https://');
return serialize('session', '', {
httpOnly: true, httpOnly: true,
secure: isSecure, secure: isSecure,
sameSite: 'lax', sameSite: 'lax',
maxAge: 0, maxAge: SESSION_DURATION,
path: '/' path: '/'
}); });
console.log('[DEBUG] Created session cookie:', cookie.substring(0, 100) + '...');
return cookie;
}
// Authentication utility functions
export function getUserEmail(userInfo: UserInfo): string {
return userInfo.email || userInfo.preferred_username || 'unknown@example.com';
}
export function logAuthEvent(event: string, details?: any): void {
const timestamp = new Date().toISOString();
console.log(`[AUTH ${timestamp}] ${event}`, details ? JSON.stringify(details) : '');
}
// Generate random state for CSRF protection
export function generateState(): string {
return crypto.randomUUID();
} }
// Generate OIDC authorization URL // Generate OIDC authorization URL
@ -118,7 +166,7 @@ export function generateAuthUrl(state: string): string {
} }
// Exchange authorization code for tokens // Exchange authorization code for tokens
export async function exchangeCodeForTokens(code: string): Promise<any> { export async function exchangeCodeForTokens(code: string): Promise<{ access_token: string }> {
const oidcEndpoint = getEnv('OIDC_ENDPOINT'); const oidcEndpoint = getEnv('OIDC_ENDPOINT');
const clientId = getEnv('OIDC_CLIENT_ID'); const clientId = getEnv('OIDC_CLIENT_ID');
const clientSecret = getEnv('OIDC_CLIENT_SECRET'); const clientSecret = getEnv('OIDC_CLIENT_SECRET');
@ -147,7 +195,7 @@ export async function exchangeCodeForTokens(code: string): Promise<any> {
} }
// Get user info from OIDC provider // Get user info from OIDC provider
export async function getUserInfo(accessToken: string): Promise<any> { export async function getUserInfo(accessToken: string): Promise<UserInfo> {
const oidcEndpoint = getEnv('OIDC_ENDPOINT'); const oidcEndpoint = getEnv('OIDC_ENDPOINT');
const response = await fetch(`${oidcEndpoint}/apps/oidc/userinfo`, { const response = await fetch(`${oidcEndpoint}/apps/oidc/userinfo`, {
@ -165,13 +213,192 @@ export async function getUserInfo(accessToken: string): Promise<any> {
return await response.json(); return await response.json();
} }
// Generate random state for CSRF protection // Parse and validate auth state from cookies
export function generateState(): string { export function parseAuthState(request: Request): {
return crypto.randomUUID(); isValid: boolean;
stateData: AuthStateData | null;
error?: string
} {
try {
const cookieHeader = request.headers.get('cookie');
const cookies = cookieHeader ? parseCookie(cookieHeader) : {};
if (!cookies.auth_state) {
return { isValid: false, stateData: null, error: 'No auth state cookie' };
}
const stateData = JSON.parse(decodeURIComponent(cookies.auth_state));
if (!stateData.state || !stateData.returnTo) {
return { isValid: false, stateData: null, error: 'Invalid state data structure' };
}
return { isValid: true, stateData };
} catch (error) {
return { isValid: false, stateData: null, error: 'Failed to parse auth state' };
}
} }
// Log authentication events for debugging // Verify state parameter against stored state
export function logAuthEvent(event: string, details?: any) { export function verifyAuthState(request: Request, receivedState: string): {
const timestamp = new Date().toISOString(); isValid: boolean;
console.log(`[AUTH ${timestamp}] ${event}`, details ? JSON.stringify(details) : ''); stateData: AuthStateData | null;
error?: string;
} {
const { isValid, stateData, error } = parseAuthState(request);
if (!isValid || !stateData) {
logAuthEvent('State parsing failed', { error });
return { isValid: false, stateData: null, error };
}
if (stateData.state !== receivedState) {
logAuthEvent('State mismatch', {
received: receivedState,
stored: stateData.state
});
return {
isValid: false,
stateData: null,
error: 'State parameter mismatch'
};
}
return { isValid: true, stateData };
}
function getAuthRequirement(context: AuthContextType): boolean {
switch (context) {
case 'contributions':
return process.env.AUTHENTICATION_NECESSARY_CONTRIBUTIONS !== 'false';
case 'ai':
return process.env.AUTHENTICATION_NECESSARY_AI !== 'false';
case 'general':
return process.env.AUTHENTICATION_NECESSARY !== 'false';
default:
return true;
}
}
export async function createSessionWithCookie(userInfo: UserInfo): Promise<{
sessionToken: string;
sessionCookie: string;
clearStateCookie: string;
userId: string;
userEmail: string;
}> {
const userId = userInfo.sub || userInfo.preferred_username || 'unknown';
const userEmail = getUserEmail(userInfo);
const sessionToken = await createSession(userId, userEmail);
const sessionCookie = createSessionCookie(sessionToken);
const clearStateCookie = 'auth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0';
return {
sessionToken,
sessionCookie,
clearStateCookie,
userId,
userEmail
};
}
export async function withAuth(Astro: AstroGlobal, context: AuthContextType = 'general'): Promise<AuthContext | Response> {
const authRequired = getAuthRequirement(context);
console.log(`[DEBUG PAGE] Auth required for ${context}:`, authRequired);
console.log('[DEBUG PAGE] Request URL:', Astro.url.toString());
if (!authRequired) {
return {
authenticated: true,
session: null,
userEmail: 'anon@anon.anon',
userId: 'anonymous'
};
}
const sessionToken = getSessionFromRequest(Astro.request);
console.log('[DEBUG PAGE] Session token found:', !!sessionToken);
if (!sessionToken) {
console.log('[DEBUG PAGE] No session token, redirecting to login');
const loginUrl = `/api/auth/login?returnTo=${encodeURIComponent(Astro.url.toString())}`;
return new Response(null, {
status: 302,
headers: { 'Location': loginUrl }
});
}
const session = await verifySession(sessionToken);
console.log('[DEBUG PAGE] Session verification result:', !!session);
if (!session) {
console.log('[DEBUG PAGE] Session verification failed, redirecting to login');
const loginUrl = `/api/auth/login?returnTo=${encodeURIComponent(Astro.url.toString())}`;
return new Response(null, {
status: 302,
headers: { 'Location': loginUrl }
});
}
console.log(`[DEBUG PAGE] Page authentication successful for ${context}:`, session.userId);
return {
authenticated: true,
session,
userEmail: session.email,
userId: session.userId
};
}
export async function withAPIAuth(request: Request, context: AuthContextType = 'general'): Promise<{
authenticated: boolean;
userId: string;
session?: SessionData;
authRequired: boolean;
}> {
const authRequired = getAuthRequirement(context);
if (!authRequired) {
return {
authenticated: true,
userId: 'anonymous',
authRequired: false
};
}
const sessionToken = getSessionFromRequest(request);
console.log(`[DEBUG API] Session token found for ${context}:`, !!sessionToken);
if (!sessionToken) {
console.log(`[DEBUG API] No session token found for ${context}`);
return {
authenticated: false,
userId: '',
authRequired: true
};
}
const session = await verifySession(sessionToken);
console.log(`[DEBUG API] Session verification result for ${context}:`, !!session);
if (!session) {
console.log(`[DEBUG API] Session verification failed for ${context}`);
return {
authenticated: false,
userId: '',
authRequired: true
};
}
console.log(`[DEBUG API] Authentication successful for ${context}:`, session.userId);
return {
authenticated: true,
userId: session.userId,
session,
authRequired: true
};
}
export function getAuthRequirementForContext(context: AuthContextType): boolean {
return getAuthRequirement(context);
} }

View File

@ -0,0 +1,395 @@
// src/utils/gitContributions.ts
import { dump } from 'js-yaml';
export interface ContributionData {
type: 'add' | 'edit';
tool: {
name: string;
icon?: string;
type: 'software' | 'method' | 'concept';
description: string;
domains: string[];
phases: string[];
platforms: string[];
skillLevel: string;
accessType?: string;
url: string;
projectUrl?: string;
license?: string;
knowledgebase?: boolean;
'domain-agnostic-software'?: string[];
related_concepts?: string[];
tags: string[];
statusUrl?: string;
};
metadata: {
submitter: string;
reason?: string;
};
}
export interface GitOperationResult {
success: boolean;
message: string;
issueUrl?: string;
issueNumber?: number;
}
interface KnowledgebaseContribution {
toolName?: string;
title?: string;
description?: string;
content?: string;
externalLink?: string;
difficulty?: string;
categories?: string[];
tags?: string[];
uploadedFiles?: { name: string; url: string }[];
submitter: string;
reason?: string;
}
interface GitConfig {
provider: 'gitea' | 'github' | 'gitlab';
apiEndpoint: string;
apiToken: string;
repoOwner: string;
repoName: string;
}
export class GitContributionManager {
private config: GitConfig;
constructor() {
const repoUrl = process.env.GIT_REPO_URL || '';
const { owner, name } = this.parseRepoUrl(repoUrl);
this.config = {
provider: (process.env.GIT_PROVIDER as any) || 'gitea',
apiEndpoint: process.env.GIT_API_ENDPOINT || '',
apiToken: process.env.GIT_API_TOKEN || '',
repoOwner: owner,
repoName: name
};
if (!this.config.apiEndpoint || !this.config.apiToken) {
throw new Error('Missing required git configuration');
}
}
private parseRepoUrl(url: string): { owner: string; name: string } {
const match = url.match(/\/([^\/]+)\/([^\/]+?)(?:\.git)?$/);
if (!match) {
throw new Error('Invalid repository URL format');
}
return { owner: match[1], name: match[2] };
}
async submitContribution(data: ContributionData): Promise<GitOperationResult> {
try {
const toolYaml = this.generateYAML(data.tool);
const issueUrl = await this.createIssue(data, toolYaml);
return {
success: true,
message: 'Tool contribution submitted as issue',
issueUrl
};
} catch (error) {
throw new Error(`Issue creation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
async submitKnowledgebaseContribution(data: KnowledgebaseContribution): Promise<GitOperationResult> {
try {
const issueUrl = await this.createKnowledgebaseIssue(data);
return {
success: true,
message: 'Knowledge base article submitted as issue',
issueUrl
};
} catch (error) {
throw new Error(`KB issue creation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private generateYAML(tool: any): string {
// Clean tool object
const cleanTool: any = {
name: tool.name,
type: tool.type,
description: tool.description,
domains: tool.domains || [],
phases: tool.phases || [],
skillLevel: tool.skillLevel,
url: tool.url
};
// Add optional fields
if (tool.icon) cleanTool.icon = tool.icon;
if (tool.platforms?.length) cleanTool.platforms = tool.platforms;
if (tool.license) cleanTool.license = tool.license;
if (tool.accessType) cleanTool.accessType = tool.accessType;
if (tool.projectUrl) cleanTool.projectUrl = tool.projectUrl;
if (tool.knowledgebase) cleanTool.knowledgebase = tool.knowledgebase;
if (tool.related_concepts?.length) cleanTool.related_concepts = tool.related_concepts;
if (tool.tags?.length) cleanTool.tags = tool.tags;
if (tool['domain-agnostic-software']?.length) {
cleanTool['domain-agnostic-software'] = tool['domain-agnostic-software'];
}
return dump(cleanTool, {
lineWidth: -1,
noRefs: true,
quotingType: '"',
forceQuotes: false,
indent: 2
}).trim();
}
private async createIssue(data: ContributionData, toolYaml: string): Promise<string> {
const title = `${data.type === 'add' ? 'Add' : 'Update'} tool: ${data.tool.name}`;
const body = this.generateIssueBody(data, toolYaml);
let apiUrl: string;
let requestBody: any;
switch (this.config.provider) {
case 'gitea':
apiUrl = `${this.config.apiEndpoint}/repos/${this.config.repoOwner}/${this.config.repoName}/issues`;
requestBody = { title, body };
break;
case 'github':
apiUrl = `${this.config.apiEndpoint}/repos/${this.config.repoOwner}/${this.config.repoName}/issues`;
requestBody = { title, body };
break;
case 'gitlab':
apiUrl = `${this.config.apiEndpoint}/projects/${encodeURIComponent(this.config.repoOwner + '/' + this.config.repoName)}/issues`;
requestBody = { title, description: body };
break;
default:
throw new Error(`Unsupported git provider: ${this.config.provider}`);
}
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.config.apiToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
const issueData = await response.json();
// Extract issue URL
switch (this.config.provider) {
case 'gitea':
case 'github':
return issueData.html_url || issueData.url;
case 'gitlab':
return issueData.web_url;
default:
throw new Error('Unknown provider response format');
}
}
private async createKnowledgebaseIssue(data: KnowledgebaseContribution): Promise<string> {
const title = `Knowledge Base: ${data.title || data.toolName || 'New Article'}`;
const body = this.generateKnowledgebaseIssueBody(data);
let apiUrl: string;
let requestBody: any;
switch (this.config.provider) {
case 'gitea':
apiUrl = `${this.config.apiEndpoint}/repos/${this.config.repoOwner}/${this.config.repoName}/issues`;
requestBody = { title, body };
break;
case 'github':
apiUrl = `${this.config.apiEndpoint}/repos/${this.config.repoOwner}/${this.config.repoName}/issues`;
requestBody = { title, body };
break;
case 'gitlab':
apiUrl = `${this.config.apiEndpoint}/projects/${encodeURIComponent(this.config.repoOwner + '/' + this.config.repoName)}/issues`;
requestBody = { title, description: body };
break;
default:
throw new Error(`Unsupported git provider: ${this.config.provider}`);
}
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.config.apiToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
const issueData = await response.json();
// Extract issue URL
switch (this.config.provider) {
case 'gitea':
case 'github':
return issueData.html_url || issueData.url;
case 'gitlab':
return issueData.web_url;
default:
throw new Error('Unknown provider response format');
}
}
private generateIssueBody(data: ContributionData, toolYaml: string): string {
return `## ${data.type === 'add' ? 'Add' : 'Update'} Tool: ${data.tool.name}
**Submitted by:** ${data.metadata.submitter}
**Type:** ${data.tool.type}
**Action:** ${data.type}
### Tool Information
- **Name:** ${data.tool.name}
- **Description:** ${data.tool.description}
- **URL:** ${data.tool.url}
- **Skill Level:** ${data.tool.skillLevel}
${data.tool.platforms?.length ? `- **Platforms:** ${data.tool.platforms.join(', ')}` : ''}
${data.tool.license ? `- **License:** ${data.tool.license}` : ''}
${data.tool.domains?.length ? `- **Domains:** ${data.tool.domains.join(', ')}` : ''}
${data.tool.phases?.length ? `- **Phases:** ${data.tool.phases.join(', ')}` : ''}
${data.metadata.reason ? `### Reason
${data.metadata.reason}
` : ''}### Copy-Paste YAML
\`\`\`yaml
- ${toolYaml.split('\n').join('\n ')}
\`\`\`
### For Maintainers
1. Copy the YAML above
2. Add to \`src/data/tools.yaml\` in the tools array
3. Maintain alphabetical order
4. Close this issue when done
---
*Submitted via ForensicPathways contribution form*`;
}
private generateKnowledgebaseIssueBody(data: KnowledgebaseContribution): string {
const sections: string[] = [];
/* ------------------------------------------------------------------ */
/* Header */
/* ------------------------------------------------------------------ */
sections.push(`## Knowledge Base Article: ${data.title ?? 'Untitled'}`);
sections.push('');
sections.push(`**Submitted by:** ${data.submitter}`);
if (data.toolName) sections.push(`**Related Tool:** ${data.toolName}`);
if (data.difficulty) sections.push(`**Difficulty:** ${data.difficulty}`);
sections.push('');
/* ------------------------------------------------------------------ */
/* Description */
/* ------------------------------------------------------------------ */
if (data.description) {
sections.push('### Description');
sections.push(data.description);
sections.push('');
}
/* ------------------------------------------------------------------ */
/* Content */
/* ------------------------------------------------------------------ */
if (data.content) {
sections.push('### Article Content');
sections.push('```markdown');
sections.push(data.content);
sections.push('```');
sections.push('');
}
/* ------------------------------------------------------------------ */
/* External resources */
/* ------------------------------------------------------------------ */
if (data.externalLink) {
sections.push('### External Resource');
sections.push(`- [External Documentation](${data.externalLink})`);
sections.push('');
}
/* ------------------------------------------------------------------ */
/* Uploaded files */
/* ------------------------------------------------------------------ */
if (Array.isArray(data.uploadedFiles) && data.uploadedFiles.length) {
sections.push('### Uploaded Files');
data.uploadedFiles.forEach((file) => {
const fileType = file.name.toLowerCase();
let icon = '📎';
if (fileType.endsWith('.pdf')) icon = '📄';
else if (/(png|jpe?g|gif|webp)$/.test(fileType)) icon = '🖼️';
else if (/(mp4|webm|mov|avi)$/.test(fileType)) icon = '🎥';
else if (/(docx?)$/.test(fileType)) icon = '📝';
else if (/(zip|tar|gz)$/.test(fileType)) icon = '📦';
sections.push(`- ${icon} [${file.name}](${file.url})`);
});
sections.push('');
}
/* ------------------------------------------------------------------ */
/* Categories & Tags */
/* ------------------------------------------------------------------ */
const hasCategories = Array.isArray(data.categories) && data.categories.length > 0;
const hasTags = Array.isArray(data.tags) && data.tags.length > 0;
if (hasCategories || hasTags) {
sections.push('### Metadata');
if (hasCategories) sections.push(`**Categories:** ${data.categories!.join(', ')}`);
if (hasTags) sections.push(`**Tags:** ${data.tags!.join(', ')}`);
sections.push('');
}
/* ------------------------------------------------------------------ */
/* Reason */
/* ------------------------------------------------------------------ */
if (data.reason) {
sections.push('### Reason for Contribution');
sections.push(data.reason);
sections.push('');
}
/* ------------------------------------------------------------------ */
/* Footer */
/* ------------------------------------------------------------------ */
sections.push('### For Maintainers');
sections.push('1. Review the content for quality and accuracy');
sections.push('2. Create the appropriate markdown file in `src/content/knowledgebase/`');
sections.push('3. Process any uploaded files as needed');
sections.push('4. Close this issue when the article is published');
sections.push('');
sections.push('---');
sections.push('*Submitted via ForensicPathways knowledge base contribution form*');
return sections.join('\n');
}
}

434
src/utils/nextcloud.ts Normal file
View File

@ -0,0 +1,434 @@
// src/utils/nextcloud.ts
import { promises as fs } from 'fs';
import path from 'path';
import crypto from 'crypto';
interface NextcloudConfig {
endpoint: string;
username: string;
password: string;
uploadPath: string;
publicBaseUrl: string;
}
interface UploadResult {
success: boolean;
url?: string;
filename?: string;
error?: string;
size?: number;
}
interface FileValidation {
valid: boolean;
error?: string;
sanitizedName?: string;
}
export class NextcloudUploader {
private config: NextcloudConfig;
private allowedTypes: Set<string>;
private maxFileSize: number; // in bytes
constructor() {
this.config = {
endpoint: process.env.NEXTCLOUD_ENDPOINT || '',
username: process.env.NEXTCLOUD_USERNAME || '',
password: process.env.NEXTCLOUD_PASSWORD || '',
uploadPath: process.env.NEXTCLOUD_UPLOAD_PATH || '/kb-media',
publicBaseUrl: process.env.NEXTCLOUD_PUBLIC_URL || ''
};
// Allowed file types for knowledge base
this.allowedTypes = new Set([
// Images
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
// Videos
'video/mp4', 'video/webm', 'video/ogg', 'video/avi', 'video/mov',
// Documents
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
// Text files
'text/plain', 'text/csv', 'application/json',
// Archives (for tool downloads)
'application/zip', 'application/x-tar', 'application/gzip'
]);
this.maxFileSize = 50 * 1024 * 1024; // 50MB
}
/**
* Check if Nextcloud upload is properly configured
*/
isConfigured(): boolean {
return !!(this.config.endpoint &&
this.config.username &&
this.config.password &&
this.config.publicBaseUrl);
}
/**
* Validate file before upload
*/
private validateFile(file: File): FileValidation {
// Check file size
if (file.size > this.maxFileSize) {
return {
valid: false,
error: `File too large (max ${Math.round(this.maxFileSize / 1024 / 1024)}MB)`
};
}
// Check file type
if (!this.allowedTypes.has(file.type)) {
return {
valid: false,
error: `File type not allowed: ${file.type}`
};
}
// Sanitize filename
const sanitizedName = this.sanitizeFilename(file.name);
if (!sanitizedName) {
return {
valid: false,
error: 'Invalid filename'
};
}
return {
valid: true,
sanitizedName
};
}
/**
* Sanitize filename for safe storage
*/
private sanitizeFilename(filename: string): string {
// Remove or replace unsafe characters
const sanitized = filename
.replace(/[^a-zA-Z0-9._-]/g, '_') // Replace unsafe chars with underscore
.replace(/_{2,}/g, '_') // Replace multiple underscores with single
.replace(/^_|_$/g, '') // Remove leading/trailing underscores
.toLowerCase();
// Ensure reasonable length
if (sanitized.length > 100) {
const ext = path.extname(sanitized);
const base = path.basename(sanitized, ext).substring(0, 90);
return base + ext;
}
return sanitized;
}
/**
* Generate unique filename to prevent conflicts
*/
private generateUniqueFilename(originalName: string): string {
const timestamp = Date.now();
const randomId = crypto.randomBytes(4).toString('hex');
const ext = path.extname(originalName);
const base = path.basename(originalName, ext);
return `${timestamp}_${randomId}_${base}${ext}`;
}
/**
* Upload file to Nextcloud
*/
async uploadFile(file: File, category: string = 'general'): Promise<UploadResult> {
try {
if (!this.isConfigured()) {
return {
success: false,
error: 'Nextcloud not configured'
};
}
// Validate file
const validation = this.validateFile(file);
if (!validation.valid) {
return {
success: false,
error: validation.error
};
}
// Generate unique filename
const uniqueFilename = this.generateUniqueFilename(validation.sanitizedName!);
// Create category-based path
const categoryPath = this.sanitizeFilename(category);
const remotePath = `${this.config.uploadPath}/${categoryPath}/${uniqueFilename}`;
// **FIX: Ensure directory exists before upload**
const dirPath = `${this.config.uploadPath}/${categoryPath}`;
await this.ensureDirectoryExists(dirPath);
// Convert file to buffer
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Upload to Nextcloud via WebDAV
const uploadUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}${remotePath}`;
const response = await fetch(uploadUrl, {
method: 'PUT',
headers: {
'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`,
'Content-Type': file.type,
'Content-Length': buffer.length.toString()
},
body: buffer
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
}
// Generate public URL
const publicUrl = await this.createPublicLink(remotePath);
return {
success: true,
url: publicUrl,
filename: uniqueFilename,
size: file.size
};
} catch (error) {
console.error('Nextcloud upload error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Upload failed'
};
}
}
private async ensureDirectoryExists(dirPath: string): Promise<void> {
try {
// Split path and create each directory level
const parts = dirPath.split('/').filter(part => part);
let currentPath = '';
for (const part of parts) {
currentPath += '/' + part;
const mkcolUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}${currentPath}`;
const response = await fetch(mkcolUrl, {
method: 'MKCOL',
headers: {
'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`
}
});
// 201 = created, 405 = already exists, both are fine
if (response.status !== 201 && response.status !== 405) {
console.warn(`Directory creation failed: ${response.status} for ${currentPath}`);
}
}
} catch (error) {
console.warn('Failed to ensure directory exists:', error);
// Don't fail upload for directory creation issues
}
}
/**
* Create a public share link for the uploaded file
*/
private async createPublicLink(remotePath: string): Promise<string> {
try {
// Use Nextcloud's share API to create public link
const shareUrl = `${this.config.endpoint}/ocs/v2.php/apps/files_sharing/api/v1/shares`;
const formData = new FormData();
formData.append('path', remotePath);
formData.append('shareType', '3'); // Public link
formData.append('permissions', '1'); // Read only
const response = await fetch(shareUrl, {
method: 'POST',
headers: {
'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`,
'OCS-APIRequest': 'true'
},
body: formData
});
if (!response.ok) {
throw new Error('Failed to create public link');
}
const text = await response.text();
// Parse XML response to extract share URL
const urlMatch = text.match(/<url>(.*?)<\/url>/);
if (urlMatch) {
return urlMatch[1];
}
// Fallback to direct URL construction
return `${this.config.publicBaseUrl}${remotePath}`;
} catch (error) {
console.warn('Failed to create public link, using direct URL:', error);
// Fallback to direct URL
return `${this.config.publicBaseUrl}${remotePath}`;
}
}
/**
* Delete file from Nextcloud
*/
async deleteFile(remotePath: string): Promise<{ success: boolean; error?: string }> {
try {
if (!this.isConfigured()) {
return { success: false, error: 'Nextcloud not configured' };
}
const deleteUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}${remotePath}`;
const response = await fetch(deleteUrl, {
method: 'DELETE',
headers: {
'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`
}
});
if (response.ok || response.status === 404) {
return { success: true };
}
throw new Error(`Delete failed: ${response.status} ${response.statusText}`);
} catch (error) {
console.error('Nextcloud delete error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Delete failed'
};
}
}
/**
* Check Nextcloud connectivity and authentication
*/
async testConnection(): Promise<{ success: boolean; error?: string; details?: any }> {
try {
if (!this.isConfigured()) {
return {
success: false,
error: 'Nextcloud not configured',
details: {
hasEndpoint: !!this.config.endpoint,
hasUsername: !!this.config.username,
hasPassword: !!this.config.password,
hasPublicUrl: !!this.config.publicBaseUrl
}
};
}
// Test with a simple WebDAV request
const testUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}/`;
const response = await fetch(testUrl, {
method: 'PROPFIND',
headers: {
'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`,
'Depth': '0'
}
});
if (response.ok) {
return {
success: true,
details: {
endpoint: this.config.endpoint,
username: this.config.username,
uploadPath: this.config.uploadPath
}
};
}
throw new Error(`Connection failed: ${response.status} ${response.statusText}`);
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Connection test failed'
};
}
}
/**
* Get file information from Nextcloud
*/
async getFileInfo(remotePath: string): Promise<{ success: boolean; info?: any; error?: string }> {
try {
const propfindUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}${remotePath}`;
const response = await fetch(propfindUrl, {
method: 'PROPFIND',
headers: {
'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`,
'Depth': '0'
}
});
if (response.ok) {
const text = await response.text();
// Parse basic file info from WebDAV response
return {
success: true,
info: {
path: remotePath,
exists: true,
response: text.substring(0, 200) + '...' // Truncated for safety
}
};
}
if (response.status === 404) {
return {
success: true,
info: {
path: remotePath,
exists: false
}
};
}
throw new Error(`Failed to get file info: ${response.status}`);
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get file info'
};
}
}
}
// Convenience functions for easy usage
export async function uploadToNextcloud(file: File, category: string = 'general'): Promise<UploadResult> {
const uploader = new NextcloudUploader();
return await uploader.uploadFile(file, category);
}
export async function testNextcloudConnection(): Promise<{ success: boolean; error?: string; details?: any }> {
const uploader = new NextcloudUploader();
return await uploader.testConnection();
}
export function isNextcloudConfigured(): boolean {
const uploader = new NextcloudUploader();
return uploader.isConfigured();
}

View File

@ -0,0 +1,164 @@
// src/utils/rateLimitedQueue.ts
// ------------------------------------------------------------
// Enhanced FIFO queue with status tracking for visual feedback
// ------------------------------------------------------------
import dotenv from "dotenv";
dotenv.config();
/**
* Delay (in **milliseconds**) between two consecutive API calls.
* Defaults to **2000 ms** (2 seconds) when not set or invalid.
*/
const RATE_LIMIT_DELAY_MS = Number.parseInt(process.env.AI_RATE_LIMIT_DELAY_MS ?? "2000", 10) || 2000;
/**
* Internal task type with ID tracking for status updates
*/
export type Task<T = unknown> = () => Promise<T>;
interface QueuedTask {
id: string;
task: Task;
addedAt: number;
}
export interface QueueStatus {
queueLength: number;
isProcessing: boolean;
estimatedWaitTime: number; // in milliseconds
currentPosition?: number; // position of specific request
}
class RateLimitedQueue {
private queue: QueuedTask[] = [];
private processing = false;
private delayMs = RATE_LIMIT_DELAY_MS;
private lastProcessedAt = 0;
/**
* Schedule a task with ID tracking. Returns a Promise that resolves/rejects
* with the task result once the queue reaches it.
*/
add<T>(task: Task<T>, taskId?: string): Promise<T> {
const id = taskId || this.generateTaskId();
return new Promise<T>((resolve, reject) => {
this.queue.push({
id,
task: async () => {
try {
const result = await task();
resolve(result);
} catch (err) {
reject(err);
}
},
addedAt: Date.now()
});
this.process();
});
}
/**
* Get current queue status for visual feedback
*/
getStatus(taskId?: string): QueueStatus {
const queueLength = this.queue.length;
const now = Date.now();
// Calculate estimated wait time
let estimatedWaitTime = 0;
if (queueLength > 0) {
if (this.processing) {
// Time since last request + remaining delay + queue length * delay
const timeSinceLastRequest = now - this.lastProcessedAt;
const remainingDelay = Math.max(0, this.delayMs - timeSinceLastRequest);
estimatedWaitTime = remainingDelay + (queueLength - 1) * this.delayMs;
} else {
// Queue will start immediately, so just queue length * delay
estimatedWaitTime = queueLength * this.delayMs;
}
}
const status: QueueStatus = {
queueLength,
isProcessing: this.processing,
estimatedWaitTime
};
// Find position of specific task if ID provided
if (taskId) {
const position = this.queue.findIndex(item => item.id === taskId);
if (position >= 0) {
status.currentPosition = position + 1; // 1-based indexing for user display
}
}
return status;
}
/**
* Change the delay at runtime
*/
setDelay(ms: number): void {
if (!Number.isFinite(ms) || ms < 0) return;
this.delayMs = ms;
}
/**
* Get current delay setting
*/
getDelay(): number {
return this.delayMs;
}
// ---------------------------------------
// Internal helpers
// ---------------------------------------
private async process(): Promise<void> {
if (this.processing) return;
this.processing = true;
while (this.queue.length > 0) {
const next = this.queue.shift();
if (!next) continue;
this.lastProcessedAt = Date.now();
await next.task();
// Wait before the next one (only if there are more tasks)
if (this.queue.length > 0) {
await new Promise((r) => setTimeout(r, this.delayMs));
}
}
this.processing = false;
}
private generateTaskId(): string {
return `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
}
// ------------------------------------------------------------
// Export singleton instance and convenience functions
// ------------------------------------------------------------
const queue = new RateLimitedQueue();
/**
* Helper for convenience: `enqueueApiCall(() => fetch(...), 'optional-id')`.
*/
export function enqueueApiCall<T>(task: Task<T>, taskId?: string): Promise<T> {
return queue.add(task, taskId);
}
/**
* Get current queue status for visual feedback
*/
export function getQueueStatus(taskId?: string): QueueStatus {
return queue.getStatus(taskId);
}
export default queue;

View File

@ -1,40 +0,0 @@
import type { AstroGlobal } from 'astro';
import { getSessionFromRequest, verifySession, type SessionData } from './auth.js';
export interface AuthContext {
authenticated: boolean;
session: SessionData | null;
}
// Check authentication status for server-side pages
export async function getAuthContext(Astro: AstroGlobal): Promise<AuthContext> {
try {
const sessionToken = getSessionFromRequest(Astro.request);
if (!sessionToken) {
return { authenticated: false, session: null };
}
const session = await verifySession(sessionToken);
return {
authenticated: session !== null,
session
};
} catch (error) {
console.error('Failed to get auth context:', error);
return { authenticated: false, session: null };
}
}
// Redirect to login if not authenticated
export function requireAuth(authContext: AuthContext, currentUrl: string): Response | null {
if (!authContext.authenticated) {
const loginUrl = `/api/auth/login?returnTo=${encodeURIComponent(currentUrl)}`;
return new Response(null, {
status: 302,
headers: { 'Location': loginUrl }
});
}
return null;
}

69
src/utils/toolHelpers.ts Normal file
View File

@ -0,0 +1,69 @@
/**
* CONSOLIDATED Tool utility functions for consistent tool operations across the app
* Works in both server (Node.js) and client (browser) environments
*/
export interface Tool {
name: string;
type?: 'software' | 'method' | 'concept';
projectUrl?: string | null;
license?: string;
knowledgebase?: boolean;
domains?: string[];
phases?: string[];
platforms?: string[];
skillLevel?: string;
description?: string;
tags?: string[];
related_concepts?: string[];
}
/**
* Creates a URL-safe slug from a tool name
* Used for URLs, IDs, and file names consistently across the app
*/
export function createToolSlug(toolName: string): string {
if (!toolName || typeof toolName !== 'string') {
console.warn('[toolHelpers] Invalid toolName provided to createToolSlug:', toolName);
return '';
}
return toolName.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Remove duplicate hyphens
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
}
/**
* Finds a tool by name or slug from tools array
*/
export function findToolByIdentifier(tools: Tool[], identifier: string): Tool | undefined {
if (!identifier || !Array.isArray(tools)) return undefined;
return tools.find(tool =>
tool.name === identifier ||
createToolSlug(tool.name) === identifier.toLowerCase()
);
}
/**
* Checks if tool has a valid project URL (hosted on CC24 server)
*/
export function isToolHosted(tool: Tool): boolean {
return tool.projectUrl !== undefined &&
tool.projectUrl !== null &&
tool.projectUrl !== "" &&
tool.projectUrl.trim() !== "";
}
/**
* Determines tool category for styling/logic
*/
export function getToolCategory(tool: Tool): 'concept' | 'method' | 'hosted' | 'oss' | 'proprietary' {
if (tool.type === 'concept') return 'concept';
if (tool.type === 'method') return 'method';
if (isToolHosted(tool)) return 'hosted';
if (tool.license && tool.license !== 'Proprietary') return 'oss';
return 'proprietary';
}