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