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-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
_site/
|
_site/
|
||||||
dist/
|
dist/
|
||||||
@ -75,4 +77,5 @@ src/_data/config.local.yaml
|
|||||||
|
|
||||||
# Temporary files
|
# Temporary files
|
||||||
tmp/
|
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.
|
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
|
## 🎯 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.
|
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
|
### Produktions-Deployment
|
||||||
|
|
||||||
|
#### Voraussetzungen
|
||||||
|
|
||||||
|
- Ubuntu/Debian server
|
||||||
|
- Node.js 18+
|
||||||
|
- Nginx
|
||||||
|
- Domain
|
||||||
|
- SSL Zertifikat
|
||||||
|
|
||||||
|
#### Installationsschritte
|
||||||
|
|
||||||
|
##### 1. Vorbereitung
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build erstellen
|
# Klonen des Repositorys
|
||||||
npm install && npm run build
|
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
|
## 📁 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": {
|
"dependencies": {
|
||||||
"astro": "^5.3.0",
|
"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": {
|
"devDependencies": {
|
||||||
"@types/js-yaml": "^4.0.9"
|
"@types/js-yaml": "^4.0.9",
|
||||||
},
|
"@types/cookie": "^0.6.0"
|
||||||
"optionalDependencies": {
|
|
||||||
"@astrojs/node": "^9.3.0"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"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;
|
const hasKnowledgebase = tool.knowledgebase === true;
|
||||||
|
|
||||||
// Determine card styling based on hosting status (derived from projectUrl)
|
// 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;">
|
<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>
|
</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 => (
|
{tool.tags.map(tag => (
|
||||||
<span class="tag">{tag}</span>
|
<span class="tag">{tag}</span>
|
||||||
))}
|
))}
|
||||||
|
@ -108,9 +108,24 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- View Toggle -->
|
<!-- View Toggle -->
|
||||||
<div style="display: flex; gap: 1rem; margin-bottom: 1.5rem;">
|
<!--<div style="display: flex; gap: 1rem; margin-bottom: 1.5rem; align-items: center; flex-wrap: wrap;">-->
|
||||||
<button class="btn btn-secondary view-toggle active" data-view="grid">Kachelansicht</button>
|
<div style="display: flex; gap: 1rem; margin-bottom: 1.5rem; align-items: center;">
|
||||||
<button class="btn btn-secondary view-toggle" data-view="matrix">Matrix-Ansicht</button>
|
<button class="btn btn-secondary view-toggle active" style="height:50px" data-view="grid">Kachelansicht</button>
|
||||||
|
<button class="btn btn-secondary view-toggle" style="height:50px" data-view="matrix">Matrix-Ansicht</button>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -129,12 +144,33 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
const tagCloud = document.getElementById('tag-cloud');
|
const tagCloud = document.getElementById('tag-cloud');
|
||||||
const tagCloudToggle = document.getElementById('tag-cloud-toggle');
|
const tagCloudToggle = document.getElementById('tag-cloud-toggle');
|
||||||
const viewToggles = document.querySelectorAll('.view-toggle');
|
const viewToggles = document.querySelectorAll('.view-toggle');
|
||||||
|
const aiViewToggle = document.getElementById('ai-view-toggle');
|
||||||
|
|
||||||
// Track selected tags and phase
|
// Track selected tags and phase
|
||||||
let selectedTags = new Set();
|
let selectedTags = new Set();
|
||||||
let selectedPhase = '';
|
let selectedPhase = '';
|
||||||
let isTagCloudExpanded = false;
|
let isTagCloudExpanded = false;
|
||||||
|
|
||||||
|
// Check authentication status and show/hide AI button
|
||||||
|
async function checkAuthAndShowAIButton() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/status');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Show AI button if authentication is not required OR if user is authenticated
|
||||||
|
if (!data.authRequired || data.authenticated) {
|
||||||
|
if (aiViewToggle) {
|
||||||
|
aiViewToggle.style.display = 'inline-flex';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Auth check failed, AI button remains hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call auth check on page load
|
||||||
|
checkAuthAndShowAIButton();
|
||||||
|
|
||||||
// Initialize tag cloud state
|
// Initialize tag cloud state
|
||||||
function initTagCloud() {
|
function initTagCloud() {
|
||||||
const visibleCount = 22;
|
const visibleCount = 22;
|
||||||
|
@ -14,18 +14,18 @@ const tools = data.tools;
|
|||||||
|
|
||||||
// Separate collaboration tools from domain-specific tools
|
// Separate collaboration tools from domain-specific tools
|
||||||
const collaborationTools = tools.filter((tool: any) =>
|
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) =>
|
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
|
// Create matrix structure for domain-specific tools only
|
||||||
const matrix: Record<string, Record<string, any[]>> = {};
|
const matrix: Record<string, Record<string, any[]>> = {};
|
||||||
domains.forEach((domain: any) => {
|
domains.forEach((domain: any) => {
|
||||||
matrix[domain.id] = {};
|
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) =>
|
matrix[domain.id][phase.id] = domainTools.filter((tool: any) =>
|
||||||
tool.domains && tool.domains.includes(domain.id) && tool.phases && tool.phases.includes(phase.id)
|
tool.domains && tool.domains.includes(domain.id) && tool.phases && tool.phases.includes(phase.id)
|
||||||
);
|
);
|
||||||
@ -75,7 +75,7 @@ domains.forEach((domain: any) => {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 200px;">Domain / Phase</th>
|
<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>
|
<th data-phase={phase.id}>{phase.name}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@ -84,7 +84,7 @@ domains.forEach((domain: any) => {
|
|||||||
{domains.map((domain: any) => (
|
{domains.map((domain: any) => (
|
||||||
<tr data-domain={domain.id}>
|
<tr data-domain={domain.id}>
|
||||||
<th>{domain.name}</th>
|
<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}>
|
<td class="matrix-cell" data-domain={domain.id} data-phase={phase.id}>
|
||||||
{matrix[domain.id][phase.id].map((tool: any) => {
|
{matrix[domain.id][phase.id].map((tool: any) => {
|
||||||
const hasValidProjectUrl = tool.projectUrl !== undefined &&
|
const hasValidProjectUrl = tool.projectUrl !== undefined &&
|
||||||
@ -364,13 +364,13 @@ domains.forEach((domain: any) => {
|
|||||||
const dfirMatrixSection = document.getElementById('dfir-matrix-section');
|
const dfirMatrixSection = document.getElementById('dfir-matrix-section');
|
||||||
const collaborationContainer = document.getElementById('collaboration-tools-container');
|
const collaborationContainer = document.getElementById('collaboration-tools-container');
|
||||||
|
|
||||||
if (selectedPhase === 'collaboration') {
|
if (selectedPhase === 'collaboration-general') {
|
||||||
// Show only collaboration tools, hide matrix
|
// Show only collaboration tools, hide matrix
|
||||||
collaborationSection.style.display = 'block';
|
collaborationSection.style.display = 'block';
|
||||||
dfirMatrixSection.style.display = 'none';
|
dfirMatrixSection.style.display = 'none';
|
||||||
|
|
||||||
// Filter collaboration tools
|
// Filter collaboration tools
|
||||||
const filteredCollaboration = filtered.filter(tool => (tool.phases || []).includes('collaboration'));
|
const filteredCollaboration = filtered.filter(tool => (tool.phases || []).includes('collaboration-general'));
|
||||||
collaborationContainer.innerHTML = '';
|
collaborationContainer.innerHTML = '';
|
||||||
|
|
||||||
filteredCollaboration.forEach(tool => {
|
filteredCollaboration.forEach(tool => {
|
||||||
@ -386,7 +386,7 @@ domains.forEach((domain: any) => {
|
|||||||
collaborationSection.style.display = 'block';
|
collaborationSection.style.display = 'block';
|
||||||
|
|
||||||
// Show all collaboration tools that pass general filters
|
// 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 = '';
|
collaborationContainer.innerHTML = '';
|
||||||
|
|
||||||
filteredCollaboration.forEach(tool => {
|
filteredCollaboration.forEach(tool => {
|
||||||
@ -404,7 +404,7 @@ domains.forEach((domain: any) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Re-populate with filtered DFIR tools - with safe array handling
|
// 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 => {
|
filteredDfirTools.forEach(tool => {
|
||||||
const hasValidProjectUrl = tool.projectUrl !== undefined &&
|
const hasValidProjectUrl = tool.projectUrl !== undefined &&
|
||||||
tool.projectUrl !== null &&
|
tool.projectUrl !== null &&
|
||||||
@ -416,7 +416,7 @@ domains.forEach((domain: any) => {
|
|||||||
|
|
||||||
domains.forEach(domain => {
|
domains.forEach(domain => {
|
||||||
phases.forEach(phase => {
|
phases.forEach(phase => {
|
||||||
if (phase !== 'collaboration') {
|
if (phase !== 'collaboration-general') {
|
||||||
const cell = document.querySelector(`[data-domain="${domain}"][data-phase="${phase}"]`);
|
const cell = document.querySelector(`[data-domain="${domain}"][data-phase="${phase}"]`);
|
||||||
if (cell) {
|
if (cell) {
|
||||||
const chip = document.createElement('span');
|
const chip = document.createElement('span');
|
||||||
|
@ -697,8 +697,17 @@ tools:
|
|||||||
kollaborative Phasen und sichere Speicherung von Beweismitteln
|
kollaborative Phasen und sichere Speicherung von Beweismitteln
|
||||||
mit Versionierung.
|
mit Versionierung.
|
||||||
domains:
|
domains:
|
||||||
|
- incident-response
|
||||||
|
- law-enforcement
|
||||||
|
- malware-analysis
|
||||||
|
- fraud-investigation
|
||||||
|
- network-forensics
|
||||||
|
- mobile-forensics
|
||||||
|
- cloud-forensics
|
||||||
|
- ics-forensics
|
||||||
phases:
|
phases:
|
||||||
- collaboration-general
|
- collaboration-general
|
||||||
|
- reporting
|
||||||
platforms:
|
platforms:
|
||||||
- Web
|
- Web
|
||||||
skillLevel: novice
|
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 ToolCard from '../components/ToolCard.astro';
|
||||||
import ToolFilters from '../components/ToolFilters.astro';
|
import ToolFilters from '../components/ToolFilters.astro';
|
||||||
import ToolMatrix from '../components/ToolMatrix.astro';
|
import ToolMatrix from '../components/ToolMatrix.astro';
|
||||||
|
import AIQueryInterface from '../components/AIQueryInterface.astro';
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import { load } from 'js-yaml';
|
import { load } from 'js-yaml';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@ -45,6 +46,16 @@ const tools = data.tools;
|
|||||||
</svg>
|
</svg>
|
||||||
SSO & Zugang erfahren
|
SSO & Zugang erfahren
|
||||||
</a>
|
</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;">
|
<a href="#filters-section" class="btn btn-secondary" style="padding: 0.75rem 1.5rem;">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
|
||||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
|
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
|
||||||
@ -61,10 +72,13 @@ const tools = data.tools;
|
|||||||
<section id="filters-section" style="padding: 2rem 0;">
|
<section id="filters-section" style="padding: 2rem 0;">
|
||||||
<ToolFilters />
|
<ToolFilters />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- AI Query Interface -->
|
||||||
|
<AIQueryInterface />
|
||||||
|
|
||||||
<!-- Tools Grid -->
|
<!-- Tools Grid -->
|
||||||
<section id="tools-grid" style="padding-bottom: 2rem;">
|
<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) => (
|
{tools.map((tool: any) => (
|
||||||
<ToolCard tool={tool} />
|
<ToolCard tool={tool} />
|
||||||
))}
|
))}
|
||||||
@ -86,10 +100,13 @@ const tools = data.tools;
|
|||||||
const toolsContainer = document.getElementById('tools-container');
|
const toolsContainer = document.getElementById('tools-container');
|
||||||
const toolsGrid = document.getElementById('tools-grid');
|
const toolsGrid = document.getElementById('tools-grid');
|
||||||
const matrixContainer = document.getElementById('matrix-container');
|
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 noResults = document.getElementById('no-results');
|
||||||
|
const aiQueryBtn = document.getElementById('ai-query-btn');
|
||||||
|
|
||||||
// Guard against null elements
|
// Guard against null elements
|
||||||
if (!toolsContainer || !toolsGrid || !matrixContainer || !noResults) {
|
if (!toolsContainer || !toolsGrid || !matrixContainer || !noResults || !aiInterface || !filtersSection) {
|
||||||
console.error('Required DOM elements not found');
|
console.error('Required DOM elements not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -97,14 +114,201 @@ const tools = data.tools;
|
|||||||
// Initial tools HTML
|
// Initial tools HTML
|
||||||
const initialToolsHTML = toolsContainer.innerHTML;
|
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
|
// Handle filtered results
|
||||||
window.addEventListener('toolsFiltered', (event: Event) => {
|
window.addEventListener('toolsFiltered', (event: Event) => {
|
||||||
const customEvent = event as CustomEvent;
|
const customEvent = event as CustomEvent;
|
||||||
const filtered = customEvent.detail;
|
const filtered = customEvent.detail;
|
||||||
const currentView = document.querySelector('.view-toggle.active')?.getAttribute('data-view');
|
const currentView = document.querySelector('.view-toggle.active')?.getAttribute('data-view');
|
||||||
|
|
||||||
if (currentView === 'matrix') {
|
if (currentView === 'matrix' || currentView === 'ai') {
|
||||||
// Matrix view handles its own rendering
|
// Matrix and AI views handle their own rendering
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -128,16 +332,12 @@ const tools = data.tools;
|
|||||||
window.addEventListener('viewChanged', (event: Event) => {
|
window.addEventListener('viewChanged', (event: Event) => {
|
||||||
const customEvent = event as CustomEvent;
|
const customEvent = event as CustomEvent;
|
||||||
const view = customEvent.detail;
|
const view = customEvent.detail;
|
||||||
|
switchToView(view);
|
||||||
if (view === 'matrix') {
|
|
||||||
toolsGrid.style.display = 'none';
|
|
||||||
matrixContainer.style.display = 'block';
|
|
||||||
} else {
|
|
||||||
toolsGrid.style.display = 'block';
|
|
||||||
matrixContainer.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Make switchToView available globally for the AI button
|
||||||
|
(window as any).switchToAIView = () => switchToView('ai');
|
||||||
|
|
||||||
function createToolCard(tool) {
|
function createToolCard(tool) {
|
||||||
const hasValidProjectUrl = tool.projectUrl !== undefined &&
|
const hasValidProjectUrl = tool.projectUrl !== undefined &&
|
||||||
tool.projectUrl !== null &&
|
tool.projectUrl !== null &&
|
||||||
@ -152,16 +352,16 @@ function createToolCard(tool) {
|
|||||||
cardDiv.style.cursor = 'pointer';
|
cardDiv.style.cursor = 'pointer';
|
||||||
cardDiv.onclick = () => (window as any).showToolDetails(tool.name);
|
cardDiv.onclick = () => (window as any).showToolDetails(tool.name);
|
||||||
|
|
||||||
// Create button HTML based on hosting status
|
// Create responsive button HTML
|
||||||
let buttonHTML;
|
let buttonHTML;
|
||||||
if (hasValidProjectUrl) {
|
if (hasValidProjectUrl) {
|
||||||
// Two buttons for tools we're hosting
|
// Two buttons for tools we're hosting - responsive layout
|
||||||
buttonHTML = `
|
buttonHTML = `
|
||||||
<div style="display: flex; gap: 0.5rem;" onclick="event.stopPropagation();">
|
<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;">
|
<a href="${tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-secondary" style="flex: 1; text-align: center;">
|
||||||
Software-Homepage
|
Software-Homepage
|
||||||
</a>
|
</a>
|
||||||
<a href="${tool.projectUrl}" target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="flex: 1;">
|
<a href="${tool.projectUrl}" target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="flex: 1; text-align: center;">
|
||||||
Zugreifen
|
Zugreifen
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -169,28 +369,28 @@ function createToolCard(tool) {
|
|||||||
} else {
|
} else {
|
||||||
// Single button for tools we're not hosting
|
// Single button for tools we're not hosting
|
||||||
buttonHTML = `
|
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
|
Software-Homepage
|
||||||
</a>
|
</a>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
cardDiv.innerHTML = `
|
cardDiv.innerHTML = `
|
||||||
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.75rem;">
|
<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;">${tool.name}</h3>
|
<h3 style="margin: 0; flex: 1; min-width: 0;">${tool.name}</h3>
|
||||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; flex-shrink: 0;">
|
||||||
${hasValidProjectUrl ? '<span class="badge badge-primary">Self-Hosted</span>' : ''}
|
${hasValidProjectUrl ? '<span class="badge badge-primary">Self-Hosted</span>' : ''}
|
||||||
${tool.license !== 'Proprietary' ? '<span class="badge badge-success">Open Source</span>' : ''}
|
${tool.license !== 'Proprietary' ? '<span class="badge badge-success">Open Source</span>' : ''}
|
||||||
${hasKnowledgebase ? '<span class="badge badge-error">Infos 📖</span>' : ''}
|
${hasKnowledgebase ? '<span class="badge badge-error">Infos 📖</span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
</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}
|
${tool.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem;">
|
<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">
|
<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>
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
<line x1="9" y1="9" x2="15" y2="9"></line>
|
<line x1="9" y1="9" x2="15" y2="9"></line>
|
||||||
@ -201,7 +401,7 @@ function createToolCard(tool) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<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>
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
<path d="M12 6v6l4 2"></path>
|
<path d="M12 6v6l4 2"></path>
|
||||||
@ -211,7 +411,7 @@ function createToolCard(tool) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<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>
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||||
<polyline points="14 2 14 8 20 8"></polyline>
|
<polyline points="14 2 14 8 20 8"></polyline>
|
||||||
|
@ -46,14 +46,14 @@ const hostedServices = data.tools.filter((tool: any) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Uptime Kuma Embed -->
|
<!-- Uptime Kuma Embed
|
||||||
<div class="card" style="padding: 0; overflow: hidden;">
|
<div class="card" style="padding: 0; overflow: hidden;">
|
||||||
<iframe
|
<iframe
|
||||||
src="https://status.mikoshi.de/status/cc24-hub?embed=true"
|
src="https://status.mikoshi.de/status/cc24-hub?embed=true"
|
||||||
style="width: 100%; height: 600px; border: none;"
|
style="width: 100%; height: 600px; border: none;"
|
||||||
title="Uptime Kuma Status Page"
|
title="Uptime Kuma Status Page"
|
||||||
></iframe>
|
></iframe>
|
||||||
</div>
|
</div>-->
|
||||||
</section>
|
</section>
|
||||||
</BaseLayout>
|
</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