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 = {
"knowledgebase": Record<string, {
id: string;
render(): Render[".md"];
slug: string;
body: string;
body?: string;
collection: "knowledgebase";
data: InferEntrySchema<"knowledgebase">;
data: any;
rendered?: RenderedContent;
filePath?: string;
}>;

File diff suppressed because one or more lines are too long

View File

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

View File

@ -1,18 +1,39 @@
# AI Configuration
AI_API_ENDPOINT=https://aiendpoint.org
AI_API_KEY=your_apikey_here
AI_MODEL='ai_model_name_here'
# ===========================================
# ForensicPathways Environment Configuration
# ===========================================
# OIDC Configuration
OIDC_ENDPOINT=https://oidc-provider.org
OIDC_CLIENT_ID=your_oidc_client_id
OIDC_CLIENT_SECRET=your_oidc_client_secret
AUTH_SECRET=your_super_secret_jwt_key_that_should_be_at_least_32_characters_long_for_security
# Authentication & OIDC (Required)
AUTH_SECRET=change-this-to-a-strong-secret-key-in-production
OIDC_ENDPOINT=https://your-oidc-provider.com
OIDC_CLIENT_ID=your-oidc-client-id
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
PUBLIC_BASE_URL=http://localhost:4321
# AI Service Configuration (Required for AI features)
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/
temp/
.astro/data-store.json
.astro/settings.json
.astro/data-store.json
.astro/content.d.ts

820
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
- **Drei Kategorien**: Software-Tools, forensische Methoden UND Grundlagenkonzepte
- **Matrix-Ansicht**: Visualisierung nach Domänen × Prozess-Phasen
- **Erweiterte Filter**: Suche nach Name, Tags, Domäne, Phase, Lizenz
- **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
### 📚 Inhaltstypen
- **Software/Tools:** Open Source und proprietäre forensische Software
- **Methoden:** Bewährte forensische Verfahren und Prozesse
- **Konzepte:** Grundlegendes Fachwissen und theoretische Grundlagen
## 🛠️ 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
- **Backend**: Node.js mit API-Routen für KI und Authentifizierung
- **Styling**: Vanilla CSS mit CSS Custom Properties
- **Datenformat**: YAML für Tool-/Methoden-/Konzept-Definitionen
- **KI-Integration**: Mistral AI über OpenAI-kompatible API
- **Authentifizierung**: OIDC (OpenID Connect) mit JWT-Sessions
- **Node.js**: >=18.0.0
### 🤝 Contribution-System
- **Tool/Methoden-Beiträge:** Webformular für neue Einträge
- **Knowledgebase-Artikel:** Artikel-Editor mit Datei-Upload
- **Git-Integration:** Automatische Issue-Erstellung für Review-Prozess
- **File-Management:** Nextcloud-Integration für Medien-Uploads
## 🚀 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
@ -40,6 +84,10 @@ cd cc24-hub
# Dependencies installieren
npm install
# Umgebungsvariablen konfigurieren
cp .env.example .env
# .env bearbeiten (siehe Konfiguration unten)
# Development Server starten
npm run dev
```
@ -48,150 +96,128 @@ Die Seite ist dann unter `http://localhost:4321` verfügbar.
### Produktions-Deployment
#### Voraussetzungen
#### 1. System vorbereiten
- Ubuntu/Debian server
- Node.js 18+
- Nginx
- Domain
- SSL Zertifikat
```bash
# System-Updates
sudo apt update && sudo apt upgrade -y
#### 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
# Klonen des Repositorys
git clone https://git.cc24.dev/mstoeck3/cc24-hub
cd cc24-hub
sudo git clone https://git.cc24.dev/mstoeck3/cc24-hub /opt/cc24-hub
cd /opt/cc24-hub
# Abhängigkeiten installieren
npm install
sudo npm install
# Production-Build anstoßen
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
# Production-Build erstellen
sudo npm run build
# 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
# AI Konfiguration
AI_API_ENDPOINT=https://llm.mikoshi.de # hier geeigneten Endpunkt setzen
AI_API_KEY=your_ai_api_key
AI_MODEL=mistral/mistral-small-latest # hier geeignetes KI-Modell wählen
# === GRUNDKONFIGURATION ===
NODE_ENV=production
PUBLIC_BASE_URL=https://ihre-domain.de
# 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
OIDC_ENDPOINT=https://cloud.cc24.dev
OIDC_CLIENT_ID=your_oidc_client_id
OIDC_CLIENT_SECRET=your_oidc_client_secret
AUTH_SECRET=your_super_secret_jwt_key_min_32_chars
OIDC_ENDPOINT=https://ihr-oidc-provider.de
OIDC_CLIENT_ID=cc24-hub-client
OIDC_CLIENT_SECRET=your_super_secret_client_secret
AUTH_SECRET=your_jwt_secret_min_32_characters_long
# Public Configuration
PUBLIC_BASE_URL=https://your-domain.com # hier die URL setzen, mit der von außen zugegriffen wird
# === NEXTCLOUD (Optional) ===
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
# .env sichern
sudo chmod 600 /var/www/cc24-hub/.env
sudo chown www-data:www-data /var/www/cc24-hub/.env
# Berechtigungen sichern
sudo chmod 600 /opt/cc24-hub/.env
sudo chown www-data:www-data /opt/cc24-hub/.env
```
##### 4. Systemd-Service erstellen
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
#### 4. Nginx konfigurieren
Erstelle `/etc/nginx/sites-available/cc24-hub`:
```nginx
server {
listen 80;
server_name your-domain.com;
server_name ihre-domain.de;
# Redirect HTTP to HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name your-domain.com;
# SSL Configuration (adjust paths for your certificates)
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
server_name ihre-domain.de;
# SSL Konfiguration (Let's Encrypt empfohlen)
ssl_certificate /etc/letsencrypt/live/ihre-domain.de/fullchain.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
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Proxy to Node.js application
add_header Referrer-Policy "strict-origin-when-cross-origin";
# Static Files
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_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
@ -203,360 +229,248 @@ server {
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
# Optional: Serve static assets directly (performance optimization)
location /_astro/ {
proxy_pass http://localhost:3000;
expires 1y;
add_header Cache-Control "public, immutable";
}
# Upload limit
client_max_body_size 50M;
}
```
##### 6. Daemon starten und Autostart setzen
```bash
# Enable Nginx site
# Site aktivieren
sudo ln -s /etc/nginx/sites-available/cc24-hub /etc/nginx/sites-enabled/
sudo nginx -t
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 enable cc24-hub
sudo systemctl start cc24-hub
# Check status
# Status prüfen
sudo systemctl status cc24-hub
```
##### 7. Deployment verifizieren
## 🔧 Konfiguration
### Minimalkonfiguration (ohne Auth)
```bash
# Check application logs
sudo journalctl -u cc24-hub -f
# Check if app is responding
curl http://localhost:3000
# Check external access
curl https://your-domain.com
# Nur für Tests geeignet
AUTHENTICATION_NECESSARY=false
PUBLIC_BASE_URL=http://localhost:4321
```
#### OIDC Konfigurieren
### Tools-Datenbank
Nextcloud OIDC Einstellungen (sollte auch mit anderen OIDC-Anwendungen klappen):
- **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
Die Tools werden in `src/data/tools.yaml` verwaltet. Vollständiges Beispiel:
```yaml
tools:
- name: "Autopsy"
icon: "📦"
type: "software"
description: "Die führende Open-Source-Alternative zu kommerziellen Forensik-Suiten"
domains: ["incident-response", "law-enforcement"]
phases: ["examination", "analysis"]
platforms: ["Windows", "Linux"]
skillLevel: "intermediate"
accessType: "download"
url: "https://www.autopsy.com/"
projectUrl: "https://autopsy.cc24.dev" # CC24-Server URL (optional)
license: "Apache 2.0"
knowledgebase: true # Hat erweiterte Dokumentation
related_concepts: ["Hash Functions & Digital Signatures", "SQL Query Fundamentals"] # Verknüpfung zu Konzepten
tags: ["gui", "filesystem", "timeline-analysis"]
statusUrl: "https://status.example.com/badge/1/status" # Status-Badge URL (optional)
```
### 2. Forensische Methoden
```yaml
- name: "Live Memory Acquisition Procedure"
icon: "📋"
type: "method"
description: "Standardisiertes Verfahren zur forensisch korrekten Akquisition des Arbeitsspeichers"
domains: ["incident-response", "law-enforcement"]
phases: ["data-collection"]
platforms: [] # Methoden haben keine Plattformen
skillLevel: "advanced"
accessType: null
url: "https://www.nist.gov/publications/guide-integrating-forensic-techniques"
projectUrl: null
license: null
- name: Autopsy
type: software # software|method|concept
description: >-
Die führende Open-Source-Alternative zu kommerziellen Forensik-Suiten mit
intuitiver grafischer Oberfläche. Besonders stark in der Timeline-Analyse,
Keyword-Suche und dem Carving gelöschter Dateien. Die modulare
Plugin-Architektur erlaubt Erweiterungen für spezielle
Untersuchungsszenarien.
icon: 📦
skillLevel: intermediate # novice|beginner|intermediate|advanced|expert
url: https://www.autopsy.com/
domains:
- incident-response
- static-investigations
- malware-analysis
- 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
# 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
# Beispiel Konzept
- name: Hash Functions & Digital Signatures
type: concept
description: >-
Kryptographische Grundlagen für Datenintegrität und
Authentifizierung in der digitalen Forensik.
icon: 🔐
skillLevel: intermediate
url: https://en.wikipedia.org/wiki/Cryptographic_hash_function
domains:
- incident-response
- static-investigations
- malware-analysis
phases:
- data-collection
- examination
tags:
- cryptography
- data-integrity
- evidence-preservation
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
# Anwendungs-Logs
sudo journalctl -u cc24-hub -f
# Repository aktualisieren
cd /opt/cc24-hub
sudo git pull
# Development-Modus
npm run dev
# Dependencies aktualisieren
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: {
port: 4321,
host: true
}
});
},
allowImportingTsExtensions: true
});

File diff suppressed because it is too large Load Diff

View File

@ -7,20 +7,19 @@
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"check:health": "curl -f http://localhost:4321/health || exit 1"
"astro": "astro"
},
"dependencies": {
"@astrojs/node": "^9.3.0",
"astro": "^5.12.0",
"cookie": "^0.6.0",
"astro": "^5.12.3",
"cookie": "^1.0.2",
"dotenv": "^16.4.5",
"jose": "^5.2.0",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/cookie": "^0.6.0",
"@types/js-yaml": "^4.0.9"
},
"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="16" x2="12.01" y2="16"/>
</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>
</p>
</div>
@ -81,7 +81,8 @@ const domainAgnosticSoftware = data['domain-agnostic-software'] || [];
</div>
</div>
</div>
<!-- This should be your loading section in AIQueryInterface.astro -->
<!-- Loading State -->
<div id="ai-loading" class="ai-loading" style="display: none; text-align: center; padding: 2rem;">
<div style="display: inline-block; margin-bottom: 1rem;">
@ -92,6 +93,32 @@ const domainAgnosticSoftware = data['domain-agnostic-software'] || [];
</svg>
</div>
<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>
<!-- Error State -->
@ -240,86 +267,161 @@ document.addEventListener('DOMContentLoaded', () => {
aiInput.addEventListener('input', updateCharacterCount);
updateCharacterCount();
// Submit handler
const handleSubmit = async () => {
const query = aiInput.value.trim();
if (!query) {
alert('Bitte geben Sie eine Beschreibung ein.');
return;
}
if (query.length < 10) {
alert('Bitte geben Sie eine detailliertere Beschreibung ein (mindestens 10 Zeichen).');
return;
}
// Hide previous results and errors
aiResults.style.display = 'none';
aiError.style.display = 'none';
aiLoading.style.display = 'block';
// Disable submit button
aiSubmitBtn.disabled = true;
submitBtnText.textContent = currentMode === 'workflow' ? 'Generiere Empfehlungen...' : 'Suche passende Methode...';
try {
const response = await fetch('/api/ai/query', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
mode: currentMode
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `HTTP ${response.status}`);
}
if (!data.success) {
throw new Error(data.error || 'Unknown error');
}
// Store recommendation for restoration
currentRecommendation = data.recommendation;
// Submit handler with enhanced queue feedback
const handleSubmit = async () => {
const query = aiInput.value.trim();
// Display results based on mode
if (currentMode === 'workflow') {
displayWorkflowResults(data.recommendation, query);
} else {
displayToolResults(data.recommendation, query);
if (!query) {
alert('Bitte geben Sie eine Beschreibung ein.');
return;
}
if (query.length < 10) {
alert('Bitte geben Sie eine detailliertere Beschreibung ein (mindestens 10 Zeichen).');
return;
}
// Generate task ID for tracking
const taskId = `ai_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
// Hide previous results and errors
aiResults.style.display = 'none';
aiError.style.display = 'none';
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;
}
aiLoading.style.display = 'none';
aiResults.style.display = 'block';
// Disable submit button
aiSubmitBtn.disabled = true;
submitBtnText.textContent = currentMode === 'workflow' ? 'Generiere Empfehlungen...' : 'Suche passende Methode...';
} catch (error) {
console.error('AI query failed:', error);
aiLoading.style.display = 'none';
aiError.style.display = 'block';
// Start queue status polling
let statusInterval;
let startTime = Date.now();
// Show user-friendly error messages
if (error.message.includes('429')) {
aiErrorMessage.textContent = 'Zu viele Anfragen. Bitte warten Sie einen Moment und versuchen Sie es erneut.';
} else if (error.message.includes('401')) {
aiErrorMessage.textContent = 'Authentifizierung erforderlich. Bitte melden Sie sich an.';
} else if (error.message.includes('503')) {
aiErrorMessage.textContent = 'KI-Service vorübergehend nicht verfügbar. Bitte versuchen Sie es später erneut.';
} else {
aiErrorMessage.textContent = `Fehler: ${error.message}`;
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 {
const response = await fetch('/api/ai/query', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
mode: currentMode,
taskId // Include task ID for backend tracking
})
});
const data = await response.json();
// Clear status polling
if (statusInterval) clearInterval(statusInterval);
if (!response.ok) {
throw new Error(data.error || `HTTP ${response.status}`);
}
if (!data.success) {
throw new Error(data.error || 'Unknown error');
}
// Store recommendation for restoration
currentRecommendation = data.recommendation;
// Display results based on mode
if (currentMode === 'workflow') {
displayWorkflowResults(data.recommendation, query);
} else {
displayToolResults(data.recommendation, query);
}
aiLoading.style.display = 'none';
aiResults.style.display = 'block';
} catch (error) {
console.error('AI query failed:', error);
// Clear status polling
if (statusInterval) clearInterval(statusInterval);
aiLoading.style.display = 'none';
aiError.style.display = 'block';
// Show user-friendly error messages
if (error.message.includes('429')) {
aiErrorMessage.textContent = 'Zu viele Anfragen. Bitte warten Sie einen Moment und versuchen Sie es erneut.';
} else if (error.message.includes('401')) {
aiErrorMessage.textContent = 'Authentifizierung erforderlich. Bitte melden Sie sich an.';
} else if (error.message.includes('503')) {
aiErrorMessage.textContent = 'KI-Service vorübergehend nicht verfügbar. Bitte versuchen Sie es später erneut.';
} else {
aiErrorMessage.textContent = `Fehler: ${error.message}`;
}
} finally {
// Re-enable submit button and hide queue status
aiSubmitBtn.disabled = false;
const config = modeConfig[currentMode];
submitBtnText.textContent = config.submitText;
if (queueStatus) queueStatus.style.display = 'none';
if (statusInterval) clearInterval(statusInterval);
}
} finally {
// Re-enable submit button
aiSubmitBtn.disabled = false;
const config = modeConfig[currentMode];
submitBtnText.textContent = config.submitText;
}
};
};
// Event listeners
aiSubmitBtn.addEventListener('click', handleSubmit);
@ -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;">
<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>
Passende Tool-Empfehlungen
Passende Empfehlungen
</h3>
<p style="margin: 0; opacity: 0.9; line-height: 1.5;">
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>
<p class="text-muted" style="margin: 0;">
© 2025 CC24-Guide - Lizensiert unter BSD-3-Clause
© 2025 ForensicPathways - Lizensiert unter BSD-3-Clause
</p>
</div>
<div style="display: flex; gap: 2rem; align-items: center;">

