Merge pull request 'ai-feature' (#1) from ai-feature into main

Reviewed-on: mstoeck3/cc24-hub#1
This commit is contained in:
Mario Stöckl 2025-07-17 12:59:48 +00:00
commit b1955521bc
24 changed files with 2555 additions and 5371 deletions

View File

@ -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
View File

@ -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
View File

@ -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

View File

@ -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
View 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

File diff suppressed because it is too large Load Diff

View File

@ -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"

View 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>

View File

@ -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>
))} ))}

View File

@ -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;

View File

@ -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');

View File

@ -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
View 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' }
});
}
};

View 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' }
});
}
};

View 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 });
}
};

View 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' }
});
}
};

View 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' }
});
}
};

View 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' }
});
}
};

View 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>

View File

@ -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>

View File

@ -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
View 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
View 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;
}