Merge pull request 'contribution-mechanic' (#21) from contribution-mechanic into main
Reviewed-on: mstoeck3/cc24-hub#21
This commit is contained in:
commit
86d2370976
6
.astro/content.d.ts
vendored
6
.astro/content.d.ts
vendored
@ -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
@ -1,5 +1,5 @@
|
||||
{
|
||||
"_variables": {
|
||||
"lastUpdateCheck": 1752478949435
|
||||
"lastUpdateCheck": 1753528124767
|
||||
}
|
||||
}
|
47
.env.example
47
.env.example
@ -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
3
.gitignore
vendored
@ -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
820
README.md
@ -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.
|
@ -14,5 +14,6 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 4321,
|
||||
host: true
|
||||
}
|
||||
});
|
||||
},
|
||||
allowImportingTsExtensions: true
|
||||
});
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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": {
|
||||
|
@ -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>"
|
||||
|
72
src/components/ContributionButton.astro
Normal file
72
src/components/ContributionButton.astro
Normal 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>
|
@ -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;">
|
||||
|
@ -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>
|
@ -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';
|
||||
---
|
||||
|
@ -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>
|
@ -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 }));
|
||||
}
|
||||
|
@ -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 || [];
|
||||
|
@ -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,
|
||||
};
|
||||
});
|
@ -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"]
|
||||
|
@ -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
|
||||
|
@ -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"]
|
||||
|
@ -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"]
|
||||
|
@ -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"]
|
||||
|
@ -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
|
||||
|
@ -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
11
src/env.d.ts
vendored
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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 & 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 & 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);">💻 Code‑Beiträ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
|
||||
Git‑Repository 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;">
|
||||
|
@ -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');
|
||||
}
|
||||
};
|
23
src/pages/api/ai/queue-status.ts
Normal file
23
src/pages/api/ai/queue-status.ts
Normal 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');
|
||||
}
|
||||
};
|
@ -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' }
|
||||
});
|
||||
}
|
||||
};
|
@ -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 });
|
||||
}
|
||||
};
|
@ -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');
|
||||
};
|
@ -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');
|
||||
};
|
160
src/pages/api/contribute/knowledgebase.ts
Normal file
160
src/pages/api/contribute/knowledgebase.ts
Normal 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');
|
||||
};
|
219
src/pages/api/contribute/tool.ts
Normal file
219
src/pages/api/contribute/tool.ts
Normal 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');
|
||||
};
|
280
src/pages/api/upload/media.ts
Normal file
280
src/pages/api/upload/media.ts
Normal 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');
|
||||
};
|
@ -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>
|
253
src/pages/contribute/index.astro
Normal file
253
src/pages/contribute/index.astro
Normal 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>
|
481
src/pages/contribute/knowledgebase.astro
Normal file
481
src/pages/contribute/knowledgebase.astro
Normal 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 knowledge‑base 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>
|
751
src/pages/contribute/tool.astro
Normal file
751
src/pages/contribute/tool.astro
Normal 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>
|
@ -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);">
|
||||
|
@ -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();
|
||||
});
|
||||
|
@ -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>
|
@ -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>
|
@ -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
|
||||
};
|
@ -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
157
src/utils/api.ts
Normal 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;
|
||||
|
@ -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);
|
||||
}
|
395
src/utils/gitContributions.ts
Normal file
395
src/utils/gitContributions.ts
Normal 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
434
src/utils/nextcloud.ts
Normal 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();
|
||||
}
|
164
src/utils/rateLimitedQueue.ts
Normal file
164
src/utils/rateLimitedQueue.ts
Normal 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;
|
@ -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
69
src/utils/toolHelpers.ts
Normal 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';
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user