View File

@ -1,4 +1,5 @@
---
// src/components/Navigation.astro
import ThemeToggle from './ThemeToggle.astro';
const currentPath = Astro.url.pathname;
@ -8,9 +9,9 @@ const currentPath = Astro.url.pathname;
<div class="container">
<div class="nav-wrapper">
<a href="/" class="nav-brand">
<img src="/logo-dark.png" alt="CC24-Guide" class="nav-logo nav-logo-light" />
<img src="/logo-white.png" alt="CC24-Guide" class="nav-logo nav-logo-dark" />
<span style="font-weight: 600; font-size: 1.125rem;">CC24-Guide</span>
<img src="/logo-dark.png" alt="ForensicPathways" class="nav-logo nav-logo-light" />
<img src="/logo-white.png" alt="ForensicPathways" class="nav-logo nav-logo-dark" />
<span style="font-weight: 600; font-size: 1.125rem;">ForensicPathways</span>
</a>
<ul class="nav-links">
@ -24,6 +25,11 @@ const currentPath = Astro.url.pathname;
~/knowledgebase
</a>
</li>
<li>
<a href="/contribute" class={`nav-link ${currentPath.startsWith('/contribute') ? 'active' : ''}`}>
~/contribute
</a>
</li>
<li>
<a href="/status" class={`nav-link ${currentPath === '/status' ? 'active' : ''}`}>
~/status
@ -40,35 +46,4 @@ const currentPath = Astro.url.pathname;
</ul>
</div>
</div>
</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>
</nav>

View File

