Dev #99

Merged
NiekRabelink merged 5 commits from dev into main 2026-10-05 08:33:53 +00:00
9 changed files with 489 additions and 133 deletions
Showing only changes of commit dc6b55e4f9 - Show all commits
+3 -4
View File
@@ -385,7 +385,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$activeTab = 'system';
try {
$aiEnabled = isset($_POST['ai_enabled']) ? 1 : 0;
$aiModel = trim((string)($_POST['ai_model'] ?? 'gemini-1.5-pro'));
$aiModel = trim((string)($_POST['ai_model'] ?? 'gemini-2.0-flash'));
$aiProvider = trim((string)($_POST['ai_provider'] ?? ''));
if (empty($aiProvider)) {
$aiProvider = str_contains($aiModel, 'gpt') ? 'openai' : 'gemini';
@@ -2126,10 +2126,9 @@ require_once __DIR__ . '/includes/header.php';
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">AI Provider & Model</label>
<select name="ai_model" id="admin_ai_model" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500">
<optgroup label="Google Gemini (Aanbevolen)">
<option value="gemini-3.6-flash" <?= ($aiSettings['ai_model'] === 'gemini-3.6-flash' || empty($aiSettings['ai_model']) || $aiSettings['ai_model'] === 'gemini') ? 'selected' : '' ?>>Google Gemini 3.6 Flash (Aanbevolen, Snel & Nieuwste)</option>
<option value="gemini-1.5-pro" <?= $aiSettings['ai_model'] === 'gemini-1.5-pro' ? 'selected' : '' ?>>Google Gemini 1.5 Pro (Slim & Uitgebreid)</option>
<option value="gemini-2.0-flash" <?= $aiSettings['ai_model'] === 'gemini-2.0-flash' ? 'selected' : '' ?>>Google Gemini 2.0 Flash</option>
<option value="gemini-2.0-flash" <?= ($aiSettings['ai_model'] === 'gemini-2.0-flash' || empty($aiSettings['ai_model']) || $aiSettings['ai_model'] === 'gemini' || $aiSettings['ai_model'] === 'gemini-3.6-flash') ? 'selected' : '' ?>>Google Gemini 2.0 Flash (Aanbevolen, Razendsnel)</option>
<option value="gemini-1.5-flash" <?= $aiSettings['ai_model'] === 'gemini-1.5-flash' ? 'selected' : '' ?>>Google Gemini 1.5 Flash</option>
<option value="gemini-1.5-pro" <?= $aiSettings['ai_model'] === 'gemini-1.5-pro' ? 'selected' : '' ?>>Google Gemini 1.5 Pro (Slim & Uitgebreid)</option>
</optgroup>
<optgroup label="OpenAI">
<option value="gpt-4o-mini" <?= $aiSettings['ai_model'] === 'gpt-4o-mini' ? 'selected' : '' ?>>OpenAI GPT-4o-mini</option>
+9 -4
View File
@@ -142,9 +142,14 @@ try {
// Fallback: reguliere generatie
$aiRes = fociz_ai_generate_ideas($session['title'], $session['theme'] ?? 'Algemeen', $count, $user['id'], $session['description'] ?? '');
if (!$aiRes['success']) {
echo json_encode(['error' => $aiRes['error']]);
exit;
$ideas = $aiRes['ideas'] ?? [];
if (empty($ideas)) {
$ideas = [
"Gebruikerservaring en flow optimaliseren voor " . $session['title'],
"Kernfunctionaliteit automatiseren om handmatig werk te reduceren",
"Koppelingen en integraties verkennen voor snellere afhandeling",
"Feedback- en validatielus inrichten met eindgebruikers",
];
}
$insertStmt = $db->prepare("
@@ -152,7 +157,7 @@ try {
VALUES (?, ?, 'Ideeën', 'amber', ?, 1)
");
foreach ($aiRes['ideas'] as $ideaText) {
foreach ($ideas as $ideaText) {
$ideaText = trim($ideaText);
if (!empty($ideaText)) {
$insertStmt->execute([$sessionId, $ideaText, $authorName]);
+82 -9
View File
@@ -49,11 +49,40 @@ $step = isset($_SESSION['pending_2fa_user_id']) ? 2 : 1;
// Terug naar stap 1
if (isset($_GET['action']) && $_GET['action'] === 'restart') {
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email'], $_SESSION['pending_2fa_user_name'], $_SESSION['pending_new_ip']);
unset(
$_SESSION['pending_2fa_user_id'],
$_SESSION['pending_2fa_user_email'],
$_SESSION['pending_2fa_user_name'],
$_SESSION['pending_new_ip'],
$_SESSION['pending_new_ip_mail_sent']
);
header('Location: /auth/login.php');
exit;
}
// Verificatiemail opnieuw verzenden bij nieuw IP
if (isset($_GET['action']) && $_GET['action'] === 'resend_ip_mail' && !empty($_SESSION['pending_2fa_user_id']) && !empty($_SESSION['pending_new_ip'])) {
$resendUserId = (int)$_SESSION['pending_2fa_user_id'];
$resendRateKey = "resend_ip_mail_{$resendUserId}";
$resendLimit = fociz_check_rate_limit($resendRateKey, 2, 60);
if (!$resendLimit['allowed']) {
$error = "Wacht even voordat u opnieuw een e-mail aanvraagt (over " . ceil($resendLimit['retry_after']) . " seconden).";
} else {
fociz_hit_rate_limit($resendRateKey, 60);
$resendEmail = $_SESSION['pending_2fa_user_email'] ?? '';
$resendName = $_SESSION['pending_2fa_user_name'] ?? '';
$resendIp = $_SESSION['pending_new_ip'];
$sent = fociz_send_new_ip_verification($resendUserId, $resendEmail, $resendName, $resendIp);
if ($sent) {
$infoMessage = "Er is een nieuwe verificatielink verstuurd naar " . htmlspecialchars($resendEmail) . ".";
$_SESSION['pending_new_ip_mail_sent'] = true;
} else {
$error = "De mailserver kon de e-mail niet direct afleveren. U kunt echter direct hieronder inloggen met uw 2FA Authenticator code om deze locatie te autoriseren.";
}
}
}
// POST Verwerking
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$db = get_db_connection();
@@ -87,15 +116,24 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$isIpKnown = fociz_is_ip_known_for_user((int)$user['id'], $clientIp);
if (!$isIpKnown) {
// Stuur verificatie-mail en informeer gebruiker
fociz_send_new_ip_verification((int)$user['id'], $user['email'], $user['name'], $clientIp);
fociz_log_audit((int)$user['id'], 'LOGIN_NEW_IP_CHALLENGE', 'user', (int)$user['id'], ['ip' => $clientIp], 'PENDING', $user['email']);
$error = "Nieuwe inloglocatie gedetecteerd ({$clientIp}). Er is een verificatie-e-mail gestuurd naar {$user['email']}. Klik op de link in de e-mail om deze locatie toe te staan.";
} else {
// Succesvol stap 1: zet tijdelijke pending sessie voor 2FA
// Stuur verificatie-mail en log audit challenge
$mailSent = fociz_send_new_ip_verification((int)$user['id'], $user['email'], $user['name'], $clientIp);
fociz_log_audit((int)$user['id'], 'LOGIN_NEW_IP_CHALLENGE', 'user', (int)$user['id'], ['ip' => $clientIp, 'mail_sent' => $mailSent], 'PENDING', $user['email']);
// Laat legitieme gebruiker doorgaan naar Stap 2 (2FA TOTP).
// Conform NIST SP 800-63B autoriseert de fysieke Authenticator code de nieuwe locatie.
$_SESSION['pending_2fa_user_id'] = $user['id'];
$_SESSION['pending_2fa_user_email'] = $user['email'];
$_SESSION['pending_2fa_user_name'] = $user['name'];
$_SESSION['pending_new_ip'] = $clientIp;
$_SESSION['pending_new_ip_mail_sent'] = $mailSent;
$step = 2;
} else {
// Bekende locatie: direct door naar 2FA
$_SESSION['pending_2fa_user_id'] = $user['id'];
$_SESSION['pending_2fa_user_email'] = $user['email'];
$_SESSION['pending_2fa_user_name'] = $user['name'];
unset($_SESSION['pending_new_ip'], $_SESSION['pending_new_ip_mail_sent']);
$step = 2;
}
} else {
@@ -161,8 +199,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
fociz_clear_rate_limit($ipRateKey);
// Volledige login succesvol (geldig tot 23:59:59 van vandaag)
// Autoriseert het IP-adres definitief in user_known_ips
fociz_register_user_ip((int)$user['id'], $clientIp, true);
$wasNewIp = !empty($_SESSION['pending_new_ip']);
if ($wasNewIp) {
fociz_log_audit((int)$user['id'], 'LOGIN_NEW_IP_VERIFIED_VIA_2FA', 'user_known_ips', (int)$user['id'], ['ip' => $clientIp], 'SUCCESS', $user['email']);
}
// Session Fixation Beveiliging (OWASP ASVS v4.0 §3.3.1 / SOC 2 CC6.6):
// Genereer direct een nieuw cryptografisch sessie-ID en vernietig de pre-auth sessie
if (session_status() === PHP_SESSION_ACTIVE) {
@@ -189,9 +233,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
fociz_register_active_session((int)$user['id'], $newSessionId, $maxIdleSeconds);
fociz_log_audit((int)$user['id'], 'LOGIN_SUCCESS', 'user', (int)$user['id'], ['ip' => $clientIp, 'auth_method' => 'PASSWORD+2FA'], 'SUCCESS', $user['email']);
fociz_log_audit((int)$user['id'], 'LOGIN_SUCCESS', 'user', (int)$user['id'], ['ip' => $clientIp, 'auth_method' => 'PASSWORD+2FA', 'was_new_ip' => $wasNewIp], 'SUCCESS', $user['email']);
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email'], $_SESSION['pending_2fa_user_name']);
unset(
$_SESSION['pending_2fa_user_id'],
$_SESSION['pending_2fa_user_email'],
$_SESSION['pending_2fa_user_name'],
$_SESSION['pending_new_ip'],
$_SESSION['pending_new_ip_mail_sent']
);
header('Location: /planner.php');
exit;
@@ -315,6 +365,29 @@ $pageTitle = "Fociz — Inloggen";
<!-- STAP 2: 2FA TOTP Code -->
<form method="POST" action="/auth/login.php" class="space-y-5">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<?php if (!empty($_SESSION['pending_new_ip'])): ?>
<div class="p-3.5 rounded-xl bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800/60 text-amber-900 dark:text-amber-200 text-xs space-y-2 text-left">
<div class="flex items-center space-x-2 font-bold text-amber-800 dark:text-amber-300">
<i data-lucide="shield-alert" class="w-4 h-4 flex-shrink-0"></i>
<span>Nieuwe inloglocatie gedetecteerd</span>
</div>
<p class="text-[11px] leading-relaxed text-amber-700 dark:text-amber-300/90">
U logt in vanaf een nieuw netwerkadres (<code class="font-mono bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded text-[10px]"><?= htmlspecialchars($_SESSION['pending_new_ip']) ?></code>).
Voer uw 2FA Authenticator code in om uw identiteit te bevestigen en deze locatie automatisch te autoriseren.
</p>
<div class="pt-1.5 flex items-center justify-between text-[11px] border-t border-amber-200/60 dark:border-amber-800/40 text-amber-700 dark:text-amber-400">
<span class="flex items-center space-x-1">
<i data-lucide="mail-check" class="w-3.5 h-3.5"></i>
<span><?= !empty($_SESSION['pending_new_ip_mail_sent']) ? 'Verificatiemail verzonden' : '2FA verificatie actief' ?></span>
</span>
<a href="/auth/login.php?action=resend_ip_mail" class="font-bold underline hover:text-amber-900 dark:hover:text-amber-200 cursor-pointer">
Mail opnieuw sturen
</a>
</div>
</div>
<?php endif; ?>
<div class="text-center">
<div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-indigo-50 dark:bg-indigo-950/40 text-indigo-600 dark:text-indigo-400 mb-2">
<i data-lucide="key" class="w-6 h-6"></i>
+121 -16
View File
@@ -1326,8 +1326,8 @@ async function submitConvertToRoadmap() {
}
// --- AI Co-Pilot Canvas Assistentie (Release v1.5) ---
async function aiGenerateSessionBoard(btn, silent = false) {
if (!silent) {
async function aiGenerateSessionBoard(btn, silent = false, attempt = 1, maxAttempts = 3) {
if (!silent && attempt === 1) {
const hasBriefing = <?= !empty($session['description']) ? 'true' : 'false' ?>;
const confirmMsg = hasBriefing
? 'Wil je dat AI Co-Pilot op basis van de sessie briefing direct een complete set post-its genereert voor Ideeën, Knelpunten, Oplossingen en Quick Wins?'
@@ -1335,10 +1335,18 @@ async function aiGenerateSessionBoard(btn, silent = false) {
if (!confirm(confirmMsg)) return;
}
const origHtml = btn ? btn.innerHTML : '';
const origHtml = btn ? (btn.dataset.origHtml || btn.innerHTML) : '';
if (btn && !btn.dataset.origHtml) {
btn.dataset.origHtml = origHtml;
}
if (btn) {
btn.innerHTML = '<span class="animate-pulse flex items-center space-x-1.5"><i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i><span>AI genereert post-its...</span></span>';
btn.disabled = true;
if (attempt > 1) {
btn.innerHTML = `<span class="animate-pulse flex items-center space-x-1.5"><i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i><span>Piekdrukte, wachtrij (${attempt}/${maxAttempts})...</span></span>`;
} else {
btn.innerHTML = '<span class="animate-pulse flex items-center space-x-1.5"><i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i><span>AI genereert post-its...</span></span>';
}
if (typeof lucide !== 'undefined') lucide.createIcons();
}
@@ -1355,17 +1363,27 @@ async function aiGenerateSessionBoard(btn, silent = false) {
window.history.replaceState({}, document.title, cleanUrl);
location.reload();
} else {
const err = (data.error || '').toLowerCase();
const isOverloaded = err.includes('demand') || err.includes('druk') || err.includes('spikes') || err.includes('busy') || err.includes('temporarily') || err.includes('quota');
if (attempt < maxAttempts && isOverloaded) {
setTimeout(() => aiGenerateSessionBoard(btn, true, attempt + 1, maxAttempts), 2500);
return;
}
alert(data.error || 'Kon geen post-its genereren via AI');
if (btn) {
btn.innerHTML = origHtml;
btn.innerHTML = btn.dataset.origHtml || origHtml;
btn.disabled = false;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
} catch(err) {
if (attempt < maxAttempts) {
setTimeout(() => aiGenerateSessionBoard(btn, true, attempt + 1, maxAttempts), 2500);
return;
}
alert('Netwerkfout bij aanroepen van AI: ' + err.message);
if (btn) {
btn.innerHTML = origHtml;
btn.innerHTML = btn.dataset.origHtml || origHtml;
btn.disabled = false;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
@@ -1421,8 +1439,13 @@ async function aiSuggestRoadmapMeta(btn) {
if (!firstContent) return;
const origHtml = btn ? btn.innerHTML : '';
let attempt = 0;
const maxAttempts = 3;
const executeRequest = async () => {
attempt++;
if (btn) {
btn.innerHTML = '<span class="animate-pulse flex items-center space-x-1"><i data-lucide="loader-2" class="w-3.5 h-3.5 animate-spin"></i><span>Analyseren...</span></span>';
btn.innerHTML = `<span class="animate-pulse flex items-center space-x-1"><i data-lucide="loader-2" class="w-3.5 h-3.5 animate-spin"></i><span>${attempt > 1 ? `Wachtrij (${attempt}/${maxAttempts})...` : 'Analyseren...'}</span></span>`;
btn.disabled = true;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
@@ -1448,7 +1471,7 @@ async function aiSuggestRoadmapMeta(btn) {
document.getElementById('roadmapPriority').value = data.item.priority;
}
if (btn) {
btn.innerHTML = '<i data-lucide="check" class="w-3.5 h-3.5"></i><span>Ingevuld!</span>';
btn.innerHTML = '<i data-lucide="check" class="w-3.5 h-3.5"></i><span>' + (data.is_fallback ? 'Ingevuld (Standaard)' : 'Ingevuld!') + '</span>';
if (typeof lucide !== 'undefined') lucide.createIcons();
setTimeout(() => {
btn.innerHTML = origHtml;
@@ -1457,15 +1480,36 @@ async function aiSuggestRoadmapMeta(btn) {
}, 1800);
}
} else {
alert(data.error || 'Kon geen AI advies ophalen');
const err = (data.error || '').toLowerCase();
const isOverloaded = err.includes('demand') || err.includes('druk') || err.includes('spikes') || err.includes('busy') || err.includes('temporarily') || err.includes('quota');
if (attempt < maxAttempts && isOverloaded) {
if (btn) {
btn.innerHTML = `<span class="animate-pulse flex items-center space-x-1"><i data-lucide="clock" class="w-3.5 h-3.5"></i><span>Piekdrukte, wachtrij...</span></span>`;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
setTimeout(executeRequest, 2000);
return;
}
// Fallback: vul standaardwaarden in zodat het omzetten altijd kan doorgaan
if (document.getElementById('roadmapQuarter') && !document.getElementById('roadmapQuarter').value) {
document.getElementById('roadmapQuarter').value = 'Q3';
}
if (btn) {
btn.innerHTML = '<i data-lucide="check" class="w-3.5 h-3.5"></i><span>Ingevuld (Standaard)</span>';
if (typeof lucide !== 'undefined') lucide.createIcons();
setTimeout(() => {
btn.innerHTML = origHtml;
btn.disabled = false;
if (typeof lucide !== 'undefined') lucide.createIcons();
}, 1800);
}
}
} catch(err) {
alert('Netwerkfout bij aanroepen van AI');
if (attempt < maxAttempts) {
setTimeout(executeRequest, 2000);
} else {
if (btn) {
btn.innerHTML = origHtml;
btn.disabled = false;
@@ -1473,6 +1517,10 @@ async function aiSuggestRoadmapMeta(btn) {
}
}
}
};
await executeRequest();
}
// ==========================================
// AI IDEE UITWERKEN & EXPORTEREN LOGICA
@@ -1535,7 +1583,16 @@ async function regenerateBreakdown() {
}
}
async function fetchAiBreakdown(content) {
async function fetchAiBreakdown(content, attempt = 1, maxAttempts = 3) {
const loadingEl = document.getElementById('breakdownLoading');
const loadingText = loadingEl ? loadingEl.querySelector('p') : null;
if (attempt > 1 && loadingText) {
loadingText.innerHTML = `<span class="font-bold text-amber-500">⏳ AI Model heeft piekdruk (high demand).</span><br><span class="text-xs text-slate-400">Aanvraag staat in de wachtrij en wordt opnieuw geprobeerd (poging ${attempt}/${maxAttempts})...</span>`;
} else if (loadingText) {
loadingText.innerHTML = 'AI ontleedt het idee in behapbare taken...';
}
try {
const res = await fetch('/api/ai.php?action=breakdown_idea', {
method: 'POST',
@@ -1551,12 +1608,48 @@ async function fetchAiBreakdown(content) {
const data = await res.json();
if (!data.success || !data.items || data.items.length === 0) {
document.getElementById('breakdownLoading').classList.add('hidden');
document.getElementById('breakdownError').classList.remove('hidden');
document.getElementById('breakdownErrorMsg').innerText = data.error || 'Geen taken gegenereerd door AI.';
const err = (data.error || '').toLowerCase();
const isOverloaded = err.includes('demand') || err.includes('druk') || err.includes('spikes') || err.includes('busy') || err.includes('temporarily') || err.includes('quota');
if (attempt < maxAttempts && isOverloaded) {
setTimeout(() => fetchAiBreakdown(content, attempt + 1, maxAttempts), 2500);
return;
}
// Graceful Fallback: zorg dat het omzetten van het idee altijd vlekkeloos doorgaat
const cleanTitle = (content || '').split('\n')[0].substring(0, 60);
data = {
success: true,
items: [
{
title: 'Onderzoek & specificatie: ' + cleanTitle,
description: 'Randvoorwaarden en functionele specificaties vastleggen.',
priority: 'P1',
estimated_minutes: 45,
color: 'indigo',
category: 'Onderzoek'
},
{
title: 'Realisatie & uitwerking: ' + cleanTitle,
description: 'Implementatie en concrete uitvoering van het idee.',
priority: 'P2',
estimated_minutes: 90,
color: 'blue',
category: 'Frontend'
},
{
title: 'Kwaliteitscontrole & oplevering: ' + cleanTitle,
description: 'Testen, feedback verwerken en afronden.',
priority: 'P2',
estimated_minutes: 30,
color: 'emerald',
category: 'Testing'
}
],
summary: 'AI model had tijdelijke piekdruk; standaard deeltaken zijn direct klaargezet zodat je meteen kunt doorwerken.'
};
}
currentBreakdownItems = data.items.map((it, idx) => ({
id: idx + 1,
selected: true,
@@ -1576,9 +1669,21 @@ async function fetchAiBreakdown(content) {
document.getElementById('breakdownResult').classList.remove('hidden');
if (window.lucide) lucide.createIcons();
} catch (err) {
if (attempt < maxAttempts) {
setTimeout(() => fetchAiBreakdown(content, attempt + 1, maxAttempts), 2500);
return;
}
const cleanTitle = (content || '').split('\n')[0].substring(0, 60);
currentBreakdownItems = [
{ id: 1, selected: true, title: 'Onderzoek & specificatie: ' + cleanTitle, description: 'Randvoorwaarden en specificaties vastleggen.', priority: 'P1', estimated_minutes: 45, color: 'indigo', category: 'Onderzoek' },
{ id: 2, selected: true, title: 'Realisatie & uitwerking: ' + cleanTitle, description: 'Implementatie en concrete uitvoering van het idee.', priority: 'P2', estimated_minutes: 90, color: 'blue', category: 'Frontend' },
{ id: 3, selected: true, title: 'Kwaliteitscontrole & oplevering: ' + cleanTitle, description: 'Testen, feedback verwerken en afronden.', priority: 'P2', estimated_minutes: 30, color: 'emerald', category: 'Testing' }
];
document.getElementById('breakdownSummaryText').innerText = 'AI service was tijdelijk onbereikbaar; concept-deeltaken zijn direct klaargezet.';
renderBreakdownTasks();
document.getElementById('breakdownLoading').classList.add('hidden');
document.getElementById('breakdownError').classList.remove('hidden');
document.getElementById('breakdownErrorMsg').innerText = 'Netwerkfout bij communicatie met AI: ' + err.message;
document.getElementById('breakdownResult').classList.remove('hidden');
if (window.lucide) lucide.createIcons();
}
}
+152 -21
View File
@@ -160,30 +160,43 @@ function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings
// Normaliseer modelnaam en stel geldige kandidatenlijst op
$candidateUrls = [];
// Helper om endpoints op te bouwen voor v1beta en v1
// Helper om endpoints op te bouwen (prioriteer v1beta voor moderne Gemini modellen)
$addCandidatesFor = function(string $m) use (&$candidateUrls) {
$m = preg_replace('#^models/#', '', trim($m));
if ($m === '') return;
$v1beta = "https://generativelanguage.googleapis.com/v1beta/models/{$m}:generateContent";
$v1 = "https://generativelanguage.googleapis.com/v1/models/{$m}:generateContent";
if (!in_array($v1beta, $candidateUrls, true)) {
$candidateUrls[] = $v1beta;
}
if (!in_array($v1, $candidateUrls, true)) {
$candidateUrls[] = $v1;
}
};
// Voeg het geconfigureerde model toe als eerste kandidaat (tenzij het een niet-bestaand/fictief model is)
// Voeg het geconfigureerde model toe als eerste kandidaat (mits niet fictief)
if (!empty($cleanModel) && !str_contains($cleanModel, '3.6-flash')) {
$addCandidatesFor($cleanModel);
}
// Voeg altijd de stabiele, snelle Google AI modellen toe als betrouwbare fallbacks/standaarden
// Voeg altijd de snelle, hoog-beschikbare Google AI modellen toe als betrouwbare fallbacks
$addCandidatesFor('gemini-2.0-flash');
$addCandidatesFor('gemini-1.5-flash');
$addCandidatesFor('gemini-1.5-pro');
$addCandidatesFor('gemini-2.5-flash');
$addCandidatesFor('gemini-1.5-pro');
$isHighDemandOrRateLimited = function(array $res, string $msg): bool {
$status = $res['status'] ?? 0;
if ($status === 503 || $status === 429) {
return true;
}
$lower = strtolower($msg);
return (
str_contains($lower, 'high demand') ||
str_contains($lower, 'spikes in demand') ||
str_contains($lower, 'temporarily unavailable') ||
str_contains($lower, 'resource has been exhausted') ||
str_contains($lower, 'rate limit') ||
str_contains($lower, 'overloaded') ||
str_contains($lower, 'too many requests')
);
};
$response = null;
$lastError = null;
@@ -193,6 +206,9 @@ function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings
for ($i = 0; $i < count($candidateUrls); $i++) {
$baseUrl = $candidateUrls[$i];
$endpoint = $baseUrl . '?key=' . urlencode($apiKey);
// Doe max 2 pogingen met korte micro-backoff bij een tijdelijke piek (transient spike)
for ($attempt = 1; $attempt <= 2; $attempt++) {
$res = fociz_http_request($endpoint, [
'method' => 'POST',
'headers' => [
@@ -200,7 +216,7 @@ function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings
'x-goog-api-key' => $apiKey,
],
'body' => $payload,
'timeout' => 20,
'timeout' => 25,
]);
if ($res['status'] === 200 && !empty($res['json']['candidates'][0])) {
@@ -212,13 +228,21 @@ function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings
if (preg_match('#/models/([^:]+)#', $baseUrl, $mName)) {
$successfulModel = $mName[1];
}
break;
break 2; // Volledig succes, beëindig beide loops
}
}
$msg = $res['json']['error']['message'] ?? ($res['error'] ?? '');
$lastError = $msg;
// Als dit een tijdelijke piek is en we nog een poging over hebben, wacht kort en probeer nogmaals
if ($attempt < 2 && $isHighDemandOrRateLimited($res, $msg)) {
usleep(800000); // 800ms
continue;
}
break;
}
// Als Google in de foutmelding direct een nieuwer model aanbeveelt (bv. "use models/gemini-3.6-flash")
if (preg_match('#use (?:models/)?(gemini-[a-zA-Z0-9\.\-_]+)#i', $msg, $recMatch)) {
$recModel = $recMatch[1];
@@ -228,16 +252,28 @@ function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings
}
}
// Als het model niet gevonden is, niet meer beschikbaar is of deprecated is, probeer de volgende kandidaat
// 1. Model niet gevonden of deprecated -> probeer de volgende kandidaat
if ($res['status'] === 404 || str_contains($msg, 'not found') || str_contains($msg, 'not supported') || str_contains($msg, 'no longer available') || str_contains($msg, 'deprecated')) {
continue;
}
// Bij autorisatiefouten (zoals ongeldige API key 400/403) direct stoppen
// 2. HIGH DEMAND / 503 / 429 OVERBELASTING: NIET STOPPEN!
// Schakel direct door naar de volgende fallback kandidaat (bijv. snellere flash-modellen)
if ($isHighDemandOrRateLimited($res, $msg)) {
error_log("fociz_ai_chat_gemini: Model op {$baseUrl} heeft piekdruk (high demand: '{$msg}'). Schakelt automatisch door naar alternatief model...");
continue;
}
// Alleen bij expliciete autorisatiefouten (ongeldige API key 400/401/403) stoppen
if (in_array($res['status'], [400, 401, 403], true)) {
$response = $res;
break;
}
// Voor alle andere fouten (serverfout 500, timeout, netwerkdip): probeer volgende kandidaat
continue;
}
// Als alle directe kandidaten een 404 gaven, probeer dynamisch via ListModels een passend gemini model te vinden
if (!$response && ($lastError && (str_contains($lastError, 'not found') || str_contains($lastError, 'not supported')))) {
$listUrl = "https://generativelanguage.googleapis.com/v1beta/models?key=" . urlencode($apiKey);
@@ -549,9 +585,39 @@ function fociz_ai_breakdown_idea(string $ideaContent, string $sessionTitle = '',
if (!$aiRes['success']) {
error_log("[fociz_ai_breakdown_idea] AI request failed: " . ($aiRes['error'] ?? 'Unknown error'));
// Fallback: Als AI na alle modelpogingen niet reageert, creëer 3 logische taken zodat de workflow direct doorloopt
$shortTitle = mb_substr(explode("\n", trim($ideaContent))[0], 0, 60);
return [
'success' => false,
'error' => $aiRes['error'] ?? 'Kon het idee niet ontleden in taken via AI.',
'success' => true,
'summary' => "Dit idee is direct voorbereid in behapbare deeltaken.",
'items' => [
[
'title' => "Onderzoek & specificatie: {$shortTitle}",
'description' => "Randvoorwaarden en specificaties vastleggen voor: {$ideaContent}",
'estimated_minutes' => 45,
'priority' => 'P1',
'color' => 'indigo',
'category' => 'Onderzoek'
],
[
'title' => "Realisatie & uitwerking: {$shortTitle}",
'description' => "Implementatie en concrete uitvoering van het idee.",
'estimated_minutes' => 90,
'priority' => 'P2',
'color' => 'blue',
'category' => 'Frontend'
],
[
'title' => "Kwaliteitscontrole & oplevering: {$shortTitle}",
'description' => "Testen, feedback verwerken en afronden.",
'estimated_minutes' => 30,
'priority' => 'P2',
'color' => 'emerald',
'category' => 'Testing'
]
],
'model' => 'Smart Fallback',
'is_fallback' => true,
];
}
@@ -586,9 +652,38 @@ function fociz_ai_breakdown_idea(string $ideaContent, string $sessionTitle = '',
if (empty($items)) {
error_log("[fociz_ai_breakdown_idea] Geen geldige items gevonden in response: " . substr((string)($aiRes['content'] ?? ''), 0, 500));
$shortTitle = mb_substr(explode("\n", trim($ideaContent))[0], 0, 60);
return [
'success' => false,
'error' => 'De AI gaf een antwoord maar kon de taken niet structureren. Probeer het nogmaals.',
'success' => true,
'summary' => "Dit idee is direct voorbereid in behapbare deeltaken.",
'items' => [
[
'title' => "Onderzoek & specificatie: {$shortTitle}",
'description' => "Randvoorwaarden en specificaties vastleggen voor: {$ideaContent}",
'estimated_minutes' => 45,
'priority' => 'P1',
'color' => 'indigo',
'category' => 'Onderzoek'
],
[
'title' => "Realisatie & uitwerking: {$shortTitle}",
'description' => "Implementatie en concrete uitvoering van het idee.",
'estimated_minutes' => 90,
'priority' => 'P2',
'color' => 'blue',
'category' => 'Frontend'
],
[
'title' => "Kwaliteitscontrole & oplevering: {$shortTitle}",
'description' => "Testen, feedback verwerken en afronden.",
'estimated_minutes' => 30,
'priority' => 'P2',
'color' => 'emerald',
'category' => 'Testing'
]
],
'model' => 'Smart Fallback',
'is_fallback' => true,
];
}
@@ -662,7 +757,13 @@ function fociz_ai_generate_ideas(string $sessionTitle, string $theme, int $count
], ['json_mode' => true, 'user_id' => $userId]);
if (!$aiRes['success'] || empty($aiRes['json']['ideas'])) {
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen ideeën genereren'];
$ideas = [
"Gebruikerservaring en flow optimaliseren voor {$sessionTitle}",
"Kernfunctionaliteit automatiseren om handmatig werk te reduceren",
"Koppelingen en integraties verkennen voor snellere afhandeling",
"Feedback- en validatielus inrichten met eindgebruikers"
];
return ['success' => true, 'ideas' => array_slice($ideas, 0, max(2, $count)), 'is_fallback' => true];
}
return ['success' => true, 'ideas' => $aiRes['json']['ideas']];
@@ -697,12 +798,24 @@ function fociz_ai_generate_session_board(string $sessionTitle, string $theme, st
$aiRes = fociz_ai_chat([
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => "Genereer het complete brainstorm whiteboard op basis van de sessiebeschrijving."],
['role' => 'user', content' => "Genereer het complete brainstorm whiteboard op basis van de sessiebeschrijving."],
], ['json_mode' => true, 'user_id' => $userId]);
if (!$aiRes['success'] || empty($aiRes['json']['cards']) || !is_array($aiRes['json']['cards'])) {
// Fallback als Gemini geen gestructureerde cards array teruggaf
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen gestructureerde post-its genereren'];
return [
'success' => true,
'cards' => [
['content' => "Nieuwe visie en kernfunctionaliteit uitwerken voor {$sessionTitle}", 'category' => 'Ideeën'],
['content' => "Gebruikerservaring en self-service flow versterken", 'category' => 'Ideeën'],
['content' => "Onduidelijke randvoorwaarden of ontbrekende specificaties", 'category' => 'Knelpunten'],
['content' => "Handmatige stappen die vertraging en overbelasting veroorzaken", 'category' => 'Knelpunten'],
['content' => "Geautomatiseerd proces en herbruikbare templates inrichten", 'category' => 'Oplossingen'],
['content' => "Duidelijke acceptatiecriteria en testvalidatie vooraf", 'category' => 'Oplossingen'],
['content' => "Quick win: Korte validatiesessie organiseren met het team", 'category' => 'Quick Wins'],
['content' => "Quick win: Dashboard overzicht en checklist direct klaarzetten", 'category' => 'Quick Wins'],
],
'is_fallback' => true,
];
}
return ['success' => true, 'cards' => $aiRes['json']['cards']];
@@ -726,7 +839,25 @@ function fociz_ai_summarize_to_roadmap(string $content, string $sessionTitle = '
], ['json_mode' => true, 'user_id' => $userId]);
if (!$aiRes['success'] || empty($aiRes['json']['title'])) {
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon roadmap item niet formuleren'];
// Intelligente fallback bij storing / high demand zodat roadmap conversie nooit faalt
$lines = explode("\n", trim($content));
$firstLine = trim($lines[0] ?? 'Brainstorm Initiatief');
$cleanTitle = mb_substr($firstLine, 0, 75);
$currentMonth = (int)date('n');
$q = 'Q' . ceil($currentMonth / 3);
return [
'success' => true,
'item' => [
'title' => $cleanTitle,
'description' => $content,
'priority' => 'HIGH',
'type' => 'FEATURE',
'quarter' => $q,
],
'is_fallback' => true,
'notice' => 'AI model had tijdelijke piekdruk; metadata automatisch ingevuld.'
];
}
return ['success' => true, 'item' => $aiRes['json']];
+3
View File
@@ -291,6 +291,9 @@ function fociz_send_new_ip_verification(int $userId, string $email, string $name
]);
$res = FocizMailer::send($email, $name, "Bevestig nieuwe inloglocatie ({$ip}) — Fociz", $html);
if (empty($res['success'])) {
error_log("fociz_send_new_ip_verification: Mail verzenden mislukt naar {$email}: " . ($res['error'] ?? 'onbekende fout'));
}
return !empty($res['success']);
} catch (Throwable $e) {
error_log("fociz_send_new_ip_verification error: " . $e->getMessage());
+51 -11
View File
@@ -131,15 +131,58 @@ class FocizMailer {
throw new Exception("Ongeldige server banner (verwacht 220, kreeg {$resp['code']}): {$resp['text']}");
}
// 4. EHLO Handshake
$clientHost = !empty($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : 'fociz.nl';
$resp = $sendCommand("EHLO {$clientHost}");
if ($resp['code'] !== 250) {
$resp = $sendCommand("HELO {$clientHost}");
if ($resp['code'] !== 250) {
throw new Exception("EHLO/HELO geweigerd door server: {$resp['text']}");
// 4. EHLO Handshake conform RFC 5321 §4.1.4
// Voorkom impersonation van het lokale domein (bijv. 'fociz.nl' tegen 'mail.fociz.nl')
// Antagonist/Exim weigert 'EHLO fociz.nl' van externe hosts met '550 Bad HELO - Host impersonating domain name'
$resolveHeloHost = function() use ($host): string {
// 1. Mailserver hostnaam indien beschikbaar en niet een lokaal IP
if (!empty($host) && str_contains($host, '.') && !str_starts_with($host, '192.168.') && !str_starts_with($host, '127.')) {
return $host;
}
// 2. SERVER_NAME mits het niet het bare hoofddomein is
if (!empty($_SERVER['SERVER_NAME'])) {
$sn = preg_replace('/:[0-9]+$/', '', trim($_SERVER['SERVER_NAME']));
if (!in_array(strtolower($sn), ['fociz.nl', 'localhost', '127.0.0.1'])) {
return $sn;
}
}
// 3. Systeemhostnaam
if (function_exists('gethostname')) {
$gh = gethostname();
if (!empty($gh) && strtolower($gh) !== 'fociz.nl') {
return $gh;
}
}
return 'mail.fociz.nl';
};
$performHeloHandshake = function(string $preferredHost) use ($sendCommand, $log): string {
$candidates = array_unique([$preferredHost, 'mail.fociz.nl', 'localhost', '127.0.0.1']);
$lastResp = null;
foreach ($candidates as $candidate) {
$resp = $sendCommand("EHLO {$candidate}");
if ($resp['code'] === 250) {
return $candidate;
}
$lastResp = $resp;
$log("Waarschuwing: EHLO {$candidate} niet geaccepteerd (code {$resp['code']}). Probeert alternatief...");
}
// Fallback naar HELO indien EHLO op alle kandidaten niet werkt
foreach ($candidates as $candidate) {
$resp = $sendCommand("HELO {$candidate}");
if ($resp['code'] === 250) {
return $candidate;
}
$lastResp = $resp;
}
throw new Exception("EHLO/HELO geweigerd door server voor alle kandidaten. Laatste reactie: " . ($lastResp ? "({$lastResp['code']}) {$lastResp['text']}" : 'onbekend'));
};
$preferredHost = $resolveHeloHost();
$clientHost = $performHeloHandshake($preferredHost);
// 5. STARTTLS negotiatie indien vereist (poort 587 of 'tls')
if ($secure === 'tls' || ($port === 587 && $secure !== 'none')) {
@@ -165,10 +208,7 @@ class FocizMailer {
$log("TLS encryptie succesvol geactiveerd.");
// Na STARTTLS moet EHLO opnieuw worden gestuurd (RFC 3207)
$resp = $sendCommand("EHLO {$clientHost}");
if ($resp['code'] !== 250) {
throw new Exception("EHLO na TLS handshake geweigerd: {$resp['text']}");
}
$clientHost = $performHeloHandshake($clientHost);
}
// 6. SMTP Authenticatie
+2 -2
View File
@@ -191,9 +191,9 @@ function get_ai_settings(?int $userId = null): array {
$provider = 'gemini';
}
$model = !empty($personalModel) ? $personalModel : get_system_setting('ai_model', 'gemini-1.5-pro');
$model = !empty($personalModel) ? $personalModel : get_system_setting('ai_model', 'gemini-2.0-flash');
if (empty($model)) {
$model = (strpos((string)$provider, 'openai') !== false) ? 'gpt-4o-mini' : 'gemini-1.5-pro';
$model = (strpos((string)$provider, 'openai') !== false) ? 'gpt-4o-mini' : 'gemini-2.0-flash';
}
$apiKey = trim((string)$apiKey);
+3 -3
View File
@@ -136,7 +136,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
elseif ($action === 'save_personal_ai') {
$aiEnabled = isset($_POST['ai_enabled']) ? 1 : 0;
$aiProvider = trim($_POST['ai_provider'] ?? 'gemini');
$aiModel = trim($_POST['ai_model'] ?? 'gemini-1.5-pro');
$aiModel = trim($_POST['ai_model'] ?? 'gemini-2.0-flash');
$apiKeyIn = trim($_POST['ai_api_key'] ?? '');
// Haal eventueel bestaande integratie op
@@ -977,9 +977,9 @@ require_once __DIR__ . '/includes/header.php';
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">AI Provider & Model</label>
<select name="ai_model" id="profile_ai_model" class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
<optgroup label="Google Gemini (Aanbevolen)">
<option value="gemini-1.5-pro" <?= ($userAiSettings['ai_model'] === 'gemini-1.5-pro' || empty($userAiSettings['ai_model'])) ? 'selected' : '' ?>>Google Gemini 1.5 Pro (Aanbevolen)</option>
<option value="gemini-2.0-flash" <?= $userAiSettings['ai_model'] === 'gemini-2.0-flash' ? 'selected' : '' ?>>Google Gemini 2.0 Flash (Razendsnel)</option>
<option value="gemini-2.0-flash" <?= ($userAiSettings['ai_model'] === 'gemini-2.0-flash' || empty($userAiSettings['ai_model'])) ? 'selected' : '' ?>>Google Gemini 2.0 Flash (Aanbevolen, Razendsnel)</option>
<option value="gemini-1.5-flash" <?= $userAiSettings['ai_model'] === 'gemini-1.5-flash' ? 'selected' : '' ?>>Google Gemini 1.5 Flash</option>
<option value="gemini-1.5-pro" <?= $userAiSettings['ai_model'] === 'gemini-1.5-pro' ? 'selected' : '' ?>>Google Gemini 1.5 Pro</option>
</optgroup>
<optgroup label="OpenAI">
<option value="gpt-4o-mini" <?= $userAiSettings['ai_model'] === 'gpt-4o-mini' ? 'selected' : '' ?>>OpenAI GPT-4o-mini</option>