Merge pull request 'ai-feature' (#1) from ai-feature into main
Reviewed-on: mstoeck3/cc24-hub#1
This commit is contained in:
		
						commit
						b1955521bc
					
				@ -1 +1 @@
 | 
			
		||||
[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.11.1","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"static\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\":false,\"csp\":false},\"legacy\":{\"collections\":false}}"]
 | 
			
		||||
[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.11.1","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"server\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":true,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\",\"entrypoint\":\"astro/assets/endpoint/node\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\":false,\"csp\":false},\"legacy\":{\"collections\":false},\"session\":{\"driver\":\"fs-lite\",\"options\":{\"base\":\"/var/home/user01/Projekte/cc24-hub/node_modules/.astro/sessions\"}}}"]
 | 
			
		||||
							
								
								
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@ -4,6 +4,8 @@ npm-debug.log*
 | 
			
		||||
yarn-debug.log*
 | 
			
		||||
yarn-error.log*
 | 
			
		||||
 | 
			
		||||
package-lock.json
 | 
			
		||||
 | 
			
		||||
# Build output
 | 
			
		||||
_site/
 | 
			
		||||
dist/
 | 
			
		||||
@ -75,4 +77,5 @@ src/_data/config.local.yaml
 | 
			
		||||
 | 
			
		||||
# Temporary files
 | 
			
		||||
tmp/
 | 
			
		||||
temp/
 | 
			
		||||
temp/
 | 
			
		||||
.astro/data-store.json
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										207
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										207
									
								
								README.md
									
									
									
									
									
								
							@ -2,6 +2,9 @@
 | 
			
		||||
 | 
			
		||||
Ein kuratiertes Verzeichnis für digitale Forensik- und Incident-Response-Tools, entwickelt für die Seminargruppe CC24-w1.
 | 
			
		||||
 | 
			
		||||
*DISCLAIMER:*
 | 
			
		||||
Hier wurde Exzessives Vibe-Coding verwendet. Die Auswahl der Software ist aber kuratiert.
 | 
			
		||||
 | 
			
		||||
## 🎯 Projektübersicht
 | 
			
		||||
 | 
			
		||||
CC24-Hub ist eine statische Website, die eine strukturierte Übersicht über bewährte DFIR-Tools bietet. Das Projekt orientiert sich am NIST-Framework (SP 800-86) und kategorisiert Tools nach forensischen Domänen und Untersuchungsphasen.
 | 
			
		||||
@ -44,19 +47,207 @@ Die Seite ist dann unter `http://localhost:4321` verfügbar.
 | 
			
		||||
 | 
			
		||||
### Produktions-Deployment
 | 
			
		||||
 | 
			
		||||
#### Voraussetzungen
 | 
			
		||||
 | 
			
		||||
- Ubuntu/Debian server
 | 
			
		||||
- Node.js 18+ 
 | 
			
		||||
- Nginx
 | 
			
		||||
- Domain
 | 
			
		||||
- SSL Zertifikat
 | 
			
		||||
 | 
			
		||||
#### Installationsschritte
 | 
			
		||||
 | 
			
		||||
##### 1. Vorbereitung
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
# Build erstellen
 | 
			
		||||
npm install && npm run build
 | 
			
		||||
# Klonen des Repositorys
 | 
			
		||||
git clone https://git.cc24.dev/mstoeck3/cc24-hub
 | 
			
		||||
cd cc24-hub
 | 
			
		||||
 | 
			
		||||
# Abhängigkeiten installieren
 | 
			
		||||
npm install
 | 
			
		||||
 | 
			
		||||
# Production-Build anstoßen
 | 
			
		||||
npm run build
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Die statische Seite wird im `dist/` Verzeichnis generiert und kann in die Webroot des Webservers kopiert werden.
 | 
			
		||||
##### 2. Webroot vorbereiten
 | 
			
		||||
 | 
			
		||||
### Verfügbare Scripts
 | 
			
		||||
```bash
 | 
			
		||||
# Webroot erstellen und Berechtigungen setzen
 | 
			
		||||
sudo mkdir -p /var/www/cc24-hub
 | 
			
		||||
sudo chown $USER:$USER /var/www/cc24-hub
 | 
			
		||||
 | 
			
		||||
# Build in Webroot kopieren
 | 
			
		||||
sudo cp -r ./dist/* /var/www/cc24-hub/
 | 
			
		||||
sudo cp ./src/data/tools.yaml /var/www/cc24-hub/src/data/
 | 
			
		||||
sudo cp package.json /var/www/cc24-hub/
 | 
			
		||||
 | 
			
		||||
# Prod-Abhängigkeiten installieren
 | 
			
		||||
cd /var/www/cc24-hub
 | 
			
		||||
npm install --omit=dev
 | 
			
		||||
 | 
			
		||||
# Berechtigungen setzen
 | 
			
		||||
sudo chown -R www-data:www-data /var/www/cc24-hub
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
##### 3. Umgebungsvariablen setzen
 | 
			
		||||
 | 
			
		||||
Erstelle `/var/www/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
 | 
			
		||||
 | 
			
		||||
# Authentifizierung ("false" setzen für Tests, oder wenn kostenlose KI verwendet wird)
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
# Public Configuration
 | 
			
		||||
PUBLIC_BASE_URL=https://your-domain.com # hier die URL setzen, mit der von außen zugegriffen wird
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
# .env sichern
 | 
			
		||||
sudo chmod 600 /var/www/cc24-hub/.env
 | 
			
		||||
sudo chown www-data:www-data /var/www/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
 | 
			
		||||
 | 
			
		||||
Erstelle `/etc/nginx/sites-available/cc24-hub`:
 | 
			
		||||
 | 
			
		||||
```nginx
 | 
			
		||||
server {
 | 
			
		||||
    listen 80;
 | 
			
		||||
    server_name your-domain.com;
 | 
			
		||||
    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;
 | 
			
		||||
    
 | 
			
		||||
    # 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
 | 
			
		||||
    location / {
 | 
			
		||||
        proxy_pass http://localhost:3000;
 | 
			
		||||
        proxy_http_version 1.1;
 | 
			
		||||
        proxy_set_header Upgrade $http_upgrade;
 | 
			
		||||
        proxy_set_header Connection 'upgrade';
 | 
			
		||||
        proxy_set_header Host $host;
 | 
			
		||||
        proxy_set_header X-Real-IP $remote_addr;
 | 
			
		||||
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
 | 
			
		||||
        proxy_set_header X-Forwarded-Proto $scheme;
 | 
			
		||||
        proxy_cache_bypass $http_upgrade;
 | 
			
		||||
        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";
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
##### 6. Daemon starten und Autostart setzen
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
# Enable Nginx site
 | 
			
		||||
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
 | 
			
		||||
sudo systemctl daemon-reload
 | 
			
		||||
sudo systemctl enable cc24-hub
 | 
			
		||||
sudo systemctl start cc24-hub
 | 
			
		||||
 | 
			
		||||
# Check status
 | 
			
		||||
sudo systemctl status cc24-hub
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
##### 7. Deployment verifizieren
 | 
			
		||||
 | 
			
		||||
```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
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### OIDC Konfigurieren
 | 
			
		||||
 | 
			
		||||
Nextcloud OIDC Einstellungen (sollte auch mit anderen OIDC-Anwendungen klappen):
 | 
			
		||||
- **Redirect URI**: `https://your-domain.com/auth/callback`
 | 
			
		||||
- **Logout URI**: `https://your-domain.com`
 | 
			
		||||
 | 
			
		||||
- `npm run dev` - Development Server
 | 
			
		||||
- `npm run build` - Produktions-Build
 | 
			
		||||
- `npm run preview` - Vorschau des Builds
 | 
			
		||||
- `npm run deploy:static` - Statisches Deployment (Script)
 | 
			
		||||
 | 
			
		||||
## 📁 Projektstruktur
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,23 +0,0 @@
 | 
			
		||||
// astro.config.mjs - Static deployment configuration
 | 
			
		||||
import { defineConfig } from 'astro/config';
 | 
			
		||||
 | 
			
		||||
export default defineConfig({
 | 
			
		||||
  // Static site generation - no adapter needed
 | 
			
		||||
  output: 'static',
 | 
			
		||||
  
 | 
			
		||||
  // Build configuration
 | 
			
		||||
  build: {
 | 
			
		||||
    assets: '_astro'
 | 
			
		||||
  },
 | 
			
		||||
  
 | 
			
		||||
  // Development server
 | 
			
		||||
  server: {
 | 
			
		||||
    port: 4321,
 | 
			
		||||
    host: true
 | 
			
		||||
  },
 | 
			
		||||
  
 | 
			
		||||
  // Ensure all pages are pre-rendered
 | 
			
		||||
  experimental: {
 | 
			
		||||
    prerender: true
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										22
									
								
								astro.config.mjs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								astro.config.mjs
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,22 @@
 | 
			
		||||
// astro.config.mjs - SSR configuration for authentication
 | 
			
		||||
import { defineConfig } from 'astro/config';
 | 
			
		||||
import node from '@astrojs/node';
 | 
			
		||||
 | 
			
		||||
export default defineConfig({
 | 
			
		||||
  // Server-side rendering for authentication and API routes
 | 
			
		||||
  output: 'server',
 | 
			
		||||
  adapter: node({
 | 
			
		||||
    mode: 'standalone'
 | 
			
		||||
  }),
 | 
			
		||||
  
 | 
			
		||||
  // Build configuration
 | 
			
		||||
  build: {
 | 
			
		||||
    assets: '_astro'
 | 
			
		||||
  },
 | 
			
		||||
  
 | 
			
		||||
  // Development server
 | 
			
		||||
  server: {
 | 
			
		||||
    port: 4321,
 | 
			
		||||
    host: true
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										5204
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5204
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										12
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								package.json
									
									
									
									
									
								
							@ -14,13 +14,15 @@
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "astro": "^5.3.0",
 | 
			
		||||
    "js-yaml": "^4.1.0"
 | 
			
		||||
    "@astrojs/node": "^9.3.0",
 | 
			
		||||
    "js-yaml": "^4.1.0",
 | 
			
		||||
    "jose": "^5.2.0",
 | 
			
		||||
    "cookie": "^0.6.0",
 | 
			
		||||
    "dotenv": "^16.4.5"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@types/js-yaml": "^4.0.9"
 | 
			
		||||
  },
 | 
			
		||||
  "optionalDependencies": {
 | 
			
		||||
    "@astrojs/node": "^9.3.0"
 | 
			
		||||
    "@types/js-yaml": "^4.0.9",
 | 
			
		||||
    "@types/cookie": "^0.6.0"
 | 
			
		||||
  },
 | 
			
		||||
  "engines": {
 | 
			
		||||
    "node": ">=18.0.0"
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										430
									
								
								src/components/AIQueryInterface.astro
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										430
									
								
								src/components/AIQueryInterface.astro
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,430 @@
 | 
			
		||||
---
 | 
			
		||||
import { promises as fs } from 'fs';
 | 
			
		||||
import { load } from 'js-yaml';
 | 
			
		||||
import path from 'path';
 | 
			
		||||
 | 
			
		||||
// Load tools data for validation
 | 
			
		||||
const yamlPath = path.join(process.cwd(), 'src/data/tools.yaml');
 | 
			
		||||
const yamlContent = await fs.readFile(yamlPath, 'utf8');
 | 
			
		||||
const data = load(yamlContent) as any;
 | 
			
		||||
const tools = data.tools;
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<!-- AI Query Interface -->
 | 
			
		||||
<section id="ai-interface" class="ai-interface" style="display: none;">
 | 
			
		||||
  <div class="ai-query-section">
 | 
			
		||||
    <div style="text-align: center; margin-bottom: 2rem;">
 | 
			
		||||
      <h2 style="margin-bottom: 1rem; color: var(--color-primary);">
 | 
			
		||||
        <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.75rem; vertical-align: middle;">
 | 
			
		||||
          <path d="M9 11H5a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7a2 2 0 0 0-2-2h-4"/>
 | 
			
		||||
          <path d="M9 11V7a3 3 0 0 1 6 0v4"/>
 | 
			
		||||
        </svg>
 | 
			
		||||
        KI-gestützte Tool-Empfehlungen
 | 
			
		||||
      </h2>
 | 
			
		||||
      <p class="text-muted" style="max-width: 700px; margin: 0 auto; line-height: 1.6;">
 | 
			
		||||
        Beschreiben Sie Ihr forensisches Szenario und erhalten Sie maßgeschneiderte Tool-Empfehlungen 
 | 
			
		||||
        basierend auf bewährten DFIR-Workflows und der verfügbaren Software-Datenbank.
 | 
			
		||||
      </p>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="ai-input-container" style="max-width: 800px; margin: 0 auto;">
 | 
			
		||||
      <textarea 
 | 
			
		||||
        id="ai-query-input" 
 | 
			
		||||
        placeholder="Beschreiben Sie Ihr forensisches Szenario... z.B. 'Verdacht auf Ransomware-Angriff auf Windows-Domänencontroller mit verschlüsselten Dateien und verdächtigen Netzwerkverbindungen'"
 | 
			
		||||
        style="min-height: 120px; resize: vertical; font-size: 0.9375rem; line-height: 1.5;"
 | 
			
		||||
        maxlength="2000"
 | 
			
		||||
      ></textarea>
 | 
			
		||||
      
 | 
			
		||||
      <!-- Privacy Notice -->
 | 
			
		||||
      <div style="margin-top: 0.5rem; margin-bottom: 1rem;">
 | 
			
		||||
        <p style="font-size: 0.75rem; color: var(--color-text-secondary); text-align: center; line-height: 1.4;">
 | 
			
		||||
          <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.25rem; vertical-align: middle;">
 | 
			
		||||
            <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>
 | 
			
		||||
          Ihre Anfrage wird an mistral.ai übertragen 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>
 | 
			
		||||
      
 | 
			
		||||
      <div style="display: flex; justify-content: center; gap: 1rem;">
 | 
			
		||||
        <button id="ai-submit-btn" class="btn btn-accent" style="padding: 0.75rem 2rem;">
 | 
			
		||||
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
            <path d="M14.828 14.828a4 4 0 0 1-5.656 0"/>
 | 
			
		||||
            <path d="M9 9a3 3 0 1 1 6 0c0 .749-.269 1.433-.73 1.96L11 14v1a1 1 0 0 1-1 1h-1a1 1 0 0 1-1-1v-1l-3.27-3.04A3 3 0 0 1 5 9a3 3 0 0 1 6 0"/>
 | 
			
		||||
            <path d="M12 17h.01"/>
 | 
			
		||||
          </svg>
 | 
			
		||||
          Empfehlungen generieren
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <!-- 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;">
 | 
			
		||||
      <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" stroke-width="2" style="animation: pulse 2s ease-in-out infinite;">
 | 
			
		||||
        <path d="M14.828 14.828a4 4 0 0 1-5.656 0"/>
 | 
			
		||||
        <path d="M9 9a3 3 0 1 1 6 0c0 .749-.269 1.433-.73 1.96L11 14v1a1 1 0 0 1-1 1h-1a1 1 0 0 1-1-1v-1l-3.27-3.04A3 3 0 0 1 5 9a3 3 0 0 1 6 0"/>
 | 
			
		||||
        <path d="M12 17h.01"/>
 | 
			
		||||
      </svg>
 | 
			
		||||
    </div>
 | 
			
		||||
    <p style="color: var(--color-text-secondary);">Analysiere Szenario und generiere Empfehlungen...</p>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <!-- Error State -->
 | 
			
		||||
  <div id="ai-error" class="ai-error" style="display: none; text-align: center; padding: 2rem;">
 | 
			
		||||
    <div style="background-color: var(--color-error); color: white; padding: 1rem; border-radius: 0.5rem; max-width: 600px; margin: 0 auto;">
 | 
			
		||||
      <h3 style="margin-bottom: 0.5rem;">Fehler bei der KI-Anfrage</h3>
 | 
			
		||||
      <p id="ai-error-message" style="margin: 0;">Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut.</p>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <!-- AI Results -->
 | 
			
		||||
  <div id="ai-results" class="ai-results" style="display: none;">
 | 
			
		||||
    <!-- Results will be populated here by JavaScript -->
 | 
			
		||||
  </div>
 | 
			
		||||
</section>
 | 
			
		||||
 | 
			
		||||
<script define:vars={{ tools }}>
 | 
			
		||||
document.addEventListener('DOMContentLoaded', () => {
 | 
			
		||||
  const aiInterface = document.getElementById('ai-interface');
 | 
			
		||||
  const aiInput = document.getElementById('ai-query-input');
 | 
			
		||||
  const aiSubmitBtn = document.getElementById('ai-submit-btn');
 | 
			
		||||
  const aiLoading = document.getElementById('ai-loading');
 | 
			
		||||
  const aiError = document.getElementById('ai-error');
 | 
			
		||||
  const aiErrorMessage = document.getElementById('ai-error-message');
 | 
			
		||||
  const aiResults = document.getElementById('ai-results');
 | 
			
		||||
 | 
			
		||||
  let currentRecommendation = null;
 | 
			
		||||
 | 
			
		||||
  if (!aiInput || !aiSubmitBtn || !aiLoading || !aiError || !aiResults) {
 | 
			
		||||
    console.error('AI interface elements not found');
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Character counter for input
 | 
			
		||||
  const updateCharacterCount = () => {
 | 
			
		||||
    const length = aiInput.value.length;
 | 
			
		||||
    const maxLength = 2000;
 | 
			
		||||
    
 | 
			
		||||
    // Find or create character counter
 | 
			
		||||
    let counter = document.getElementById('ai-char-counter');
 | 
			
		||||
    if (!counter) {
 | 
			
		||||
      counter = document.createElement('div');
 | 
			
		||||
      counter.id = 'ai-char-counter';
 | 
			
		||||
      counter.style.cssText = 'font-size: 0.75rem; color: var(--color-text-secondary); text-align: right; margin-top: 0.25rem;';
 | 
			
		||||
      aiInput.parentNode.insertBefore(counter, aiInput.nextSibling);
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    counter.textContent = `${length}/${maxLength}`;
 | 
			
		||||
    if (length > maxLength * 0.9) {
 | 
			
		||||
      counter.style.color = 'var(--color-warning)';
 | 
			
		||||
    } else {
 | 
			
		||||
      counter.style.color = 'var(--color-text-secondary)';
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  aiInput.addEventListener('input', updateCharacterCount);
 | 
			
		||||
  updateCharacterCount(); // Initial count
 | 
			
		||||
 | 
			
		||||
  // Submit handler
 | 
			
		||||
  const handleSubmit = async () => {
 | 
			
		||||
    const query = aiInput.value.trim();
 | 
			
		||||
    
 | 
			
		||||
    if (!query) {
 | 
			
		||||
      alert('Bitte geben Sie eine Beschreibung Ihres Szenarios 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;
 | 
			
		||||
    aiSubmitBtn.textContent = 'Generiere Empfehlungen...';
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const response = await fetch('/api/ai/query', {
 | 
			
		||||
        method: 'POST',
 | 
			
		||||
        headers: {
 | 
			
		||||
          'Content-Type': 'application/json',
 | 
			
		||||
        },
 | 
			
		||||
        body: JSON.stringify({ query })
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      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;
 | 
			
		||||
      
 | 
			
		||||
      // Display results
 | 
			
		||||
      displayResults(data.recommendation, query);
 | 
			
		||||
      aiLoading.style.display = 'none';
 | 
			
		||||
      aiResults.style.display = 'block';
 | 
			
		||||
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('AI query failed:', error);
 | 
			
		||||
      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
 | 
			
		||||
      aiSubmitBtn.disabled = false;
 | 
			
		||||
      aiSubmitBtn.innerHTML = `
 | 
			
		||||
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
          <path d="M14.828 14.828a4 4 0 0 1-5.656 0"/>
 | 
			
		||||
          <path d="M9 9a3 3 0 1 1 6 0c0 .749-.269 1.433-.73 1.96L11 14v1a1 1 0 0 1-1 1h-1a1 1 0 0 1-1-1v-1l-3.27-3.04A3 3 0 0 1 5 9a3 3 0 0 1 6 0"/>
 | 
			
		||||
          <path d="M12 17h.01"/>
 | 
			
		||||
        </svg>
 | 
			
		||||
        Empfehlungen generieren
 | 
			
		||||
      `;
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  // Event listeners
 | 
			
		||||
  aiSubmitBtn.addEventListener('click', handleSubmit);
 | 
			
		||||
  
 | 
			
		||||
  aiInput.addEventListener('keydown', (e) => {
 | 
			
		||||
    if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      handleSubmit();
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // Function to restore AI results when switching back to AI view
 | 
			
		||||
  window.restoreAIResults = () => {
 | 
			
		||||
    if (currentRecommendation) {
 | 
			
		||||
      aiResults.style.display = 'block';
 | 
			
		||||
      aiLoading.style.display = 'none';
 | 
			
		||||
      aiError.style.display = 'none';
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  // Helper function to format workflow suggestions as proper lists
 | 
			
		||||
  function formatWorkflowSuggestion(text) {
 | 
			
		||||
    // Check if text contains numbered list items (1., 2., 3., etc.)
 | 
			
		||||
    const numberedListPattern = /(\d+\.\s)/g;
 | 
			
		||||
    
 | 
			
		||||
    if (numberedListPattern.test(text)) {
 | 
			
		||||
      // Split by numbered items and clean up
 | 
			
		||||
      const items = text.split(/\d+\.\s/).filter(item => item.trim().length > 0);
 | 
			
		||||
      
 | 
			
		||||
      if (items.length > 1) {
 | 
			
		||||
        const listItems = items.map(item => 
 | 
			
		||||
          `<li style="margin-bottom: 0.5rem; line-height: 1.6;">${item.trim()}</li>`
 | 
			
		||||
        ).join('');
 | 
			
		||||
        
 | 
			
		||||
        return `<ol style="margin: 0; padding-left: 1.5rem; color: var(--color-text);">${listItems}</ol>`;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Check for bullet points (-, *, •)
 | 
			
		||||
    const bulletPattern = /^[\s]*[-\*•]\s/gm;
 | 
			
		||||
    if (bulletPattern.test(text)) {
 | 
			
		||||
      const items = text.split(/^[\s]*[-\*•]\s/gm).filter(item => item.trim().length > 0);
 | 
			
		||||
      
 | 
			
		||||
      if (items.length > 1) {
 | 
			
		||||
        const listItems = items.map(item => 
 | 
			
		||||
          `<li style="margin-bottom: 0.5rem; line-height: 1.6;">${item.trim()}</li>`
 | 
			
		||||
        ).join('');
 | 
			
		||||
        
 | 
			
		||||
        return `<ul style="margin: 0; padding-left: 1.5rem; color: var(--color-text);">${listItems}</ul>`;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Check for line breaks that might indicate separate points
 | 
			
		||||
    if (text.includes('\n')) {
 | 
			
		||||
      const lines = text.split('\n').filter(line => line.trim().length > 0);
 | 
			
		||||
      if (lines.length > 1) {
 | 
			
		||||
        const listItems = lines.map(line => 
 | 
			
		||||
          `<li style="margin-bottom: 0.5rem; line-height: 1.6;">${line.trim()}</li>`
 | 
			
		||||
        ).join('');
 | 
			
		||||
        
 | 
			
		||||
        return `<ul style="margin: 0; padding-left: 1.5rem; color: var(--color-text);">${listItems}</ul>`;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Fallback to regular paragraph if no list format detected
 | 
			
		||||
    return `<p style="margin: 0; line-height: 1.6; color: var(--color-text);">${text}</p>`;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function displayResults(recommendation, originalQuery) {
 | 
			
		||||
    // Group tools by phase
 | 
			
		||||
    const toolsByPhase = {};
 | 
			
		||||
    const phaseOrder = ['data-collection', 'examination', 'analysis', 'reporting'];
 | 
			
		||||
    const phaseNames = {
 | 
			
		||||
      'data-collection': 'Datensammlung',
 | 
			
		||||
      'examination': 'Auswertung', 
 | 
			
		||||
      'analysis': 'Analyse',
 | 
			
		||||
      'reporting': 'Bericht & Präsentation'
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Initialize phases
 | 
			
		||||
    phaseOrder.forEach(phase => {
 | 
			
		||||
      toolsByPhase[phase] = [];
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Group recommended tools by phase
 | 
			
		||||
    recommendation.recommended_tools?.forEach(recTool => {
 | 
			
		||||
      if (toolsByPhase[recTool.phase]) {
 | 
			
		||||
        // Find full tool data
 | 
			
		||||
        const fullTool = tools.find(t => t.name === recTool.name);
 | 
			
		||||
        if (fullTool) {
 | 
			
		||||
          toolsByPhase[recTool.phase].push({
 | 
			
		||||
            ...fullTool,
 | 
			
		||||
            recommendation: recTool
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const resultsHTML = `
 | 
			
		||||
      <div class="workflow-container">
 | 
			
		||||
        <div style="text-align: center; margin-bottom: 2rem; padding: 1.5rem; background: linear-gradient(135deg, var(--color-accent) 0%, var(--color-primary) 100%); color: white; border-radius: 0.75rem;">
 | 
			
		||||
          <h3 style="margin: 0 0 0.75rem 0; font-size: 1.5rem;">Empfohlener DFIR-Workflow</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>"
 | 
			
		||||
          </p>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        ${recommendation.scenario_analysis ? `
 | 
			
		||||
          <div class="card" style="margin-bottom: 2rem; border-left: 4px solid var(--color-primary);">
 | 
			
		||||
            <h4 style="margin: 0 0 1rem 0; color: var(--color-primary); display: flex; align-items: center; gap: 0.5rem;">
 | 
			
		||||
              <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
 | 
			
		||||
                <path d="M12 20h9"/>
 | 
			
		||||
                <path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
 | 
			
		||||
              </svg>
 | 
			
		||||
              Szenario-Analyse
 | 
			
		||||
            </h4>
 | 
			
		||||
            ${formatWorkflowSuggestion(recommendation.scenario_analysis)}
 | 
			
		||||
          </div>
 | 
			
		||||
        ` : ''}
 | 
			
		||||
 | 
			
		||||
        ${phaseOrder.map((phase, index) => {
 | 
			
		||||
          const phaseTools = toolsByPhase[phase];
 | 
			
		||||
          if (phaseTools.length === 0) return '';
 | 
			
		||||
 | 
			
		||||
          return `
 | 
			
		||||
            <div class="workflow-phase">
 | 
			
		||||
              <div class="phase-header">
 | 
			
		||||
                <div class="phase-number">${index + 1}</div>
 | 
			
		||||
                <div class="phase-info">
 | 
			
		||||
                  <h3 class="phase-title">${phaseNames[phase]}</h3>
 | 
			
		||||
                  <div class="phase-tools">
 | 
			
		||||
                    ${phaseTools.map(tool => {
 | 
			
		||||
                      const hasValidProjectUrl = tool.projectUrl !== undefined && 
 | 
			
		||||
                                                tool.projectUrl !== null && 
 | 
			
		||||
                                                tool.projectUrl !== "" && 
 | 
			
		||||
                                                tool.projectUrl.trim() !== "";
 | 
			
		||||
                      
 | 
			
		||||
                      const priorityColors = {
 | 
			
		||||
                        high: 'var(--color-error)',
 | 
			
		||||
                        medium: 'var(--color-warning)', 
 | 
			
		||||
                        low: 'var(--color-accent)'
 | 
			
		||||
                      };
 | 
			
		||||
 | 
			
		||||
                      return `
 | 
			
		||||
                        <div class="tool-recommendation ${hasValidProjectUrl ? 'hosted' : (tool.license !== 'Proprietary' ? 'oss' : '')}" 
 | 
			
		||||
                             onclick="window.showToolDetails('${tool.name}')">
 | 
			
		||||
                          <div class="tool-rec-header">
 | 
			
		||||
                            <h4 class="tool-rec-name">${tool.name}</h4>
 | 
			
		||||
                            <span class="tool-rec-priority ${tool.recommendation.priority}" 
 | 
			
		||||
                                  style="background-color: ${priorityColors[tool.recommendation.priority]};">
 | 
			
		||||
                              ${tool.recommendation.priority}
 | 
			
		||||
                            </span>
 | 
			
		||||
                          </div>
 | 
			
		||||
                          
 | 
			
		||||
                          <div class="tool-rec-justification">
 | 
			
		||||
                            "${tool.recommendation.justification}"
 | 
			
		||||
                          </div>
 | 
			
		||||
                          
 | 
			
		||||
                          <div class="tool-rec-metadata">
 | 
			
		||||
                            <div style="display: flex; flex-wrap: wrap; gap: 0.25rem; margin-bottom: 0.5rem;">
 | 
			
		||||
                              ${hasValidProjectUrl ? '<span class="badge badge-primary">Self-Hosted</span>' : ''}
 | 
			
		||||
                              ${tool.license !== 'Proprietary' ? '<span class="badge badge-success">Open Source</span>' : ''}
 | 
			
		||||
                              <span class="badge" style="background-color: var(--color-bg-tertiary); color: var(--color-text);">${tool.skillLevel}</span>
 | 
			
		||||
                            </div>
 | 
			
		||||
                            <div style="font-size: 0.8125rem; color: var(--color-text-secondary);">
 | 
			
		||||
                              ${tool.platforms.join(', ')} • ${tool.license}
 | 
			
		||||
                            </div>
 | 
			
		||||
                          </div>
 | 
			
		||||
                        </div>
 | 
			
		||||
                      `;
 | 
			
		||||
                    }).join('')}
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
              ${index < phaseOrder.length - 1 ? `
 | 
			
		||||
                <div class="workflow-arrow">
 | 
			
		||||
                  <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" stroke-width="2">
 | 
			
		||||
                    <line x1="12" y1="5" x2="12" y2="19"/>
 | 
			
		||||
                    <polyline points="19,12 12,19 5,12"/>
 | 
			
		||||
                  </svg>
 | 
			
		||||
                </div>
 | 
			
		||||
              ` : ''}
 | 
			
		||||
            </div>
 | 
			
		||||
          `;
 | 
			
		||||
        }).join('')}
 | 
			
		||||
 | 
			
		||||
        ${recommendation.workflow_suggestion ? `
 | 
			
		||||
          <div class="card" style="margin-top: 2rem; border-left: 4px solid var(--color-accent);">
 | 
			
		||||
            <h4 style="margin: 0 0 1rem 0; color: var(--color-accent); display: flex; align-items: center; gap: 0.5rem;">
 | 
			
		||||
              <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
 | 
			
		||||
                <polyline points="9,11 12,14 22,4"/>
 | 
			
		||||
                <path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
 | 
			
		||||
              </svg>
 | 
			
		||||
              Workflow-Empfehlung
 | 
			
		||||
            </h4>
 | 
			
		||||
            ${formatWorkflowSuggestion(recommendation.workflow_suggestion)}
 | 
			
		||||
          </div>
 | 
			
		||||
        ` : ''}
 | 
			
		||||
 | 
			
		||||
        ${recommendation.additional_notes ? `
 | 
			
		||||
          <div class="card" style="margin-top: 1rem; background-color: var(--color-warning); color: white;">
 | 
			
		||||
            <h4 style="margin: 0 0 1rem 0; display: flex; align-items: center; gap: 0.5rem;">
 | 
			
		||||
              <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>
 | 
			
		||||
              Wichtige Hinweise
 | 
			
		||||
            </h4>
 | 
			
		||||
            <div style="color: white;">
 | 
			
		||||
              ${formatWorkflowSuggestion(recommendation.additional_notes).replace(/color: var\(--color-text\)/g, 'color: white')}
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        ` : ''}
 | 
			
		||||
      </div>
 | 
			
		||||
    `;
 | 
			
		||||
 | 
			
		||||
    aiResults.innerHTML = resultsHTML;
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
@ -29,7 +29,7 @@ const hasValidProjectUrl = tool.projectUrl !== undefined &&
 | 
			
		||||
const hasKnowledgebase = tool.knowledgebase === true;
 | 
			
		||||
 | 
			
		||||
// Determine card styling based on hosting status (derived from projectUrl)
 | 
			
		||||
const cardClass = hasValidProjectUrl ? 'card card-hosted' : (tool.license !== 'Proprietary' ? 'card card-oss' : 'card');
 | 
			
		||||
const cardClass = hasValidProjectUrl ? 'card card-hosted tool-card' : (tool.license !== 'Proprietary' ? 'card card-oss tool-card' : 'card tool-card');
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<div class={cardClass} onclick={`window.showToolDetails('${tool.name}')`} style="cursor: pointer;">
 | 
			
		||||
@ -79,7 +79,7 @@ const cardClass = hasValidProjectUrl ? 'card card-hosted' : (tool.license !== 'P
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
  
 | 
			
		||||
  <div style="display: flex; flex-wrap: wrap; gap: 0.25rem; margin-bottom: 1rem;">
 | 
			
		||||
  <div class="tool-tags-container" style="display: flex; flex-wrap: wrap; gap: 0.25rem; margin-bottom: 1rem;">
 | 
			
		||||
    {tool.tags.map(tag => (
 | 
			
		||||
      <span class="tag">{tag}</span>
 | 
			
		||||
    ))}
 | 
			
		||||
 | 
			
		||||
@ -108,9 +108,24 @@ const sortedTags = Object.entries(tagFrequency)
 | 
			
		||||
  </div>
 | 
			
		||||
  
 | 
			
		||||
  <!-- View Toggle -->
 | 
			
		||||
  <div style="display: flex; gap: 1rem; margin-bottom: 1.5rem;">
 | 
			
		||||
    <button class="btn btn-secondary view-toggle active" data-view="grid">Kachelansicht</button>
 | 
			
		||||
    <button class="btn btn-secondary view-toggle" data-view="matrix">Matrix-Ansicht</button>
 | 
			
		||||
  <!--<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>
 | 
			
		||||
    
 | 
			
		||||
    <!-- AI Recommendations Button (only visible when authenticated) -->
 | 
			
		||||
    <button 
 | 
			
		||||
      id="ai-view-toggle" 
 | 
			
		||||
      class="btn btn-secondary view-toggle" 
 | 
			
		||||
      data-view="ai"
 | 
			
		||||
      style="display: none; background-color: var(--color-accent); color: white; border-color: var(--color-accent);"
 | 
			
		||||
    >
 | 
			
		||||
      <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem; height:25px">
 | 
			
		||||
        <path d="M9 11H5a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7a2 2 0 0 0-2-2h-4"/>
 | 
			
		||||
        <path d="M9 11V7a3 3 0 0 1 6 0v4"/>
 | 
			
		||||
      </svg>
 | 
			
		||||
      KI-Empfehlungen
 | 
			
		||||
    </button>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
@ -129,12 +144,33 @@ const sortedTags = Object.entries(tagFrequency)
 | 
			
		||||
    const tagCloud = document.getElementById('tag-cloud');
 | 
			
		||||
    const tagCloudToggle = document.getElementById('tag-cloud-toggle');
 | 
			
		||||
    const viewToggles = document.querySelectorAll('.view-toggle');
 | 
			
		||||
    const aiViewToggle = document.getElementById('ai-view-toggle');
 | 
			
		||||
    
 | 
			
		||||
    // Track selected tags and phase
 | 
			
		||||
    let selectedTags = new Set();
 | 
			
		||||
    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;
 | 
			
		||||
 | 
			
		||||
@ -14,18 +14,18 @@ const tools = data.tools;
 | 
			
		||||
 | 
			
		||||
// Separate collaboration tools from domain-specific tools
 | 
			
		||||
const collaborationTools = tools.filter((tool: any) => 
 | 
			
		||||
  tool.phases && tool.phases.includes('collaboration')
 | 
			
		||||
  tool.phases && tool.phases.includes('collaboration-general')
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const domainTools = tools.filter((tool: any) => 
 | 
			
		||||
  !tool.phases || !tool.phases.includes('collaboration')
 | 
			
		||||
  !tool.phases || !tool.phases.includes('collaboration-general')
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// Create matrix structure for domain-specific tools only
 | 
			
		||||
const matrix: Record<string, Record<string, any[]>> = {};
 | 
			
		||||
domains.forEach((domain: any) => {
 | 
			
		||||
  matrix[domain.id] = {};
 | 
			
		||||
  phases.filter((phase: any) => phase.id !== 'collaboration').forEach((phase: any) => {
 | 
			
		||||
  phases.filter((phase: any) => phase.id !== 'collaboration-general').forEach((phase: any) => {
 | 
			
		||||
    matrix[domain.id][phase.id] = domainTools.filter((tool: any) => 
 | 
			
		||||
      tool.domains && tool.domains.includes(domain.id) && tool.phases && tool.phases.includes(phase.id)
 | 
			
		||||
    );
 | 
			
		||||
@ -75,7 +75,7 @@ domains.forEach((domain: any) => {
 | 
			
		||||
      <thead>
 | 
			
		||||
        <tr>
 | 
			
		||||
          <th style="width: 200px;">Domain / Phase</th>
 | 
			
		||||
          {phases.filter((phase: any) => phase.id !== 'collaboration').map((phase: any) => (
 | 
			
		||||
          {phases.filter((phase: any) => phase.id !== 'collaboration-general').map((phase: any) => (
 | 
			
		||||
            <th data-phase={phase.id}>{phase.name}</th>
 | 
			
		||||
          ))}
 | 
			
		||||
        </tr>
 | 
			
		||||
@ -84,7 +84,7 @@ domains.forEach((domain: any) => {
 | 
			
		||||
        {domains.map((domain: any) => (
 | 
			
		||||
          <tr data-domain={domain.id}>
 | 
			
		||||
            <th>{domain.name}</th>
 | 
			
		||||
            {phases.filter((phase: any) => phase.id !== 'collaboration').map((phase: any) => (
 | 
			
		||||
            {phases.filter((phase: any) => phase.id !== 'collaboration-general').map((phase: any) => (
 | 
			
		||||
              <td class="matrix-cell" data-domain={domain.id} data-phase={phase.id}>
 | 
			
		||||
              {matrix[domain.id][phase.id].map((tool: any) => {
 | 
			
		||||
                const hasValidProjectUrl = tool.projectUrl !== undefined && 
 | 
			
		||||
@ -364,13 +364,13 @@ domains.forEach((domain: any) => {
 | 
			
		||||
      const dfirMatrixSection = document.getElementById('dfir-matrix-section');
 | 
			
		||||
      const collaborationContainer = document.getElementById('collaboration-tools-container');
 | 
			
		||||
      
 | 
			
		||||
      if (selectedPhase === 'collaboration') {
 | 
			
		||||
      if (selectedPhase === 'collaboration-general') {
 | 
			
		||||
        // Show only collaboration tools, hide matrix
 | 
			
		||||
        collaborationSection.style.display = 'block';
 | 
			
		||||
        dfirMatrixSection.style.display = 'none';
 | 
			
		||||
        
 | 
			
		||||
        // Filter collaboration tools
 | 
			
		||||
        const filteredCollaboration = filtered.filter(tool => (tool.phases || []).includes('collaboration'));
 | 
			
		||||
        const filteredCollaboration = filtered.filter(tool => (tool.phases || []).includes('collaboration-general'));
 | 
			
		||||
        collaborationContainer.innerHTML = '';
 | 
			
		||||
        
 | 
			
		||||
        filteredCollaboration.forEach(tool => {
 | 
			
		||||
@ -386,7 +386,7 @@ domains.forEach((domain: any) => {
 | 
			
		||||
          collaborationSection.style.display = 'block';
 | 
			
		||||
          
 | 
			
		||||
          // Show all collaboration tools that pass general filters
 | 
			
		||||
          const filteredCollaboration = filtered.filter(tool => (tool.phases || []).includes('collaboration'));
 | 
			
		||||
          const filteredCollaboration = filtered.filter(tool => (tool.phases || []).includes('collaboration-general'));
 | 
			
		||||
          collaborationContainer.innerHTML = '';
 | 
			
		||||
          
 | 
			
		||||
          filteredCollaboration.forEach(tool => {
 | 
			
		||||
@ -404,7 +404,7 @@ domains.forEach((domain: any) => {
 | 
			
		||||
        });
 | 
			
		||||
        
 | 
			
		||||
        // Re-populate with filtered DFIR tools - with safe array handling
 | 
			
		||||
        const filteredDfirTools = filtered.filter(tool => !(tool.phases || []).includes('collaboration'));
 | 
			
		||||
        const filteredDfirTools = filtered.filter(tool => !(tool.phases || []).includes('collaboration-general'));
 | 
			
		||||
        filteredDfirTools.forEach(tool => {
 | 
			
		||||
          const hasValidProjectUrl = tool.projectUrl !== undefined && 
 | 
			
		||||
                                    tool.projectUrl !== null && 
 | 
			
		||||
@ -416,7 +416,7 @@ domains.forEach((domain: any) => {
 | 
			
		||||
          
 | 
			
		||||
          domains.forEach(domain => {
 | 
			
		||||
            phases.forEach(phase => {
 | 
			
		||||
              if (phase !== 'collaboration') {
 | 
			
		||||
              if (phase !== 'collaboration-general') {
 | 
			
		||||
                const cell = document.querySelector(`[data-domain="${domain}"][data-phase="${phase}"]`);
 | 
			
		||||
                if (cell) {
 | 
			
		||||
                  const chip = document.createElement('span');
 | 
			
		||||
 | 
			
		||||
@ -697,8 +697,17 @@ tools:
 | 
			
		||||
      kollaborative Phasen und sichere Speicherung von Beweismitteln
 | 
			
		||||
      mit Versionierung.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - malware-analysis
 | 
			
		||||
      - fraud-investigation
 | 
			
		||||
      - network-forensics
 | 
			
		||||
      - mobile-forensics
 | 
			
		||||
      - cloud-forensics
 | 
			
		||||
      - ics-forensics
 | 
			
		||||
    phases:
 | 
			
		||||
      - collaboration-general
 | 
			
		||||
      - reporting
 | 
			
		||||
    platforms:
 | 
			
		||||
      - Web
 | 
			
		||||
    skillLevel: novice
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										302
									
								
								src/pages/api/ai/query.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										302
									
								
								src/pages/api/ai/query.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,302 @@
 | 
			
		||||
// src/pages/api/ai/query.ts
 | 
			
		||||
// src/pages/api/ai/query.ts
 | 
			
		||||
import type { APIRoute } from 'astro';
 | 
			
		||||
import { getSessionFromRequest, verifySession } from '../../../utils/auth.js';
 | 
			
		||||
import { promises as fs } from 'fs';
 | 
			
		||||
import { load } from 'js-yaml';
 | 
			
		||||
import path from 'path';
 | 
			
		||||
 | 
			
		||||
export const prerender = false;
 | 
			
		||||
 | 
			
		||||
function getEnv(key: string): string {
 | 
			
		||||
  const value = process.env[key];
 | 
			
		||||
  if (!value) {
 | 
			
		||||
    throw new Error(`Missing environment variable: ${key}`);
 | 
			
		||||
  }
 | 
			
		||||
  return value;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const AI_MODEL = getEnv('AI_MODEL');
 | 
			
		||||
// Rate limiting store (in production, use Redis)
 | 
			
		||||
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
 | 
			
		||||
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
 | 
			
		||||
const RATE_LIMIT_MAX = 10; // 10 requests per minute per user
 | 
			
		||||
 | 
			
		||||
// Input validation and sanitization
 | 
			
		||||
function sanitizeInput(input: string): string {
 | 
			
		||||
  // Remove potential prompt injection patterns
 | 
			
		||||
  const dangerous = [
 | 
			
		||||
    /ignore\s+previous\s+instructions?/gi,
 | 
			
		||||
    /new\s+instructions?:/gi,
 | 
			
		||||
    /system\s*:/gi,
 | 
			
		||||
    /assistant\s*:/gi,
 | 
			
		||||
    /human\s*:/gi,
 | 
			
		||||
    /<\s*\/?system\s*>/gi,
 | 
			
		||||
    /```\s*system/gi,
 | 
			
		||||
  ];
 | 
			
		||||
  
 | 
			
		||||
  let sanitized = input.trim();
 | 
			
		||||
  dangerous.forEach(pattern => {
 | 
			
		||||
    sanitized = sanitized.replace(pattern, '[FILTERED]');
 | 
			
		||||
  });
 | 
			
		||||
  
 | 
			
		||||
  // Limit length
 | 
			
		||||
  return sanitized.slice(0, 2000);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Strip markdown code blocks from AI response
 | 
			
		||||
function stripMarkdownJson(content: string): string {
 | 
			
		||||
  // Remove ```json and ``` wrappers
 | 
			
		||||
  return content
 | 
			
		||||
    .replace(/^```json\s*/i, '')
 | 
			
		||||
    .replace(/\s*```\s*$/, '')
 | 
			
		||||
    .trim();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Rate limiting check
 | 
			
		||||
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;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Load tools database
 | 
			
		||||
async function loadToolsDatabase() {
 | 
			
		||||
  try {
 | 
			
		||||
    const yamlPath = path.join(process.cwd(), 'src/data/tools.yaml');
 | 
			
		||||
    const yamlContent = await fs.readFile(yamlPath, 'utf8');
 | 
			
		||||
    return load(yamlContent) as any;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Failed to load tools database:', error);
 | 
			
		||||
    throw new Error('Database unavailable');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Create system prompt
 | 
			
		||||
function createSystemPrompt(toolsData: any): string {
 | 
			
		||||
  const toolsList = toolsData.tools.map((tool: any) => ({
 | 
			
		||||
    name: tool.name,
 | 
			
		||||
    description: tool.description,
 | 
			
		||||
    domains: tool.domains,
 | 
			
		||||
    phases: tool.phases,
 | 
			
		||||
    platforms: tool.platforms,
 | 
			
		||||
    skillLevel: tool.skillLevel,
 | 
			
		||||
    license: tool.license,
 | 
			
		||||
    tags: tool.tags,
 | 
			
		||||
    projectUrl: tool.projectUrl ? 'self-hosted' : 'external'
 | 
			
		||||
  }));
 | 
			
		||||
 | 
			
		||||
  // Dynamically build phases list from configuration
 | 
			
		||||
  const phasesDescription = toolsData.phases.map((phase: any) => 
 | 
			
		||||
    `- ${phase.id}: ${phase.name}`
 | 
			
		||||
  ).join('\n');
 | 
			
		||||
 | 
			
		||||
  // Dynamically build domains list from configuration
 | 
			
		||||
  const domainsDescription = toolsData.domains.map((domain: any) => 
 | 
			
		||||
    `- ${domain.id}: ${domain.name}`
 | 
			
		||||
  ).join('\n');
 | 
			
		||||
 | 
			
		||||
  return `Du bist ein DFIR (Digital Forensics and Incident Response) Experte, der Ermittlern bei der Toolauswahl hilft.
 | 
			
		||||
 | 
			
		||||
VERFÜGBARE TOOLS DATABASE:
 | 
			
		||||
${JSON.stringify(toolsList, null, 2)}
 | 
			
		||||
 | 
			
		||||
UNTERSUCHUNGSPHASEN (NIST Framework):
 | 
			
		||||
${phasesDescription}
 | 
			
		||||
 | 
			
		||||
FORENSISCHE DOMÄNEN:
 | 
			
		||||
${domainsDescription}
 | 
			
		||||
 | 
			
		||||
WICHTIGE REGELN:
 | 
			
		||||
1. Open Source Tools bevorzugen (license != "Proprietary") 
 | 
			
		||||
2. Pro Phase 1-3 Tools empfehlen (immer mindestens 1 wenn verfügbar)
 | 
			
		||||
3. Tools können in MEHREREN Phasen empfohlen werden wenn sinnvoll - versuche ein Tool für jede Phase zu empfehlen!
 | 
			
		||||
4. Für Reporting-Phase: Visualisierungs- und Dokumentationstools einschließen
 | 
			
		||||
5. Gib stets dem spezieller für den Fall geeigneten Werkzeug den Vorzug.
 | 
			
		||||
6. Deutsche Antworten für deutsche Anfragen, English for English queries
 | 
			
		||||
7. Bewerbe NIEMALS Proprietäre Software fälschlicherweise als Open-Source-Tools, erkenne aber an, falls diese besser geeignet sein könnte.
 | 
			
		||||
 | 
			
		||||
TOOL-AUSWAHL NACH PHASE:
 | 
			
		||||
- Datensammlung: Imaging, Acquisition, Remote Collection Tools
 | 
			
		||||
- Auswertung: Parsing, Extraction, Initial Analysis Tools  
 | 
			
		||||
- Analyse: Deep Analysis, Correlation, Visualization Tools
 | 
			
		||||
- Berichterstattung: Documentation, Visualization, Presentation Tools (z.B. QGIS für Geodaten, Timeline-Tools)
 | 
			
		||||
 | 
			
		||||
ANTWORT-FORMAT (strict JSON):
 | 
			
		||||
{
 | 
			
		||||
  "scenario_analysis": "Detaillierte Analyse des Szenarios auf Deutsch/English",
 | 
			
		||||
  "recommended_tools": [
 | 
			
		||||
    {
 | 
			
		||||
      "name": "EXAKTER Name aus der Database",
 | 
			
		||||
      "priority": "high|medium|low", 
 | 
			
		||||
      "phase": "data-collection|examination|analysis|reporting",
 | 
			
		||||
      "justification": "Warum dieses Tool für diese Phase und Szenario geeignet ist"
 | 
			
		||||
    }
 | 
			
		||||
  ],
 | 
			
		||||
  "workflow_suggestion": "Vorgeschlagener Untersuchungsablauf",
 | 
			
		||||
  "additional_notes": "Wichtige Überlegungen und Hinweise"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
Antworte NUR mit validen JSON. Keine zusätzlichen Erklärungen außerhalb des JSON.`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Rate limiting
 | 
			
		||||
    if (!checkRateLimit(userId)) {
 | 
			
		||||
      return new Response(JSON.stringify({ error: 'Rate limit exceeded' }), {
 | 
			
		||||
        status: 429,
 | 
			
		||||
        headers: { 'Content-Type': 'application/json' }
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Parse request body
 | 
			
		||||
    const body = await request.json();
 | 
			
		||||
    const { query } = body;
 | 
			
		||||
 | 
			
		||||
    if (!query || typeof query !== 'string') {
 | 
			
		||||
      return new Response(JSON.stringify({ error: 'Query required' }), {
 | 
			
		||||
        status: 400,
 | 
			
		||||
        headers: { 'Content-Type': 'application/json' }
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // 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' }
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Load tools database
 | 
			
		||||
    const toolsData = await loadToolsDatabase();
 | 
			
		||||
 | 
			
		||||
    // Create AI request
 | 
			
		||||
    const systemPrompt = createSystemPrompt(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, // or whatever model is available
 | 
			
		||||
        messages: [
 | 
			
		||||
          {
 | 
			
		||||
            role: 'system',
 | 
			
		||||
            content: systemPrompt
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            role: 'user', 
 | 
			
		||||
            content: sanitizedQuery
 | 
			
		||||
          }
 | 
			
		||||
        ],
 | 
			
		||||
        max_tokens: 2000,
 | 
			
		||||
        temperature: 0.3
 | 
			
		||||
      })
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    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' }
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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' }
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Parse AI JSON response
 | 
			
		||||
    let recommendation;
 | 
			
		||||
    try {
 | 
			
		||||
      const cleanedContent = stripMarkdownJson(aiContent);
 | 
			
		||||
      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' }
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Validate tool names against database
 | 
			
		||||
    const validToolNames = new Set(toolsData.tools.map((t: any) => t.name));
 | 
			
		||||
    const validatedRecommendation = {
 | 
			
		||||
      ...recommendation,
 | 
			
		||||
      recommended_tools: recommendation.recommended_tools?.filter((tool: any) => {
 | 
			
		||||
        if (!validToolNames.has(tool.name)) {
 | 
			
		||||
          console.warn(`AI recommended unknown tool: ${tool.name}`);
 | 
			
		||||
          return false;
 | 
			
		||||
        }
 | 
			
		||||
        return true;
 | 
			
		||||
      }) || []
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Log successful query
 | 
			
		||||
    console.log(`[AI Query] User: ${userId}, Query length: ${sanitizedQuery.length}, Tools: ${validatedRecommendation.recommended_tools.length}`);
 | 
			
		||||
 | 
			
		||||
    return new Response(JSON.stringify({
 | 
			
		||||
      success: true,
 | 
			
		||||
      recommendation: validatedRecommendation,
 | 
			
		||||
      query: sanitizedQuery
 | 
			
		||||
    }), {
 | 
			
		||||
      status: 200,
 | 
			
		||||
      headers: { 'Content-Type': 'application/json' }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('AI query error:', error);
 | 
			
		||||
    return new Response(JSON.stringify({ error: 'Internal server error' }), {
 | 
			
		||||
      status: 500,
 | 
			
		||||
      headers: { 'Content-Type': 'application/json' }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										100
									
								
								src/pages/api/auth/callback.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								src/pages/api/auth/callback.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,100 @@
 | 
			
		||||
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 {
 | 
			
		||||
    // Debug: multiple ways to access URL parameters
 | 
			
		||||
    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' }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										34
									
								
								src/pages/api/auth/login.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/pages/api/auth/login.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,34 @@
 | 
			
		||||
import type { APIRoute } from 'astro';
 | 
			
		||||
import { generateAuthUrl, generateState, logAuthEvent } from '../../../utils/auth.js';
 | 
			
		||||
 | 
			
		||||
export const prerender = false;
 | 
			
		||||
 | 
			
		||||
export const GET: APIRoute = async ({ url, redirect }) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const state = generateState();
 | 
			
		||||
    const authUrl = generateAuthUrl(state);
 | 
			
		||||
    
 | 
			
		||||
    // Debug: log the generated URL
 | 
			
		||||
    console.log('Generated auth URL:', authUrl);
 | 
			
		||||
    
 | 
			
		||||
    // Get the intended destination after login (if any)
 | 
			
		||||
    const returnTo = url.searchParams.get('returnTo') || '/';
 | 
			
		||||
    
 | 
			
		||||
    logAuthEvent('Login initiated', { returnTo, authUrl });
 | 
			
		||||
    
 | 
			
		||||
    // Store state and returnTo in a cookie for the callback
 | 
			
		||||
    const stateData = JSON.stringify({ state, returnTo });
 | 
			
		||||
    const stateCookie = `auth_state=${encodeURIComponent(stateData)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=600`; // 10 minutes
 | 
			
		||||
    
 | 
			
		||||
    return new Response(null, {
 | 
			
		||||
      status: 302,
 | 
			
		||||
      headers: {
 | 
			
		||||
        'Location': authUrl,
 | 
			
		||||
        'Set-Cookie': stateCookie
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    logAuthEvent('Login failed', { error: error.message });
 | 
			
		||||
    return new Response('Authentication error', { status: 500 });
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										38
									
								
								src/pages/api/auth/logout.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/pages/api/auth/logout.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,38 @@
 | 
			
		||||
import type { APIRoute } from 'astro';
 | 
			
		||||
import { getSessionFromRequest, verifySession } from '../../../utils/auth.js';
 | 
			
		||||
 | 
			
		||||
export const prerender = false;
 | 
			
		||||
 | 
			
		||||
export const GET: APIRoute = async ({ request }) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const sessionToken = getSessionFromRequest(request);
 | 
			
		||||
    
 | 
			
		||||
    if (!sessionToken) {
 | 
			
		||||
      return new Response(JSON.stringify({ 
 | 
			
		||||
        authenticated: false 
 | 
			
		||||
      }), {
 | 
			
		||||
        status: 200,
 | 
			
		||||
        headers: { 'Content-Type': 'application/json' }
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    const session = await verifySession(sessionToken);
 | 
			
		||||
    
 | 
			
		||||
    return new Response(JSON.stringify({ 
 | 
			
		||||
      authenticated: session !== null,
 | 
			
		||||
      expires: session?.exp ? new Date(session.exp * 1000).toISOString() : null
 | 
			
		||||
    }), {
 | 
			
		||||
      status: 200,
 | 
			
		||||
      headers: { 'Content-Type': 'application/json' }
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    return new Response(JSON.stringify({ 
 | 
			
		||||
      authenticated: false,
 | 
			
		||||
      error: 'Session verification failed'
 | 
			
		||||
    }), {
 | 
			
		||||
      status: 200,
 | 
			
		||||
      headers: { 'Content-Type': 'application/json' }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										104
									
								
								src/pages/api/auth/process.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								src/pages/api/auth/process.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,104 @@
 | 
			
		||||
import type { APIRoute } from 'astro';
 | 
			
		||||
import { parse } from 'cookie';
 | 
			
		||||
import { 
 | 
			
		||||
  exchangeCodeForTokens, 
 | 
			
		||||
  getUserInfo, 
 | 
			
		||||
  createSession, 
 | 
			
		||||
  createSessionCookie, 
 | 
			
		||||
  logAuthEvent 
 | 
			
		||||
} from '../../../utils/auth.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);
 | 
			
		||||
    
 | 
			
		||||
    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' }
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    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' }
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // 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' }
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Exchange code for tokens
 | 
			
		||||
    console.log('Exchanging code for tokens...');
 | 
			
		||||
    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);
 | 
			
		||||
    
 | 
			
		||||
    logAuthEvent('Authentication successful', { 
 | 
			
		||||
      userId: userInfo.sub || userInfo.preferred_username,
 | 
			
		||||
      email: userInfo.email 
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    // Clear auth state cookie
 | 
			
		||||
    const clearStateCookie = 'auth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0';
 | 
			
		||||
    const returnTo = storedStateData.returnTo || '/';
 | 
			
		||||
    
 | 
			
		||||
    const headers = new Headers();
 | 
			
		||||
    headers.append('Content-Type', 'application/json');
 | 
			
		||||
    headers.append('Set-Cookie', sessionCookie);
 | 
			
		||||
    headers.append('Set-Cookie', clearStateCookie);
 | 
			
		||||
    
 | 
			
		||||
    return new Response(JSON.stringify({ 
 | 
			
		||||
      success: true, 
 | 
			
		||||
      redirectTo: returnTo 
 | 
			
		||||
    }), {
 | 
			
		||||
      status: 200,
 | 
			
		||||
      headers: headers
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
  } 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' }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										56
									
								
								src/pages/api/auth/status.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								src/pages/api/auth/status.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,56 @@
 | 
			
		||||
// src/pages/api/auth/status.ts
 | 
			
		||||
import type { APIRoute } from 'astro';
 | 
			
		||||
import { getSessionFromRequest, verifySession } from '../../../utils/auth.js';
 | 
			
		||||
 | 
			
		||||
export const prerender = false;
 | 
			
		||||
 | 
			
		||||
export const GET: APIRoute = async ({ request }) => {
 | 
			
		||||
  try {
 | 
			
		||||
    // Check if authentication is required
 | 
			
		||||
    const authRequired = process.env.AUTHENTICATION_NECESSARY !== 'false';
 | 
			
		||||
    
 | 
			
		||||
    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' }
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
  } 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' }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										54
									
								
								src/pages/auth/callback.astro
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								src/pages/auth/callback.astro
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,54 @@
 | 
			
		||||
---
 | 
			
		||||
// Since server-side URL parameters aren't working, 
 | 
			
		||||
// we'll handle this client-side and POST to the API
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<html>
 | 
			
		||||
<head>
 | 
			
		||||
  <title>Processing Authentication...</title>
 | 
			
		||||
</head>
 | 
			
		||||
<body>
 | 
			
		||||
  <div style="text-align: center; padding: 4rem; font-family: sans-serif;">
 | 
			
		||||
    <h2>Processing authentication...</h2>
 | 
			
		||||
    <p>Please wait while we complete your login.</p>
 | 
			
		||||
  </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';
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
      .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';
 | 
			
		||||
    }
 | 
			
		||||
  </script>
 | 
			
		||||
</body>
 | 
			
		||||
</html>
 | 
			
		||||
@ -3,6 +3,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
 | 
			
		||||
import ToolCard from '../components/ToolCard.astro';
 | 
			
		||||
import ToolFilters from '../components/ToolFilters.astro';
 | 
			
		||||
import ToolMatrix from '../components/ToolMatrix.astro';
 | 
			
		||||
import AIQueryInterface from '../components/AIQueryInterface.astro';
 | 
			
		||||
import { promises as fs } from 'fs';
 | 
			
		||||
import { load } from 'js-yaml';
 | 
			
		||||
import path from 'path';
 | 
			
		||||
@ -45,6 +46,16 @@ const tools = data.tools;
 | 
			
		||||
        </svg>
 | 
			
		||||
        SSO & Zugang erfahren
 | 
			
		||||
      </a>
 | 
			
		||||
      
 | 
			
		||||
      <!-- New AI Query Button -->
 | 
			
		||||
      <button id="ai-query-btn" class="btn btn-accent" style="padding: 0.75rem 1.5rem; background-color: var(--color-accent); color: white;">
 | 
			
		||||
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
          <path d="M9 11H5a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7a2 2 0 0 0-2-2h-4"/>
 | 
			
		||||
          <path d="M9 11V7a3 3 0 0 1 6 0v4"/>
 | 
			
		||||
        </svg>
 | 
			
		||||
        KI befragen
 | 
			
		||||
      </button>
 | 
			
		||||
      
 | 
			
		||||
      <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>
 | 
			
		||||
@ -61,10 +72,13 @@ const tools = data.tools;
 | 
			
		||||
  <section id="filters-section" style="padding: 2rem 0;">
 | 
			
		||||
    <ToolFilters />
 | 
			
		||||
  </section>
 | 
			
		||||
 | 
			
		||||
  <!-- AI Query Interface -->
 | 
			
		||||
  <AIQueryInterface />
 | 
			
		||||
  
 | 
			
		||||
  <!-- Tools Grid -->
 | 
			
		||||
  <section id="tools-grid" style="padding-bottom: 2rem;">
 | 
			
		||||
    <div class="grid grid-cols-3 gap-4" id="tools-container">
 | 
			
		||||
    <div class="grid-auto-fit" id="tools-container">
 | 
			
		||||
      {tools.map((tool: any) => (
 | 
			
		||||
        <ToolCard tool={tool} />
 | 
			
		||||
      ))}
 | 
			
		||||
@ -86,10 +100,13 @@ const tools = data.tools;
 | 
			
		||||
    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) {
 | 
			
		||||
    if (!toolsContainer || !toolsGrid || !matrixContainer || !noResults || !aiInterface || !filtersSection) {
 | 
			
		||||
      console.error('Required DOM elements not found');
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
@ -97,14 +114,201 @@ const tools = data.tools;
 | 
			
		||||
    // Initial tools HTML
 | 
			
		||||
    const initialToolsHTML = toolsContainer.innerHTML;
 | 
			
		||||
    
 | 
			
		||||
    // 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) {
 | 
			
		||||
          // Redirect to login, then back to AI view
 | 
			
		||||
          const returnUrl = `${window.location.pathname}?view=ai`;
 | 
			
		||||
          window.location.href = `/api/auth/login?returnTo=${encodeURIComponent(returnUrl)}`;
 | 
			
		||||
        } else {
 | 
			
		||||
          // Switch to AI view directly
 | 
			
		||||
          switchToView('ai');
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Check URL parameters on page load for view switching
 | 
			
		||||
    const urlParams = new URLSearchParams(window.location.search);
 | 
			
		||||
    const viewParam = urlParams.get('view');
 | 
			
		||||
    if (viewParam === 'ai') {
 | 
			
		||||
      // User was redirected after authentication, switch to AI view
 | 
			
		||||
      switchToView('ai');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
// Function to switch between different views
 | 
			
		||||
    function switchToView(view) {
 | 
			
		||||
      // Hide all views first (using non-null assertions since we've already checked)
 | 
			
		||||
      toolsGrid!.style.display = 'none';
 | 
			
		||||
      matrixContainer!.style.display = 'none';
 | 
			
		||||
      aiInterface!.style.display = 'none';
 | 
			
		||||
      filtersSection!.style.display = 'none';
 | 
			
		||||
 | 
			
		||||
      // Update view toggle buttons
 | 
			
		||||
      const viewToggles = document.querySelectorAll('.view-toggle');
 | 
			
		||||
      viewToggles.forEach(btn => {
 | 
			
		||||
        btn.classList.toggle('active', btn.getAttribute('data-view') === view);
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      // Show appropriate view
 | 
			
		||||
      switch (view) {
 | 
			
		||||
        case 'ai':
 | 
			
		||||
          aiInterface!.style.display = 'block';
 | 
			
		||||
          // Keep filters visible in AI mode for view switching
 | 
			
		||||
          filtersSection!.style.display = 'block';
 | 
			
		||||
          
 | 
			
		||||
          // Hide filter controls in AI mode - AGGRESSIVE APPROACH
 | 
			
		||||
          const domainPhaseContainer = document.querySelector('.domain-phase-container') as HTMLElement;
 | 
			
		||||
          const searchInput = document.getElementById('search-tools') as HTMLElement;
 | 
			
		||||
          const tagCloud = document.querySelector('.tag-cloud') as HTMLElement;
 | 
			
		||||
          // Hide all checkbox wrappers
 | 
			
		||||
          const checkboxWrappers = document.querySelectorAll('.checkbox-wrapper');
 | 
			
		||||
          // Hide the "Nach Tags filtern" header and button
 | 
			
		||||
          const tagHeader = document.querySelector('.tag-header') as HTMLElement;
 | 
			
		||||
          // Hide any elements containing "Proprietäre Software" or "Nach Tags filtern"
 | 
			
		||||
          const filterLabels = document.querySelectorAll('label, .tag-header, h4, h3');
 | 
			
		||||
          // Hide ALL input elements in the filters section (more aggressive)
 | 
			
		||||
          const allInputs = filtersSection!.querySelectorAll('input, select, textarea');
 | 
			
		||||
 | 
			
		||||
          
 | 
			
		||||
          if (domainPhaseContainer) domainPhaseContainer.style.display = 'none';
 | 
			
		||||
          if (searchInput) searchInput.style.display = 'none';
 | 
			
		||||
          if (tagCloud) tagCloud.style.display = 'none';
 | 
			
		||||
          if (tagHeader) tagHeader.style.display = 'none';
 | 
			
		||||
          
 | 
			
		||||
          // Hide ALL inputs in the filters section
 | 
			
		||||
          allInputs.forEach(input => {
 | 
			
		||||
            (input as HTMLElement).style.display = 'none';
 | 
			
		||||
          });
 | 
			
		||||
          
 | 
			
		||||
          checkboxWrappers.forEach(wrapper => {
 | 
			
		||||
            (wrapper as HTMLElement).style.display = 'none';
 | 
			
		||||
          });
 | 
			
		||||
          
 | 
			
		||||
          // Hide specific filter section elements by text content
 | 
			
		||||
          filterLabels.forEach(element => {
 | 
			
		||||
            const text = element.textContent?.toLowerCase() || '';
 | 
			
		||||
            if (text.includes('proprietäre') || text.includes('tags filtern') || text.includes('nach tags') || text.includes('suchen') || text.includes('search')) {
 | 
			
		||||
              (element as HTMLElement).style.display = 'none';
 | 
			
		||||
            }
 | 
			
		||||
          });
 | 
			
		||||
          
 | 
			
		||||
          // Restore previous AI results if they exist
 | 
			
		||||
          if ((window as any).restoreAIResults) {
 | 
			
		||||
            (window as any).restoreAIResults();
 | 
			
		||||
          }
 | 
			
		||||
          // Focus on the input
 | 
			
		||||
          const aiInput = document.getElementById('ai-query-input');
 | 
			
		||||
          if (aiInput) {
 | 
			
		||||
            setTimeout(() => aiInput.focus(), 100);
 | 
			
		||||
          }
 | 
			
		||||
          break;
 | 
			
		||||
        case 'matrix':
 | 
			
		||||
          matrixContainer!.style.display = 'block';
 | 
			
		||||
          filtersSection!.style.display = 'block';
 | 
			
		||||
          
 | 
			
		||||
          // Show filter controls in matrix mode
 | 
			
		||||
          const domainPhaseContainerMatrix = document.querySelector('.domain-phase-container') as HTMLElement;
 | 
			
		||||
          const searchInputMatrix = document.getElementById('search-tools') as HTMLElement;
 | 
			
		||||
          const tagCloudMatrix = document.querySelector('.tag-cloud') as HTMLElement;
 | 
			
		||||
          const checkboxWrappersMatrix = document.querySelectorAll('.checkbox-wrapper');
 | 
			
		||||
          const tagHeaderMatrix = document.querySelector('.tag-header') as HTMLElement;
 | 
			
		||||
          const filterLabelsMatrix = document.querySelectorAll('label, .tag-header, h4, h3');
 | 
			
		||||
          const allInputsMatrix = filtersSection!.querySelectorAll('input, select, textarea');
 | 
			
		||||
          
 | 
			
		||||
          if (domainPhaseContainerMatrix) domainPhaseContainerMatrix.style.display = 'grid';
 | 
			
		||||
          if (searchInputMatrix) searchInputMatrix.style.display = 'block';
 | 
			
		||||
          if (tagCloudMatrix) tagCloudMatrix.style.display = 'flex';
 | 
			
		||||
          if (tagHeaderMatrix) tagHeaderMatrix.style.display = 'flex';
 | 
			
		||||
          
 | 
			
		||||
          // Restore ALL inputs in the filters section
 | 
			
		||||
          allInputsMatrix.forEach(input => {
 | 
			
		||||
            (input as HTMLElement).style.display = 'block';
 | 
			
		||||
          });
 | 
			
		||||
          
 | 
			
		||||
          checkboxWrappersMatrix.forEach(wrapper => {
 | 
			
		||||
            (wrapper as HTMLElement).style.display = 'flex';
 | 
			
		||||
          });
 | 
			
		||||
          
 | 
			
		||||
          // Restore filter section elements
 | 
			
		||||
          filterLabelsMatrix.forEach(element => {
 | 
			
		||||
            const text = element.textContent?.toLowerCase() || '';
 | 
			
		||||
            if (text.includes('proprietäre') || text.includes('tags filtern') || text.includes('nach tags') || text.includes('suchen') || text.includes('search')) {
 | 
			
		||||
              (element as HTMLElement).style.display = 'block';
 | 
			
		||||
            }
 | 
			
		||||
          });
 | 
			
		||||
          break;
 | 
			
		||||
        default: // grid
 | 
			
		||||
          toolsGrid!.style.display = 'block';
 | 
			
		||||
          filtersSection!.style.display = 'block';
 | 
			
		||||
          
 | 
			
		||||
          // Show filter controls in grid mode
 | 
			
		||||
          const domainPhaseContainerGrid = document.querySelector('.domain-phase-container') as HTMLElement;
 | 
			
		||||
          const searchInputGrid = document.getElementById('search-tools') as HTMLElement;
 | 
			
		||||
          const tagCloudGrid = document.querySelector('.tag-cloud') as HTMLElement;
 | 
			
		||||
          const checkboxWrappersGrid = document.querySelectorAll('.checkbox-wrapper');
 | 
			
		||||
          const tagHeaderGrid = document.querySelector('.tag-header') as HTMLElement;
 | 
			
		||||
          const filterLabelsGrid = document.querySelectorAll('label, .tag-header, h4, h3');
 | 
			
		||||
          const allInputsGrid = filtersSection!.querySelectorAll('input, select, textarea');
 | 
			
		||||
          
 | 
			
		||||
          if (domainPhaseContainerGrid) domainPhaseContainerGrid.style.display = 'grid';
 | 
			
		||||
          if (searchInputGrid) searchInputGrid.style.display = 'block';
 | 
			
		||||
          if (tagCloudGrid) tagCloudGrid.style.display = 'flex';
 | 
			
		||||
          if (tagHeaderGrid) tagHeaderGrid.style.display = 'flex';
 | 
			
		||||
          
 | 
			
		||||
          // Restore ALL inputs in the filters section
 | 
			
		||||
          allInputsGrid.forEach(input => {
 | 
			
		||||
            (input as HTMLElement).style.display = 'block';
 | 
			
		||||
          });
 | 
			
		||||
          
 | 
			
		||||
          checkboxWrappersGrid.forEach(wrapper => {
 | 
			
		||||
            (wrapper as HTMLElement).style.display = 'flex';
 | 
			
		||||
          });
 | 
			
		||||
          
 | 
			
		||||
          // Restore filter section elements
 | 
			
		||||
          filterLabelsGrid.forEach(element => {
 | 
			
		||||
            const text = element.textContent?.toLowerCase() || '';
 | 
			
		||||
            if (text.includes('proprietäre') || text.includes('tags filtern') || text.includes('nach tags') || text.includes('suchen') || text.includes('search')) {
 | 
			
		||||
              (element as HTMLElement).style.display = 'block';
 | 
			
		||||
            }
 | 
			
		||||
          });
 | 
			
		||||
          break;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Clear URL parameters after switching
 | 
			
		||||
      if (window.location.search) {
 | 
			
		||||
        window.history.replaceState({}, '', window.location.pathname);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Handle filtered results
 | 
			
		||||
    window.addEventListener('toolsFiltered', (event: Event) => {
 | 
			
		||||
      const customEvent = event as CustomEvent;
 | 
			
		||||
      const filtered = customEvent.detail;
 | 
			
		||||
      const currentView = document.querySelector('.view-toggle.active')?.getAttribute('data-view');
 | 
			
		||||
      
 | 
			
		||||
      if (currentView === 'matrix') {
 | 
			
		||||
        // Matrix view handles its own rendering
 | 
			
		||||
      if (currentView === 'matrix' || currentView === 'ai') {
 | 
			
		||||
        // Matrix and AI views handle their own rendering
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
@ -128,16 +332,12 @@ const tools = data.tools;
 | 
			
		||||
    window.addEventListener('viewChanged', (event: Event) => {
 | 
			
		||||
      const customEvent = event as CustomEvent;
 | 
			
		||||
      const view = customEvent.detail;
 | 
			
		||||
      
 | 
			
		||||
      if (view === 'matrix') {
 | 
			
		||||
        toolsGrid.style.display = 'none';
 | 
			
		||||
        matrixContainer.style.display = 'block';
 | 
			
		||||
      } else {
 | 
			
		||||
        toolsGrid.style.display = 'block';
 | 
			
		||||
        matrixContainer.style.display = 'none';
 | 
			
		||||
      }
 | 
			
		||||
      switchToView(view);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Make switchToView available globally for the AI button
 | 
			
		||||
    (window as any).switchToAIView = () => switchToView('ai');
 | 
			
		||||
 | 
			
		||||
function createToolCard(tool) {
 | 
			
		||||
  const hasValidProjectUrl = tool.projectUrl !== undefined && 
 | 
			
		||||
                            tool.projectUrl !== null && 
 | 
			
		||||
@ -152,16 +352,16 @@ function createToolCard(tool) {
 | 
			
		||||
  cardDiv.style.cursor = 'pointer';
 | 
			
		||||
  cardDiv.onclick = () => (window as any).showToolDetails(tool.name);
 | 
			
		||||
  
 | 
			
		||||
  // Create button HTML based on hosting status
 | 
			
		||||
  // Create responsive button HTML
 | 
			
		||||
  let buttonHTML;
 | 
			
		||||
  if (hasValidProjectUrl) {
 | 
			
		||||
    // Two buttons for tools we're hosting
 | 
			
		||||
    // Two buttons for tools we're hosting - responsive layout
 | 
			
		||||
    buttonHTML = `
 | 
			
		||||
      <div style="display: flex; gap: 0.5rem;" onclick="event.stopPropagation();">
 | 
			
		||||
        <a href="${tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-secondary" style="flex: 1;">
 | 
			
		||||
      <div class="button-container" style="display: flex; gap: 0.5rem;" onclick="event.stopPropagation();">
 | 
			
		||||
        <a href="${tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-secondary" style="flex: 1; text-align: center;">
 | 
			
		||||
          Software-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" style="flex: 1; text-align: center;">
 | 
			
		||||
          Zugreifen
 | 
			
		||||
        </a>
 | 
			
		||||
      </div>
 | 
			
		||||
@ -169,28 +369,28 @@ function createToolCard(tool) {
 | 
			
		||||
  } else {
 | 
			
		||||
    // Single button for tools we're not hosting
 | 
			
		||||
    buttonHTML = `
 | 
			
		||||
      <a href="${tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="width: 100%;" onclick="event.stopPropagation();">
 | 
			
		||||
      <a href="${tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="width: 100%; text-align: center;" onclick="event.stopPropagation();">
 | 
			
		||||
        Software-Homepage
 | 
			
		||||
      </a>
 | 
			
		||||
    `;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  cardDiv.innerHTML = `
 | 
			
		||||
    <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.75rem;">
 | 
			
		||||
      <h3 style="margin: 0;">${tool.name}</h3>
 | 
			
		||||
      <div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
 | 
			
		||||
    <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.75rem; flex-wrap: wrap; gap: 0.5rem;">
 | 
			
		||||
      <h3 style="margin: 0; flex: 1; min-width: 0;">${tool.name}</h3>
 | 
			
		||||
      <div style="display: flex; gap: 0.5rem; flex-wrap: wrap; flex-shrink: 0;">
 | 
			
		||||
        ${hasValidProjectUrl ? '<span class="badge badge-primary">Self-Hosted</span>' : ''}
 | 
			
		||||
        ${tool.license !== 'Proprietary' ? '<span class="badge badge-success">Open Source</span>' : ''}
 | 
			
		||||
        ${hasKnowledgebase ? '<span class="badge badge-error">Infos 📖</span>' : ''}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    
 | 
			
		||||
    <p class="text-muted" style="font-size: 0.875rem; margin-bottom: 1rem;">
 | 
			
		||||
    <p class="text-muted" style="font-size: 0.875rem; margin-bottom: 1rem; line-height: 1.5;">
 | 
			
		||||
      ${tool.description}
 | 
			
		||||
    </p>
 | 
			
		||||
    
 | 
			
		||||
    <div style="display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem;">
 | 
			
		||||
      <div style="display: flex; align-items: center; gap: 0.25rem;">
 | 
			
		||||
      <div style="display: flex; align-items: center; gap: 0.25rem; flex-wrap: wrap;">
 | 
			
		||||
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
 | 
			
		||||
          <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
 | 
			
		||||
          <line x1="9" y1="9" x2="15" y2="9"></line>
 | 
			
		||||
@ -201,7 +401,7 @@ function createToolCard(tool) {
 | 
			
		||||
        </span>
 | 
			
		||||
      </div>
 | 
			
		||||
      
 | 
			
		||||
      <div style="display: flex; align-items: center; gap: 0.25rem;">
 | 
			
		||||
      <div style="display: flex; align-items: center; gap: 0.25rem; flex-wrap: wrap;">
 | 
			
		||||
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
 | 
			
		||||
          <circle cx="12" cy="12" r="10"></circle>
 | 
			
		||||
          <path d="M12 6v6l4 2"></path>
 | 
			
		||||
@ -211,7 +411,7 @@ function createToolCard(tool) {
 | 
			
		||||
        </span>
 | 
			
		||||
      </div>
 | 
			
		||||
      
 | 
			
		||||
      <div style="display: flex; align-items: center; gap: 0.25rem;">
 | 
			
		||||
      <div style="display: flex; align-items: center; gap: 0.25rem; flex-wrap: wrap;">
 | 
			
		||||
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
 | 
			
		||||
          <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
 | 
			
		||||
          <polyline points="14 2 14 8 20 8"></polyline>
 | 
			
		||||
 | 
			
		||||
@ -46,14 +46,14 @@ const hostedServices = data.tools.filter((tool: any) => {
 | 
			
		||||
      ))}
 | 
			
		||||
    </div>
 | 
			
		||||
    
 | 
			
		||||
    <!-- Uptime Kuma Embed -->
 | 
			
		||||
    <!-- Uptime Kuma Embed 
 | 
			
		||||
    <div class="card" style="padding: 0; overflow: hidden;">
 | 
			
		||||
      <iframe 
 | 
			
		||||
        src="https://status.mikoshi.de/status/cc24-hub?embed=true"
 | 
			
		||||
        style="width: 100%; height: 600px; border: none;"
 | 
			
		||||
        title="Uptime Kuma Status Page"
 | 
			
		||||
      ></iframe>
 | 
			
		||||
    </div>
 | 
			
		||||
    </div>-->
 | 
			
		||||
  </section>
 | 
			
		||||
</BaseLayout>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										176
									
								
								src/utils/auth.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										176
									
								
								src/utils/auth.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,176 @@
 | 
			
		||||
import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
 | 
			
		||||
import { serialize, parse } from 'cookie';
 | 
			
		||||
import { config } from 'dotenv';
 | 
			
		||||
 | 
			
		||||
// Load environment variables
 | 
			
		||||
config();
 | 
			
		||||
 | 
			
		||||
// Environment variables - use runtime access for server-side
 | 
			
		||||
function getEnv(key: string): string {
 | 
			
		||||
  const value = process.env[key];
 | 
			
		||||
  if (!value) {
 | 
			
		||||
    throw new Error(`Missing environment variable: ${key}`);
 | 
			
		||||
  }
 | 
			
		||||
  return value;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const SECRET_KEY = new TextEncoder().encode(getEnv('OIDC_CLIENT_SECRET'));
 | 
			
		||||
const SESSION_DURATION = 6 * 60 * 60; // 6 hours in seconds
 | 
			
		||||
 | 
			
		||||
export interface SessionData {
 | 
			
		||||
  userId: string;
 | 
			
		||||
  authenticated: boolean;
 | 
			
		||||
  exp: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Create a signed JWT session token
 | 
			
		||||
export async function createSession(userId: string): Promise<string> {
 | 
			
		||||
  const exp = Math.floor(Date.now() / 1000) + SESSION_DURATION;
 | 
			
		||||
  
 | 
			
		||||
  return await new SignJWT({ 
 | 
			
		||||
    userId, 
 | 
			
		||||
    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;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Get session from request cookies
 | 
			
		||||
export function getSessionFromRequest(request: Request): string | null {
 | 
			
		||||
  const cookieHeader = request.headers.get('cookie');
 | 
			
		||||
  if (!cookieHeader) return null;
 | 
			
		||||
  
 | 
			
		||||
  const cookies = parse(cookieHeader);
 | 
			
		||||
  return cookies.session || null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Create session cookie
 | 
			
		||||
export function createSessionCookie(token: string): string {
 | 
			
		||||
  const publicBaseUrl = getEnv('PUBLIC_BASE_URL');
 | 
			
		||||
  const isSecure = publicBaseUrl.startsWith('https://');
 | 
			
		||||
  
 | 
			
		||||
  return serialize('session', token, {
 | 
			
		||||
    httpOnly: true,
 | 
			
		||||
    secure: isSecure,
 | 
			
		||||
    sameSite: 'lax',
 | 
			
		||||
    maxAge: SESSION_DURATION,
 | 
			
		||||
    path: '/'
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Clear session cookie
 | 
			
		||||
export function clearSessionCookie(): string {
 | 
			
		||||
  const publicBaseUrl = getEnv('PUBLIC_BASE_URL');
 | 
			
		||||
  const isSecure = publicBaseUrl.startsWith('https://');
 | 
			
		||||
  
 | 
			
		||||
  return serialize('session', '', {
 | 
			
		||||
    httpOnly: true,
 | 
			
		||||
    secure: isSecure,
 | 
			
		||||
    sameSite: 'lax',
 | 
			
		||||
    maxAge: 0,
 | 
			
		||||
    path: '/'
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Generate OIDC authorization URL
 | 
			
		||||
export function generateAuthUrl(state: string): string {
 | 
			
		||||
  const oidcEndpoint = getEnv('OIDC_ENDPOINT');
 | 
			
		||||
  const clientId = getEnv('OIDC_CLIENT_ID');
 | 
			
		||||
  const publicBaseUrl = getEnv('PUBLIC_BASE_URL');
 | 
			
		||||
  
 | 
			
		||||
  const params = new URLSearchParams({
 | 
			
		||||
    response_type: 'code',
 | 
			
		||||
    client_id: clientId,
 | 
			
		||||
    redirect_uri: `${publicBaseUrl}/auth/callback`,
 | 
			
		||||
    scope: 'openid profile email',
 | 
			
		||||
    state: state
 | 
			
		||||
  });
 | 
			
		||||
  
 | 
			
		||||
  return `${oidcEndpoint}/apps/oidc/authorize?${params.toString()}`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Exchange authorization code for tokens
 | 
			
		||||
export async function exchangeCodeForTokens(code: string): Promise<any> {
 | 
			
		||||
  const oidcEndpoint = getEnv('OIDC_ENDPOINT');
 | 
			
		||||
  const clientId = getEnv('OIDC_CLIENT_ID');
 | 
			
		||||
  const clientSecret = getEnv('OIDC_CLIENT_SECRET');
 | 
			
		||||
  const publicBaseUrl = getEnv('PUBLIC_BASE_URL');
 | 
			
		||||
  
 | 
			
		||||
  const response = await fetch(`${oidcEndpoint}/apps/oidc/token`, {
 | 
			
		||||
    method: 'POST',
 | 
			
		||||
    headers: {
 | 
			
		||||
      'Content-Type': 'application/x-www-form-urlencoded',
 | 
			
		||||
      'Authorization': `Basic ${btoa(`${clientId}:${clientSecret}`)}`
 | 
			
		||||
    },
 | 
			
		||||
    body: new URLSearchParams({
 | 
			
		||||
      grant_type: 'authorization_code',
 | 
			
		||||
      code: code,
 | 
			
		||||
      redirect_uri: `${publicBaseUrl}/auth/callback`
 | 
			
		||||
    })
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (!response.ok) {
 | 
			
		||||
    const error = await response.text();
 | 
			
		||||
    console.error('Token exchange failed:', error);
 | 
			
		||||
    throw new Error('Failed to exchange authorization code');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return await response.json();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Get user info from OIDC provider
 | 
			
		||||
export async function getUserInfo(accessToken: string): Promise<any> {
 | 
			
		||||
  const oidcEndpoint = getEnv('OIDC_ENDPOINT');
 | 
			
		||||
  
 | 
			
		||||
  const response = await fetch(`${oidcEndpoint}/apps/oidc/userinfo`, {
 | 
			
		||||
    headers: {
 | 
			
		||||
      'Authorization': `Bearer ${accessToken}`
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (!response.ok) {
 | 
			
		||||
    const error = await response.text();
 | 
			
		||||
    console.error('Userinfo request failed:', error);
 | 
			
		||||
    throw new Error('Failed to get user info');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return await response.json();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Generate random state for CSRF protection
 | 
			
		||||
export function generateState(): string {
 | 
			
		||||
  return crypto.randomUUID();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 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) : '');
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										40
									
								
								src/utils/serverAuth.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/utils/serverAuth.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,40 @@
 | 
			
		||||
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;
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user