@ -1,4 +1,6 @@
---
import { createToolSlug } from '../utils/toolHelpers.js';
export interface Props {
toolName: string;
context: 'card' | 'modal-primary' | 'modal-secondary';
@ -7,12 +9,8 @@ export interface Props {
const { toolName, context, size = 'small' } = Astro.props;
// Create URL-safe slug from tool name
const toolSlug = 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
// AFTER: Single line with centralized function
const toolSlug = createToolSlug(toolName);
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 {
tool: {
name: string;
@ -35,99 +38,114 @@ const hasValidProjectUrl = tool.projectUrl !== undefined &&
const hasKnowledgebase = tool.knowledgebase === true;
// Determine card styling based on type and hosting status
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');
const cardClass = isConcept ? 'card card-concept tool-card cursor-pointer' :
isMethod ? 'card card-method tool-card cursor-pointer' :
hasValidProjectUrl ? 'card card-hosted tool-card cursor-pointer' :
(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)'};">
<!-- Card Header with Fixed Height -->
<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">
<!-- Only show CC24-Server and Knowledgebase badges -->
{!isMethod && hasValidProjectUrl && <span class="badge badge-primary">CC24-Server</span>}
{hasKnowledgebase && <span class="badge badge-error">📖</span>}
</div>
<div
class={cardClass}
{...toolDataAttributes}
onclick={`window.showToolDetails('${tool.name}')`}
>
<!-- Card Header with Fixed Height -->
<div class="tool-card-header">
<h3>
{tool.icon && <span class="mr-2 text-lg">{tool.icon}</span>}
{tool.name}
</h3>
<div class="tool-card-badges">
<!-- Only show CC24-Server and Knowledgebase badges -->
{!isMethod && hasValidProjectUrl && <span class="badge badge-primary">CC24-Server</span>}
{hasKnowledgebase && <span class="badge badge-error">📖</span>}
<ShareButton toolName={tool.name} context="card" size="small" />
</div>
</div>
<!-- Description - Truncated to 2 lines -->
<p class="text-muted">
{tool.description}
</p>
<!-- Metadata - Compact Icons with Better Alignment -->
<div class="tool-card-metadata flex items-center gap-4 mb-3" style="line-height: 1;">
<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" class="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 class="overflow-hidden min-w-0" style="text-overflow: ellipsis; white-space: nowrap;">
{tool.platforms.slice(0, 2).join(', ')}{tool.platforms.length > 2 ? '...' : ''}
</span>
</div>
<!-- Description - Truncated to 2 lines -->
<p class="text-muted">
{tool.description}
</p>
<!-- 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="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.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;">
{isMethod ? 'Methode' : tool.license === 'Proprietary' ? 'Prop.' : tool.license?.split(' ')[0]}
</span>
</div>
<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" class="flex-shrink-0">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 6v6l4 2"></path>
</svg>
<span class="overflow-hidden min-w-0" style="text-overflow: ellipsis; white-space: nowrap;">
{tool.skillLevel}
</span>
</div>
<!-- Tags - Two Lines with Fade -->
<div class="tool-tags-container">
{tool.tags.slice(0, 8).map(tag => (
<span class="tag">{tag}</span>
))}
<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" 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>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
<span class="overflow-hidden min-w-0" style="text-overflow: ellipsis; white-space: nowrap;">
{isConcept ? 'Konzept' : isMethod ? 'Methode' : tool.license === 'Proprietary' ? 'Prop.' : tool.license?.split(' ')[0]}
</span>
</div>
<!-- Buttons - Fixed at Bottom -->
<div class="tool-card-buttons" onclick="event.stopPropagation();">
{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);">
Mehr erfahren
</div>
<!-- Tags - Two Lines with Fade -->
<div class="tool-tags-container">
{tool.tags.slice(0, 8).map(tag => (
<span class="tag">{tag}</span>
))}
</div>
<!-- Buttons - Fixed at Bottom (NO EDIT BUTTONS - Available in modals) -->
<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>
) : 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);">
Zur Methode
<a href={tool.projectUrl} target="_blank" rel="noopener noreferrer" class="btn btn-primary">
Zugreifen
</a>
) : hasValidProjectUrl ? (
<!-- Two buttons for hosted tools -->
<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>
) : (
<!-- Single button for external tools -->
<a href={tool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary single-button">
Software-Homepage
</a>
)}
</div>
</div>
) : (
<a href={tool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary single-button">
Software-Homepage
</a>
)}
</div>
</div>
</div>

View File

@ -1,11 +1,9 @@
---
import { getToolsData } from '../utils/dataService.js';
// Load tools data
const data = await getToolsData();
const domains = data.domains;
const phases = data.phases;
@ -106,7 +104,6 @@ const sortedTags = Object.entries(tagFrequency)
</div>
<!-- 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;">
<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>
@ -127,7 +124,6 @@ const sortedTags = Object.entries(tagFrequency)
</div>
</div>
<script define:vars={{ toolsData: data.tools, tagFrequency, sortedTags }}>
// Store tools data globally for filtering
window.toolsData = toolsData;
@ -149,26 +145,6 @@ const sortedTags = Object.entries(tagFrequency)
let selectedPhase = '';
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
function initTagCloud() {
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() {
const searchTerm = searchInput.value.toLowerCase();
const selectedDomain = domainSelect.value;
@ -310,7 +286,7 @@ const sortedTags = Object.entries(tagFrequency)
const phases = tool.phases || [];
const tags = tool.tags || [];
// Search filter
// Search filter - more comprehensive
if (searchTerm && !(
tool.name.toLowerCase().includes(searchTerm) ||
tool.description.toLowerCase().includes(searchTerm) ||
@ -329,12 +305,12 @@ const sortedTags = Object.entries(tagFrequency)
return false;
}
// Proprietary filter (skip for methods since they don't have licenses)
if (!includeProprietary && !isMethod(tool) && tool.license === 'Proprietary') {
// Proprietary filter (skip for methods and concepts since they don't have licenses)
if (!includeProprietary && !isMethod(tool) && tool.type !== 'concept' && tool.license === 'Proprietary') {
return false;
}
// Tag filter
// Tag filter - ensure all selected tags are present
if (selectedTags.size > 0 && !Array.from(selectedTags).every(tag => tags.includes(tag))) {
return false;
}
@ -344,6 +320,7 @@ const sortedTags = Object.entries(tagFrequency)
// Update matrix highlighting
updateMatrixHighlighting();
// Emit custom event with filtered results
window.dispatchEvent(new CustomEvent('toolsFiltered', { detail: filtered }));
}

View File

@ -2,8 +2,6 @@
import { getToolsData } from '../utils/dataService.js';
import ShareButton from './ShareButton.astro';
// Load tools data
const data = await getToolsData();
@ -37,17 +35,17 @@ domains.forEach((domain: any) => {
<div id="matrix-container" class="matrix-wrapper" style="display: none;">
<!-- Domain-Agnostic Software Sections -->
{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 class="collaboration-header" onclick={`toggleDomainAgnosticSection('${sectionData.section.id}')`} style="cursor: pointer; display: flex; align-items: center; gap: 0.75rem; 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;">
<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 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" class="mr-2">
<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>
<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}
<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}
</span>
</h3>
@ -65,14 +63,14 @@ domains.forEach((domain: any) => {
tool.projectUrl !== "" &&
tool.projectUrl.trim() !== "";
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}')`}>
<div class="tool-compact-header">
<h4 style="margin: 0; font-size: 0.875rem; font-weight: 600;">
{tool.icon && <span style="margin-right: 0.5rem;">{tool.icon}</span>}
<h4 class="m-0 text-sm font-semibold">
{tool.icon && <span class="mr-2">{tool.icon}</span>}
{tool.name}
</h4>
<div style="display: flex; gap: 0.25rem;">
<div class="flex gap-1">
{hasValidProjectUrl && <span class="badge badge--mini badge-primary">CC24-Server</span>}
{tool.knowledgebase === true && <span class="badge badge--mini badge-error">📖</span>}
</div>
@ -80,7 +78,7 @@ domains.forEach((domain: any) => {
<p class="text-muted">
{tool.description}
</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>•</span>
<span>{tool.skillLevel}</span>
@ -95,7 +93,7 @@ domains.forEach((domain: any) => {
<!-- DFIR Tools Matrix -->
<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">
<thead>
<tr>
@ -128,7 +126,7 @@ domains.forEach((domain: any) => {
title={`${tool.name}${tool.knowledgebase === true ? ' (KB verfügbar)' : ''}`}
>
{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>
);
})}
@ -146,12 +144,15 @@ domains.forEach((domain: any) => {
<!-- Primary Modal -->
<div class="tool-details" id="tool-details-primary">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 1rem;">
<h2 id="tool-name-primary" style="margin: 0;">Tool Name</h2>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<div class="flex justify-between items-start mb-4">
<h2 id="tool-name-primary" class="m-0">Tool Name</h2>
<div class="flex items-center gap-2">
<div id="share-button-primary" style="display: none;">
<!-- Share button will be populated by JavaScript -->
</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')">
<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>
@ -163,23 +164,26 @@ domains.forEach((domain: any) => {
<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>
<!-- Secondary Modal -->
<div class="tool-details" id="tool-details-secondary">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 1rem;">
<h2 id="tool-name-secondary" style="margin: 0;">Tool Name</h2>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<div class="flex justify-between items-start mb-4">
<h2 id="tool-name-secondary" class="m-0">Tool Name</h2>
<div class="flex items-center gap-2">
<div id="share-button-secondary" style="display: none;">
<!-- Share button will be populated by JavaScript -->
</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')">
<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>
@ -191,13 +195,13 @@ domains.forEach((domain: any) => {
<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>
<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
function generateShareURL(toolName, view, modal = null) {
const toolSlug = createToolSlug(toolName);
const toolSlug = window.createToolSlug(toolName);
const baseUrl = window.location.origin + window.location.pathname;
const params = new URLSearchParams();
params.set('tool', toolSlug);
@ -353,10 +338,18 @@ domains.forEach((domain: any) => {
backdrop = document.createElement('div');
backdrop.id = 'share-modal-backdrop';
backdrop.style.cssText = `
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5); z-index: 9999;
display: flex; align-items: center; justify-content: center;
opacity: 0; transition: opacity 0.2s ease;
position: fixed;
top: 0;
left: 0;
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);
}
@ -364,21 +357,27 @@ domains.forEach((domain: any) => {
// Create share dialog
const dialog = document.createElement('div');
dialog.style.cssText = `
background: var(--color-bg); border: 1px solid var(--color-border);
border-radius: 0.75rem; padding: 1.5rem; max-width: 400px; width: 90%;
box-shadow: var(--shadow-lg); transform: scale(0.9); transition: transform 0.2s ease;
background-color: var(--color-bg);
border: 1px solid var(--color-border);
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 = `
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem;">
<h3 style="margin: 0; color: var(--color-primary);">
<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;">
<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;">
<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>
${toolName} teilen
</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">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
@ -386,44 +385,54 @@ domains.forEach((domain: any) => {
</div>
<div style="display: flex; flex-direction: column; gap: 0.75rem;">
<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: var(--color-bg); cursor: pointer; transition: var(--transition-fast); text-align: left; width: 100%;">
<div style="width: 32px; height: 32px; background: var(--color-primary); border-radius: 0.25rem; display: flex; align-items: center; justify-content: center;">
<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)'">
<div style="width: 2rem; height: 2rem; background-color: 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">
<rect x="3" y="3" 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"/>
<rect x="3" y="3" 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>
</div>
<div>
<div style="font-weight: 500; margin-bottom: 0.25rem;color: var(--color-text-secondary)">Kachelansicht</div>
<div style="font-size: 0.8125rem; color: var(--color-text-secondary);">Scrollt zur Karte in der Übersicht</div>
<div style="font-weight: 500; margin-bottom: 0.25rem; color: var(--color-text);">Kachelansicht</div>
<div style="font-size: 0.875rem; color: var(--color-text-secondary);">Scrollt zur Karte in der Übersicht</div>
</div>
</button>
<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: var(--color-bg); cursor: pointer; transition: var(--transition-fast); text-align: left; width: 100%;">
<div style="width: 32px; height: 32px; background: var(--color-accent); border-radius: 0.25rem; display: flex; align-items: center; justify-content: center;">
<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)'">
<div style="width: 2rem; height: 2rem; background-color: 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">
<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>
</div>
<div>
<div style="font-weight: 500; margin-bottom: 0.25rem;color: var(--color-text-secondary)">Matrix-Ansicht</div>
<div style="font-size: 0.8125rem; color: var(--color-text-secondary);">Zeigt Tool-Position in der Matrix</div>
<div style="font-weight: 500; margin-bottom: 0.25rem; color: var(--color-text);">Matrix-Ansicht</div>
<div style="font-size: 0.875rem; color: var(--color-text-secondary);">Zeigt Tool-Position in der Matrix</div>
</div>
</button>
<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: var(--color-bg); cursor: pointer; transition: var(--transition-fast); text-align: left; width: 100%;">
<div style="width: 32px; height: 32px; background: var(--color-warning); border-radius: 0.25rem; display: flex; align-items: center; justify-content: center;">
<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)'">
<div style="width: 2rem; height: 2rem; background-color: 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">
<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>
<div>
<div style="font-weight: 500; margin-bottom: 0.25rem;color: var(--color-text-secondary)">Tool-Details</div>
<div style="font-size: 0.8125rem; color: var(--color-text-secondary);">Öffnet Detail-Fenster direkt</div>
<div style="font-weight: 500; margin-bottom: 0.25rem; color: var(--color-text);">Tool-Details</div>
<div style="font-size: 0.875rem; color: var(--color-text-secondary);">Öffnet Detail-Fenster direkt</div>
</div>
</button>
</div>
@ -456,16 +465,6 @@ domains.forEach((domain: any) => {
// Share option handlers
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', () => {
const url = btn.getAttribute('data-url');
copyToClipboard(url, btn);
@ -506,15 +505,12 @@ domains.forEach((domain: any) => {
}
// 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.description.textContent = tool.description;
// Badges
const hasValidProjectUrl = tool.projectUrl !== undefined &&
tool.projectUrl !== null &&
tool.projectUrl !== "" &&
tool.projectUrl.trim() !== "";
const hasValidProjectUrl = window.isToolHosted(tool);
elements.badges.innerHTML = '';
if (isConcept) {
@ -535,7 +531,7 @@ domains.forEach((domain: any) => {
const domainsText = domains.length > 0 ? domains.join(', ') : 'Domain-agnostic';
const phasesText = phases.join(', ');
let metadataHTML = `<div style="display: grid; gap: 0.5rem;">`;
let metadataHTML = `<div class="grid gap-2">`;
if (!isConcept) {
metadataHTML += `
@ -558,7 +554,7 @@ domains.forEach((domain: any) => {
// Tags and Related Concepts
const tags = tool.tags || [];
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('')}
</div>
`;
@ -569,14 +565,14 @@ domains.forEach((domain: any) => {
const conceptLinks = relatedConcepts.map(conceptName => {
const concept = toolsData.find(t => t.name === conceptName && t.type === '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')"
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)';">
${conceptName}
</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('');
// Check if mobile device
@ -584,18 +580,18 @@ domains.forEach((domain: any) => {
const collapseOnMobile = isMobile && relatedConcepts.length > 2;
tagsHTML += `
<div style="margin-top: 1rem;">
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
<div class="mt-4">
<div class="flex items-center gap-2 mb-2">
<strong style="color: var(--color-text);">Verwandte Konzepte:</strong>
${collapseOnMobile ? `
<button id="concepts-toggle-${modalType}"
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>
` : ''}
</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}
</div>
</div>
@ -609,40 +605,40 @@ domains.forEach((domain: any) => {
if (isConcept) {
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
</a>
`;
} else if (isMethod) {
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
</a>
`;
} else if (hasValidProjectUrl) {
linksHTML += `
<div style="display: flex; gap: 0.5rem;">
<a href="${tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-secondary" style="flex: 1;">
<div class="flex gap-2">
<a href="${tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-secondary flex-1">
Homepage
</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
</a>
</div>
`;
} else {
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
</a>
`;
}
if (tool.knowledgebase === true) {
const kbId = tool.name.toLowerCase().replace(/\s+/g, '-');
const kbId = window.createToolSlug(tool.name);
linksHTML += `
<a href="/knowledgebase#kb-${kbId}" class="btn btn-secondary" style="width: 100%; margin-top: 0.5rem;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 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" 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"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
@ -659,7 +655,7 @@ domains.forEach((domain: any) => {
// ===== POPULATE SHARE BUTTON =====
const shareButtonContainer = document.getElementById(`share-button-${modalType}`);
if (shareButtonContainer) {
const toolSlug = createToolSlug(tool.name);
const toolSlug = window.createToolSlug(tool.name);
shareButtonContainer.innerHTML = `
<button class="share-btn share-btn--medium"
data-tool-name="${tool.name}"
@ -679,6 +675,26 @@ domains.forEach((domain: any) => {
`;
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
const overlay = document.getElementById('modal-overlay');
@ -703,29 +719,34 @@ domains.forEach((domain: any) => {
const primaryModal = document.getElementById('tool-details-primary');
const secondaryModal = document.getElementById('tool-details-secondary');
if (modalType === 'both' || modalType === 'all') {
if (primaryModal) {
primaryModal.classList.remove('active');
// Hide share button
const shareButtonPrimary = document.getElementById('share-button-primary');
const contributeButtonPrimary = document.getElementById('contribute-button-primary');
if (shareButtonPrimary) shareButtonPrimary.style.display = 'none';
if (contributeButtonPrimary) contributeButtonPrimary.style.display = 'none';
}
if (secondaryModal) {
secondaryModal.classList.remove('active');
// Hide share button
const shareButtonSecondary = document.getElementById('share-button-secondary');
const contributeButtonSecondary = document.getElementById('contribute-button-secondary');
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) {
primaryModal.classList.remove('active');
const shareButtonPrimary = document.getElementById('share-button-primary');
const contributeButtonPrimary = document.getElementById('contribute-button-primary');
if (shareButtonPrimary) shareButtonPrimary.style.display = 'none';
if (contributeButtonPrimary) contributeButtonPrimary.style.display = 'none';
} else if (modalType === 'secondary' && secondaryModal) {
secondaryModal.classList.remove('active');
const shareButtonSecondary = document.getElementById('share-button-secondary');
const contributeButtonSecondary = document.getElementById('contribute-button-secondary');
if (shareButtonSecondary) shareButtonSecondary.style.display = 'none';
if (contributeButtonSecondary) contributeButtonSecondary.style.display = 'none';
}
// Check if any modal is still active
@ -790,10 +811,7 @@ domains.forEach((domain: any) => {
}
const isMethod = tool.type === 'method';
const hasValidProjectUrl = tool.projectUrl !== undefined &&
tool.projectUrl !== null &&
tool.projectUrl !== "" &&
tool.projectUrl.trim() !== "";
const hasValidProjectUrl = window.isToolHosted(tool);
const domains = tool.domains || [];
const phases = tool.phases || [];

View File

@ -4,26 +4,18 @@ const knowledgebaseCollection = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
tool_name: z.string(),
description: z.string(),
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([]),
tags: z.array(z.string()).default([]),
sections: z.object({
overview: 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'),
published: z.boolean().default(true),
})
});
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"
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
author: "CC24-Team"
author: "Claude 4 Sonnet"
difficulty: "intermediate"
categories: ["incident-response", "forensics", "penetration-testing"]
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"
description: "Das Rückgrat des modernen Threat-Intelligence-Sharings mit über 40.000 aktiven Instanzen weltweit."
last_updated: 2025-07-20
author: "CC24-Team"
author: "Claude 4 Sonnet"
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"]
sections:
overview: true

View File

@ -3,7 +3,7 @@ title: "Nextcloud - Sichere Kollaborationsplattform"
tool_name: "Nextcloud"
description: "Detaillierte Anleitung und Best Practices für Nextcloud in forensischen Einsatzszenarien"
last_updated: 2025-07-20
author: "CC24-Team"
author: "Claude 4 Sonnet"
difficulty: "novice"
categories: ["collaboration-general"]
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)"
description: "Pattern matching language für Suche, Extraktion und Manipulation von Text in forensischen Analysen."
last_updated: 2025-07-20
author: "CC24-Team"
author: "Claude 4 Sonnet"
difficulty: "intermediate"
categories: ["incident-response", "malware-analysis", "network-forensics", "fraud-investigation"]
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"
description: "Detaillierte Anleitung und Best Practices für Velociraptor Remote-Forensik der nächsten Generation"
last_updated: 2025-07-20
author: "CC24-Team"
author: "Claude 4 Sonnet"
difficulty: "advanced"
categories: ["incident-response", "malware-analysis", "network-forensics"]
tags: ["web-based", "endpoint-monitoring", "artifact-extraction", "scripting", "live-forensics", "hunting"]

View File

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

View File

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

11
src/env.d.ts vendored
View File

@ -18,6 +18,17 @@ declare global {
switchToAIView?: () => void;
clearTagFilters?: () => 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;
}
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>
@ -17,12 +17,197 @@ const { title, description = 'CC24-Guide - A comprehensive directory of digital
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content={description}>
<title>{title} - CC24-Guide</title>
<title>{title} - ForensicPathways</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<script src="/src/scripts/theme.js"></script>
<script>
// 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>
</head>
<body>

View File

@ -2,11 +2,11 @@
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;">
<!-- 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);">
<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;">
Forensik im Dienst der Transparenz
</p>
@ -141,7 +141,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
</div>
<p style="margin-bottom: 1rem; line-height: 1.7;">
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>
<!-- Special Note Box -->
@ -179,6 +179,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
</div>
<!-- Contributing Section -->
<!-- Contribution Section -->
<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;">
<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="23" y1="11" x2="17" y2="11"/>
</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 style="display: grid; gap: 1.25rem;">
<!-- Suggestions -->
<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>
<p style="margin: 0;">
Ich suche stets nach Ergänzungen für die Liste. Falls euch interessante Tools oder Methoden einfallen
schreibt mir gerne auf Signal oder an <a href="mailto:mstoeck3@hs-mittweida.de">mstoeck3@hs-mittweida.de</a>.
Du hast eine Idee, wie wir den Hub erweitern können? Reiche deinen Vorschlag unkompliziert
über unsere <a href="/contribute#vorschlaege">/contribute</a>-Seite ein.
</p>
</div>
<!-- Corrections & Updates -->
<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;">
Sollte eine Anwendung/Methode nicht mehr aktuell, veraltet oder falsch repräsentiert sein,
gebt mir unbedingt Bescheid.
Ist eine Anwendung veraltet oder falsch dargestellt? Teile uns das bitte direkt unter
<a href="/contribute#korrekturen">/contribute</a> mit.
</p>
</div>
<!-- Code Contributions -->
<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;">
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>
<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;">
<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;">
<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>
Git-Repository besuchen
GitRepository besuchen
</a>
</div>
</div>
<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>
<p style="margin: 0;">

View File

@ -1,7 +1,9 @@
// src/pages/api/ai/query.ts
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 { apiError, apiServerError, createAuthErrorResponse } from '../../../utils/api.js';
import { enqueueApiCall } from '../../../utils/rateLimitedQueue.js';
export const prerender = false;
@ -97,7 +99,7 @@ function createWorkflowSystemPrompt(toolsData: any): string {
related_concepts: tool.related_concepts || []
}));
// NEW: Include concepts for background knowledge
// Include concepts for background knowledge
const conceptsList = toolsData.concepts.map((concept: any) => ({
name: concept.name,
description: concept.description,
@ -107,7 +109,7 @@ function createWorkflowSystemPrompt(toolsData: any): string {
tags: concept.tags
}));
// Get regular phases (no more filtering needed)
// Get regular phases
const regularPhases = toolsData.phases || [];
// Get domain-agnostic software phases
@ -159,7 +161,7 @@ FORENSISCHE DOMÄNEN:
${domainsDescription}
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.
3. Für Reporting-Phase: Visualisierungs- und Dokumentationssoftware einschließen
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 || []
}));
// NEW: Include concepts for background knowledge
// Include concepts for background knowledge
const conceptsList = toolsData.concepts.map((concept: any) => ({
name: concept.name,
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 }) => {
try {
// Check if authentication is required
const authRequired = process.env.AUTHENTICATION_NECESSARY !== 'false';
let userId = 'test-user';
if (authRequired) {
// Authentication check
const sessionToken = getSessionFromRequest(request);
if (!sessionToken) {
return new Response(JSON.stringify({ error: 'Authentication required' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
const session = await verifySession(sessionToken);
if (!session) {
return new Response(JSON.stringify({ error: 'Invalid session' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
userId = session.userId;
// Authentication check
const authResult = await withAPIAuth(request, 'ai');
if (!authResult.authenticated) {
return createAuthErrorResponse();
}
const userId = authResult.userId;
// Rate limiting
if (!checkRateLimit(userId)) {
return new Response(JSON.stringify({ error: 'Rate limit exceeded' }), {
status: 429,
headers: { 'Content-Type': 'application/json' }
});
return apiError.rateLimit('Rate limit exceeded');
}
// Parse request body
const body = await request.json();
const { query, mode = 'workflow' } = body;
const { query, mode = 'workflow', taskId: clientTaskId } = body;
// Validation
if (!query || typeof query !== 'string') {
return new Response(JSON.stringify({ error: 'Query required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
return apiError.badRequest('Query required');
}
if (!['workflow', 'tool'].includes(mode)) {
return new Response(JSON.stringify({ error: 'Invalid mode. Must be "workflow" or "tool"' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
return apiError.badRequest('Invalid mode. Must be "workflow" or "tool"');
}
// Sanitize input
const sanitizedQuery = sanitizeInput(query);
if (sanitizedQuery.includes('[FILTERED]')) {
return new Response(JSON.stringify({ error: 'Invalid input detected' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
return apiError.badRequest('Invalid input detected');
}
// Load tools database
@ -343,45 +317,46 @@ export const POST: APIRoute = async ({ request }) => {
? createWorkflowSystemPrompt(toolsData)
: createToolSystemPrompt(toolsData);
const aiResponse = await fetch(process.env.AI_API_ENDPOINT + '/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.AI_API_KEY}`
},
body: JSON.stringify({
model: AI_MODEL,
messages: [
{
role: 'system',
content: systemPrompt
},
{
role: 'user',
content: sanitizedQuery
}
],
max_tokens: 2000,
temperature: 0.3
// 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',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.AI_API_KEY}`
},
body: JSON.stringify({
model: AI_MODEL,
messages: [
{
role: 'system',
content: systemPrompt
},
{
role: 'user',
content: sanitizedQuery
}
],
max_tokens: 2000,
temperature: 0.3
})
})
});
, taskId);
// AI response handling
if (!aiResponse.ok) {
console.error('AI API error:', await aiResponse.text());
return new Response(JSON.stringify({ error: 'AI service unavailable' }), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
return apiServerError.unavailable('AI service unavailable');
}
const aiData = await aiResponse.json();
const aiContent = aiData.choices?.[0]?.message?.content;
if (!aiContent) {
return new Response(JSON.stringify({ error: 'No response from AI' }), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
return apiServerError.unavailable('No response from AI');
}
// Parse AI JSON response
@ -391,10 +366,7 @@ export const POST: APIRoute = async ({ request }) => {
recommendation = JSON.parse(cleanedContent);
} catch (error) {
console.error('Failed to parse AI response:', aiContent);
return new Response(JSON.stringify({ error: 'Invalid AI response format' }), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
return apiServerError.unavailable('Invalid AI response format');
}
// Validate tool names and concept names against database
@ -450,9 +422,11 @@ export const POST: APIRoute = async ({ request }) => {
// 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}`);
// Success response with task ID
return new Response(JSON.stringify({
success: true,
mode,
taskId,
recommendation: validatedRecommendation,
query: sanitizedQuery
}), {
@ -462,9 +436,6 @@ export const POST: APIRoute = async ({ request }) => {
} catch (error) {
console.error('AI query error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
return apiServerError.internal('Internal server error');
}
};

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

View File

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

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,
// we'll handle this client-side and POST to the API
---
@ -6,49 +7,118 @@
<html>
<head>
<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>
<body>
<div style="text-align: center; padding: 4rem; font-family: sans-serif;">
<div class="container">
<div class="spinner"></div>
<h2>Processing authentication...</h2>
<p>Please wait while we complete your login.</p>
<div id="error-message" style="display: none;" class="error"></div>
</div>
<script>
// Get URL parameters from client-side
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
const error = urlParams.get('error');
console.log('Client-side callback params:', { code: !!code, state: !!state, error });
if (error) {
window.location.href = '/?auth=error';
} else if (code && state) {
// Send the parameters to our API endpoint
fetch('/api/auth/process', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code, state })
})
.then(response => response.json())
.then(data => {
if (data.success) {
window.location.href = data.redirectTo || '/';
} else {
window.location.href = '/?auth=error';
(function() {
// Get URL parameters from client-side
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
const error = urlParams.get('error');
console.log('Client-side callback params:', { code: !!code, state: !!state, error });
const errorDiv = document.getElementById('error-message') as HTMLElement;
if (error) {
if (errorDiv) {
errorDiv.textContent = `Authentication error: ${error}`;
errorDiv.style.display = 'block';
}
})
.catch(error => {
console.error('Authentication processing failed:', error);
window.location.href = '/?auth=error';
});
} else {
console.error('Missing code or state parameters');
window.location.href = '/?auth=error';
}
setTimeout(() => {
window.location.href = '/?auth=error';
}, 3000);
} else if (code && state) {
// Send the parameters to our API endpoint
fetch('/api/auth/process', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code, state })
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
})
.then(data => {
if (data.success) {
window.location.href = data.redirectTo || '/';
} else {
throw new Error(data.error || 'Authentication failed');
}
})
.catch(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';
}, 3000);
});
} else {
console.error('Missing code or state parameters');
if (errorDiv) {
errorDiv.textContent = 'Missing authentication parameters';
errorDiv.style.display = 'block';
}
setTimeout(() => {
window.location.href = '/?auth=error';
}, 3000);
}
})();
</script>
</body>
</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';
---
<BaseLayout title="Impressum" description="CC24-Guide - Impressum">
<BaseLayout title="Impressum" description="ForensicPathways - Impressum">
<section style="padding: 2rem 0; max-width: 900px; margin: 0 auto;">
<!-- 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);">

View File

@ -6,7 +6,6 @@ import ToolMatrix from '../components/ToolMatrix.astro';
import AIQueryInterface from '../components/AIQueryInterface.astro';
import { getToolsData } from '../utils/dataService.js';
// Load tools data
const data = await getToolsData();
const tools = data.tools;
@ -16,7 +15,7 @@ const tools = data.tools;
<!-- Hero Section -->
<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);">
<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);">
<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,13 +52,24 @@ const tools = data.tools;
KI befragen
</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;">
<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>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
</svg>
Entdecken
Entdecken
</a>
</div>
</div>
@ -67,13 +77,12 @@ const tools = data.tools;
<!-- Filters Section -->
<section id="filters-section" style="padding: 2rem 0;">
<ToolFilters />
<ToolFilters data={data} />
</section>
<!-- AI Query Interface -->
<AIQueryInterface />
<!-- Tools Grid -->
<section id="tools-grid" style="padding-bottom: 2rem;">
<div class="grid-auto-fit" id="tools-container">
@ -89,35 +98,22 @@ const tools = data.tools;
</section>
<!-- Matrix View -->
<ToolMatrix />
<ToolMatrix data={data} />
</BaseLayout>
<script>
// Extend Window interface for custom properties
declare global {
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;
}
}
<script define:vars={{ toolsData: data.tools }}>
// Store tools data globally
window.toolsData = toolsData;
// Handle view changes and filtering
document.addEventListener('DOMContentLoaded', () => {
const toolsContainer = document.getElementById('tools-container') as HTMLElement;
const toolsGrid = document.getElementById('tools-grid') as HTMLElement;
const matrixContainer = document.getElementById('matrix-container') as HTMLElement;
const aiInterface = document.getElementById('ai-interface') as HTMLElement;
const filtersSection = document.getElementById('filters-section') as HTMLElement;
const noResults = document.getElementById('no-results') as HTMLElement;
const aiQueryBtn = document.getElementById('ai-query-btn') as HTMLButtonElement;
const toolsContainer = document.getElementById('tools-container');
const toolsGrid = document.getElementById('tools-grid');
const matrixContainer = document.getElementById('matrix-container');
const aiInterface = document.getElementById('ai-interface');
const filtersSection = document.getElementById('filters-section');
const noResults = document.getElementById('no-results');
const aiQueryBtn = document.getElementById('ai-query-btn');
// Guard against null elements
if (!toolsContainer || !toolsGrid || !matrixContainer || !noResults || !aiInterface || !filtersSection) {
@ -125,63 +121,20 @@ const tools = data.tools;
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) {
aiQueryBtn.addEventListener('click', async () => {
const authStatus = await checkAuthentication();
if (authStatus.authRequired && !authStatus.authenticated) {
const returnUrl = `${window.location.pathname}?view=ai`;
window.location.href = `/api/auth/login?returnTo=${encodeURIComponent(returnUrl)}`;
if (typeof window.requireClientAuth === 'function') {
// ENHANCED: Use AI-specific authentication
await window.requireClientAuth(() => switchToView('ai'), `${window.location.pathname}?view=ai`, 'ai');
} else {
console.warn('[AUTH] requireClientAuth not available');
switchToView('ai');
}
});
}
// Function to switch between different views
function switchToView(view: string) {
function switchToView(view) {
// Hide all views first
toolsGrid.style.display = 'none';
matrixContainer.style.display = 'none';
@ -203,7 +156,7 @@ const tools = data.tools;
if (window.restoreAIResults) {
window.restoreAIResults();
}
const aiInput = document.getElementById('ai-query-input') as HTMLTextAreaElement;
const aiInput = document.getElementById('ai-query-input');
if (aiInput) {
setTimeout(() => aiInput.focus(), 100);
}
@ -237,19 +190,19 @@ const tools = data.tools;
];
elements.forEach(selector => {
const element = document.querySelector(selector) as HTMLElement;
const element = document.querySelector(selector);
if (element) element.style.display = 'none';
});
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() {
const domainPhaseContainer = document.querySelector('.domain-phase-container') as HTMLElement;
const searchInput = document.getElementById('search-input') as HTMLElement;
const tagCloud = document.querySelector('.tag-cloud') as HTMLElement;
const tagHeader = document.querySelector('.tag-header') as HTMLElement;
const domainPhaseContainer = document.querySelector('.domain-phase-container');
const searchInput = document.getElementById('search-input');
const tagCloud = document.querySelector('.tag-cloud');
const tagHeader = document.querySelector('.tag-header');
const checkboxWrappers = document.querySelectorAll('.checkbox-wrapper');
const allInputs = filtersSection.querySelectorAll('input, select, textarea');
@ -258,29 +211,15 @@ const tools = data.tools;
if (tagCloud) tagCloud.style.display = 'flex';
if (tagHeader) tagHeader.style.display = 'flex';
allInputs.forEach(input => (input as HTMLElement).style.display = 'block');
checkboxWrappers.forEach(wrapper => (wrapper as HTMLElement).style.display = 'flex');
allInputs.forEach(input => input.style.display = 'block');
checkboxWrappers.forEach(wrapper => wrapper.style.display = 'flex');
}
// Create tool slug from name
function createToolSlug(toolName: string): string {
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()
);
}
// REMOVED: createToolSlug function - now using window.createToolSlug
// REMOVED: findTool function - now using window.findToolByIdentifier
// Navigation functions for sharing
window.navigateToGrid = function(toolName: string) {
window.navigateToGrid = function(toolName) {
console.log('Navigating to grid for tool:', toolName);
// Switch to grid view first
@ -296,7 +235,7 @@ const tools = data.tools;
// Wait for filters to clear and re-render
setTimeout(() => {
const toolCards = document.querySelectorAll('.tool-card');
let targetCard: Element | null = null;
let targetCard = null;
toolCards.forEach(card => {
const cardTitle = card.querySelector('h3');
@ -311,13 +250,12 @@ const tools = data.tools;
if (targetCard) {
console.log('Found target card, scrolling...');
// Cast to Element to fix TypeScript issue
(targetCard as Element).scrollIntoView({ behavior: 'smooth', block: 'center' });
(targetCard as HTMLElement).style.animation = 'highlight-flash 2s ease-out';
targetCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
targetCard.style.animation = 'highlight-flash 2s ease-out';
setTimeout(() => {
if (targetCard) {
(targetCard as HTMLElement).style.animation = '';
targetCard.style.animation = '';
}
}, 2000);
} else {
@ -327,7 +265,7 @@ const tools = data.tools;
}, 200);
};
window.navigateToMatrix = function(toolName: string) {
window.navigateToMatrix = function(toolName) {
console.log('Navigating to matrix for tool:', toolName);
// Switch to matrix view
@ -336,7 +274,7 @@ const tools = data.tools;
// Wait for view switch and matrix to render
setTimeout(() => {
const toolChips = document.querySelectorAll('.tool-chip');
let firstMatch: Element | null = null;
let firstMatch = null;
let matchCount = 0;
toolChips.forEach(chip => {
@ -344,7 +282,7 @@ const tools = data.tools;
const chipText = chip.textContent?.replace(/📖/g, '').replace(/[^\w\s\-\.]/g, '').trim();
if (chipText === toolName) {
// Highlight this occurrence
(chip as HTMLElement).style.animation = 'highlight-flash 2s ease-out';
chip.style.animation = 'highlight-flash 2s ease-out';
matchCount++;
// Remember the first match for scrolling
@ -354,15 +292,14 @@ const tools = data.tools;
// Clean up animation after it completes
setTimeout(() => {
(chip as HTMLElement).style.animation = '';
chip.style.animation = '';
}, 8000);
}
});
if (firstMatch) {
console.log(`Found ${matchCount} occurrences of tool, highlighting all and scrolling to first`);
// Cast to Element to fix TypeScript issue
(firstMatch as Element).scrollIntoView({ behavior: 'smooth', block: 'center' });
firstMatch.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else {
console.warn('Tool chip not found in matrix:', toolName);
}
@ -384,8 +321,8 @@ const tools = data.tools;
return;
}
// Find the tool by name or slug
const tool = findTool(toolParam);
// Find the tool by name or slug using global function
const tool = window.findToolByIdentifier(window.toolsData, toolParam);
if (!tool) {
console.warn('Shared tool not found:', toolParam);
return;
@ -417,160 +354,48 @@ const tools = data.tools;
}, 100);
}
// Handle filtered results
// ENHANCED: New filtering logic using show/hide pattern
window.addEventListener('toolsFiltered', (event) => {
const customEvent = event as CustomEvent;
const filtered = customEvent.detail;
const filtered = event.detail;
const currentView = document.querySelector('.view-toggle.active')?.getAttribute('data-view');
if (currentView === 'matrix' || currentView === 'ai') {
return;
}
// Clear container
toolsContainer.innerHTML = '';
// Get all existing tool cards
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';
} else {
noResults.style.display = 'none';
const sortedTools = sortTools(filtered, 'default');
sortedTools.forEach((tool: any) => {
const toolCard = createToolCard(tool);
toolsContainer.appendChild(toolCard);
});
}
});
// Handle view changes
window.addEventListener('viewChanged', (event) => {
const customEvent = event as CustomEvent;
const view = customEvent.detail;
const view = event.detail;
switchToView(view);
});
// Make switchToView available globally
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
handleSharedURL();
});

View File

@ -1,15 +1,51 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { getCollection } from 'astro:content';
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 allKnowledgebaseEntries = await getCollection('knowledgebase', (entry) => {
// Only include published articles
return entry.data.published !== false;
});
// Filter tools that have knowledgebase flag set to true
const knowledgebaseTools = data.tools.filter((tool: any) => tool.knowledgebase === true);
// Create unified knowledgebase entries with optional tool association
const knowledgebaseEntries = allKnowledgebaseEntries.map((entry) => {
const associatedTool = entry.data.tool_name
? data.tools.find((tool: any) => tool.name === entry.data.tool_name)
: null;
return {
// 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 name
knowledgebaseTools.sort((a: any, b: any) => a.name.localeCompare(b.name));
// 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">
@ -17,12 +53,24 @@ knowledgebaseTools.sort((a: any, b: any) => a.name.localeCompare(b.name));
<!-- 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);">
<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
</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
</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>
<!-- Search -->
@ -35,16 +83,16 @@ knowledgebaseTools.sort((a: any, b: any) => a.name.localeCompare(b.name));
/>
</div>
<!-- Tools Count -->
<!-- Articles Count -->
<div style="text-align: center; margin-bottom: 2rem;">
<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>
</div>
<!-- Knowledgebase Entries -->
<div style="max-width: 1000px; margin: 0 auto;">
{knowledgebaseTools.length === 0 ? (
{knowledgebaseEntries.length === 0 ? (
<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;">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
@ -55,88 +103,121 @@ knowledgebaseTools.sort((a: any, b: any) => a.name.localeCompare(b.name));
</svg>
<h3 style="color: var(--color-text-secondary); margin-bottom: 0.5rem;">Noch keine Knowledgebase-Einträge</h3>
<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>
</div>
) : (
<div id="kb-entries">
{knowledgebaseTools.map((tool: any, index: number) => {
const hasValidProjectUrl = tool.projectUrl !== undefined &&
tool.projectUrl !== null &&
tool.projectUrl !== "" &&
tool.projectUrl.trim() !== "";
{knowledgebaseEntries.map((entry: any, index: number) => {
const hasAssociatedTool = !!entry.associatedTool;
const hasValidProjectUrl = hasAssociatedTool &&
entry.associatedTool.projectUrl !== undefined &&
entry.associatedTool.projectUrl !== null &&
entry.associatedTool.projectUrl !== "" &&
entry.associatedTool.projectUrl.trim() !== "";
const toolSlug = tool.name.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
const isMethod = hasAssociatedTool && entry.associatedTool.type === 'method';
const isConcept = hasAssociatedTool && entry.associatedTool.type === 'concept';
const isStandalone = !hasAssociatedTool;
return (
<article
class="kb-entry card"
id={`kb-${toolSlug}`}
data-tool-name={tool.name.toLowerCase()}
id={`kb-${entry.slug}`}
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; align-items: center; gap: 1rem;">
<h3 style="margin: 0; color: var(--color-primary);">
{tool.icon && <span style="margin-right: 0.5rem;">{tool.icon}</span>}
{tool.name}
<span style="margin-right: 0.5rem;">{entry.icon}</span>
{entry.title}
</h3>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<!-- Type indicator badges -->
{tool.type === 'concept' && <span class="badge" style="background-color: var(--color-concept); color: white;">Konzept</span>}
{tool.type === 'method' && <span class="badge" style="background-color: var(--color-method); color: white;">Methode</span>}
{tool.type === 'software' && <span class="badge" style="background-color: var(--color-primary); color: white;">Software</span>}
{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>}
{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>}
{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 -->
<span class="badge" style="background-color: var(--color-text-secondary); color: white; font-size: 0.75rem;">
{tool.skillLevel || 'intermediate'}
</span>
{entry.difficulty && (
<span class="badge" style="background-color: var(--color-text-secondary); color: white; font-size: 0.75rem;">
{entry.difficulty}
</span>
)}
<!-- Knowledge Base indicator -->
<span class="badge badge-error">📖</span>
</div>
</div>
<!-- Action button -->
<a href={`/knowledgebase/${toolSlug}`} 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;">
<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>
Artikel öffnen
</a>
<!-- Action buttons -->
<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;">
<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>
Artikel öffnen
</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>
<!-- Description -->
<p style="margin: 1rem 0; color: var(--color-text-secondary); line-height: 1.5;">
{tool.description}
{entry.description}
</p>
<!-- Tags and Metadata -->
<!-- Metadata and Tags -->
<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;">
{tool.tags.map((tag: string) => (
{entry.tags.map((tag: string) => (
<span class="tag" style="font-size: 0.75rem;">{tag}</span>
))}
</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);">
<strong>Phasen:</strong> {tool.phases.join(', ')}
<strong>Kategorien:</strong> {entry.categories.join(', ')}
</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);">
<strong>Plattformen:</strong> {tool.platforms.join(', ')}
<strong>Phasen:</strong> {entry.phases.join(', ')}
</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>
</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>
</div>
</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>
<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>
// Enhanced knowledgebase functionality with search
document.addEventListener('DOMContentLoaded', () => {
@ -217,5 +291,22 @@ knowledgebaseTools.sort((a: any, b: any) => a.name.localeCompare(b.name));
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>

View File

@ -26,20 +26,35 @@ const { Content } = await entry.render();
// Load tools data to get the tool details
const data = await getToolsData();
const tool = data.tools.find((t: any) => t.name === entry.data.tool_name);
if (!tool) {
console.warn(`Tool not found for knowledgebase entry: ${entry.data.tool_name}`);
return Astro.redirect('/knowledgebase');
// UPGRADED: Handle optional tool association
const primaryTool = entry.data.tool_name
? 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
const isMethod = tool.type === 'method';
const isConcept = tool.type === 'concept';
const hasValidProjectUrl = tool.projectUrl !== undefined &&
tool.projectUrl !== null &&
tool.projectUrl !== "" &&
tool.projectUrl.trim() !== "";
// Determine styling based on tool type or fallback to generic
const isMethod = displayTool?.type === 'method';
const isConcept = displayTool?.type === 'concept';
const isStandalone = !displayTool;
const hasValidProjectUrl = displayTool && displayTool.projectUrl !== undefined &&
displayTool.projectUrl !== null &&
displayTool.projectUrl !== "" &&
displayTool.projectUrl.trim() !== "";
---
<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="flex: 1;">
<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}
</h1>
<p style="margin: 0; color: var(--color-text-secondary); font-size: 1.125rem;">
@ -58,32 +73,59 @@ const hasValidProjectUrl = tool.projectUrl !== undefined &&
</div>
<div style="display: flex; flex-direction: column; gap: 0.5rem; align-items: end;">
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
{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 && !isConcept && <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 && tool.license !== 'Proprietary' && <span class="badge badge-success">Open Source</span>}
<!-- 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>}
{isMethod && <span class="badge" style="background-color: var(--color-method); color: white;">Methode</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 && !isStandalone && displayTool?.license !== 'Proprietary' && <span class="badge badge-success">Open Source</span>}
</>
)}
<span class="badge badge-error">📖</span>
</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>
<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>
</div>
<!-- Difficulty (always shown if present) -->
{entry.data.difficulty && (
<div>
<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>
</div>
)}
<!-- Last Updated (always shown) -->
<div>
<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>
</div>
<!-- Author (always shown) -->
<div>
<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>
</div>
<!-- UPGRADED: Show article type -->
<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>
<div style="grid-column: 1 / -1;">
<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;">
{entry.data.categories.map((cat: string) => (
@ -105,57 +147,107 @@ const hasValidProjectUrl = tool.projectUrl !== undefined &&
</a>
</nav>
<!-- Content -->
<div class="card" style="padding: 2rem;">
<div class="kb-content markdown-content" style="line-height: 1.7;">
<Content />
<!-- Content -->
<div class="card" style="padding: 2rem;">
<div class="kb-content markdown-content" style="line-height: 1.7;">
<Content />
</div>
</div>
</div>
<!-- Tool Actions -->
<!-- UPGRADED: Flexible Tool Actions Section -->
<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;">
{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);">
{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="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"/>
<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>
Mehr erfahren
</a>
) : 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);">
<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>
Zur Methode
Weitere Artikel
</a>
) : (
<!-- UPGRADED: Tool-specific actions (existing logic) -->
<>
<a href={tool.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;">
<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>
Software-Homepage
</a>
{hasValidProjectUrl && (
<a href={tool.projectUrl} target="_blank" rel="noopener noreferrer" class="btn btn-primary">
{isConcept ? (
<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;">
<circle cx="12" cy="12" r="10"/>
<path d="M12 16l4-4-4-4"/>
<path d="M8 12h8"/>
<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>
Zugreifen
Mehr erfahren
</a>
) : isMethod ? (
<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;">
<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>
Zur Methode
</a>
) : (
<>
<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;">
<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>
Software-Homepage
</a>
{hasValidProjectUrl && (
<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;">
<circle cx="12" cy="12" r="10"/>
<path d="M12 16l4-4-4-4"/>
<path d="M8 12h8"/>
</svg>
Zugreifen
</a>
)}
</>
)}
</>
)}
<!-- 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">
<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"/>
@ -166,4 +258,4 @@ const hasValidProjectUrl = tool.projectUrl !== undefined &&
</div>
</div>
</article>
</BaseLayout>
</BaseLayout>

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);
color: var(--color-text);
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 {
outline: none;
input:focus, textarea:focus, select:focus {
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 {
@ -301,14 +309,67 @@ select {
display: flex;
align-items: center;
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"] {
width: auto;
width: 16px;
height: 16px;
accent-color: var(--color-primary);
margin: 0;
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 */
.card {
background-color: var(--color-bg);
@ -469,6 +530,10 @@ input[type="checkbox"] {
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 {
margin-top: auto;
flex-shrink: 0;
@ -685,6 +750,7 @@ input[type="checkbox"] {
width: 100%;
height: 100%;
background-color: rgb(0 0 0 / 50%);
backdrop-filter: blur(2px);
z-index: 999;
}
@ -843,6 +909,27 @@ input[type="checkbox"] {
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-compact {
display: flex;
@ -944,7 +1031,7 @@ Collaboration Section Collapse */
}
.ai-loading, .ai-error, .ai-results {
animation: fadeIn 0.3s ease-in;
animation: fadeIn 0.3s ease-in-out;
}
.ai-mode-toggle {
@ -1278,6 +1365,16 @@ Collaboration Section Collapse */
position: relative;
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; }
@ -1292,6 +1389,9 @@ Collaboration Section Collapse */
padding: 1.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); }
@ -1328,12 +1428,23 @@ footer {
max-height: 0;
padding-top: 0;
margin-top: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
max-height: 1000px;
padding-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)
This will literally assault the user's retinas. They'll need sunglasses to look at their shared tools! 🌈💥👁🗨*/
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.05);
opacity: 0.8;
}
}
@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 */
@media (width <= 1200px) {
.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%;
max-width: none;
}
.form-grid.two-columns {
grid-template-columns: 1fr;
}
}
@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;
gap: 0.5rem;
}
.card {
padding: 1rem;
}
.form-grid {
gap: 0.75rem;
}
.checkbox-container {
max-height: 150px;
}
}
@media (width <= 480px) {
@ -1712,4 +1848,228 @@ This will literally assault the user's retinas. They'll need sunglasses to look
.share-btn svg {
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';
import { serialize, parse } from 'cookie';
// src/utils/auth.js (FIXED - Cleaned up and enhanced debugging)
import type { AstroGlobal } from 'astro';
import crypto from 'crypto';
import { config } from 'dotenv';
import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
import { serialize, parse as parseCookie } from 'cookie';
// Load environment variables
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
function getEnv(key: string): string {
const value = process.env[key];
@ -14,90 +54,98 @@ function getEnv(key: string): string {
return value;
}
const SECRET_KEY = new TextEncoder().encode(getEnv('AUTH_SECRET'));
const SESSION_DURATION = 6 * 60 * 60; // 6 hours in seconds
export interface SessionData {
userId: string;
authenticated: boolean;
exp: number;
// Session management functions
export function getSessionFromRequest(request: Request): string | null {
const cookieHeader = request.headers.get('cookie');
console.log('[DEBUG] Cookie header:', cookieHeader ? 'present' : 'missing');
if (!cookieHeader) return null;
const cookies = parseCookie(cookieHeader);
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 createSession(userId: string): Promise<string> {
export async function verifySession(sessionToken: string): Promise<SessionData | null> {
try {
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));
// 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);
return await new SignJWT({
const token = await new SignJWT({
userId,
email,
authenticated: true,
exp
})
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime(exp)
.sign(SECRET_KEY);
}
// Verify and decode a session token
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;
}
console.log('[DEBUG] Session token created, length:', token.length);
return token;
}
// 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 {
export function createSessionCookie(sessionToken: string): string {
const publicBaseUrl = getEnv('PUBLIC_BASE_URL');
const isProduction = process.env.NODE_ENV === 'production';
const isSecure = publicBaseUrl.startsWith('https://') || isProduction;
return serialize('session', token, {
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', '', {
const cookie = serialize('session', sessionToken, {
httpOnly: true,
secure: isSecure,
sameSite: 'lax',
maxAge: 0,
maxAge: SESSION_DURATION,
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
@ -118,7 +166,7 @@ export function generateAuthUrl(state: string): string {
}
// 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 clientId = getEnv('OIDC_CLIENT_ID');
const clientSecret = getEnv('OIDC_CLIENT_SECRET');
@ -147,7 +195,7 @@ export async function exchangeCodeForTokens(code: string): Promise<any> {
}
// 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 response = await fetch(`${oidcEndpoint}/apps/oidc/userinfo`, {
@ -165,13 +213,192 @@ export async function getUserInfo(accessToken: string): Promise<any> {
return await response.json();
}
// Generate random state for CSRF protection
export function generateState(): string {
return crypto.randomUUID();
// Parse and validate auth state from cookies
export function parseAuthState(request: Request): {
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
export function logAuthEvent(event: string, details?: any) {
const timestamp = new Date().toISOString();
console.log(`[AUTH ${timestamp}] ${event}`, details ? JSON.stringify(details) : '');
// Verify state parameter against stored state
export function verifyAuthState(request: Request, receivedState: string): {
isValid: boolean;
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';
}