diff --git a/docker-compose.yml b/docker-compose.yml index 8f07ab7..0cbaf90 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,12 +28,12 @@ services: - INTERNAL_AUTH_KEY=${INTERNAL_AUTH_KEY:-internal_dev_key} - BASIC_API_USER=webgis-api - BASIC_API_PASS=${BASIC_API_PASS:-api_pass} - - SMTP_HOST=smtp.gmail.com - - SMTP_PORT=587 - - SMTP_USER=${SMTP_USER:-dev@example.com} - - SMTP_PASS=${SMTP_PASS:-dev_pass} - - SMTP_ENCRYPTION=tls - - MAIL_FROM=${SMTP_USER:-dev@example.com} + - SMTP_HOST=${SMTP_HOST:-smtp.gmail.com} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_USER=${SMTP_USER:-} + - SMTP_PASS=${SMTP_PASS:-} + - SMTP_ENCRYPTION=${SMTP_ENCRYPTION:-tls} + - MAIL_FROM=${MAIL_FROM:-} - MAIL_FROM_NAME=PovertyMapping volumes: - poverty_uploads:/var/www/html/uploads diff --git a/poverty-mapping/index.html b/poverty-mapping/index.html index c38c103..afe27eb 100644 --- a/poverty-mapping/index.html +++ b/poverty-mapping/index.html @@ -544,6 +544,28 @@ function openAuthModal(mode){ if(switchToRegister) switchToRegister.onclick = function(){ openAuthModal('register'); }; if(switchToLogin) switchToLogin.onclick = function(){ openAuthModal('login'); }; }catch(e){} + if(mode === 'register'){ + const orgProof = document.getElementById('org_proof_input'); + if(orgProof){ + orgProof.addEventListener('change', function(){ + const f = (orgProof.files && orgProof.files[0]) ? orgProof.files[0] : null; + if(!f) return; + const maxSize = 5 * 1024 * 1024; + const allowed = ['jpg','jpeg','png','svg','pdf']; + const ext = (f.name || '').split('.').pop().toLowerCase(); + if(f.size > maxSize){ + showToast('Ukuran file bukti organisasi maksimal 5MB', 'error', 5000); + orgProof.value = ''; + return; + } + if(!allowed.includes(ext)){ + showToast('Jenis file tidak diperbolehkan. Hanya .png, .jpg, .jpeg, .svg, atau .pdf.', 'error', 5000); + orgProof.value = ''; + return; + } + }); + } + } const form = document.getElementById('auth-modal-form'); form.onsubmit = async function(ev){ ev.preventDefault(); @@ -615,6 +637,29 @@ function openAuthModal(mode){ return; } + // Client-side rate limiting check + const limitKey = 'webgis_limit_' + btoa(email); + let limitInfo = { attempts: 0, lastTime: 0, lockedUntil: 0 }; + try{ + const cached = localStorage.getItem(limitKey); + if(cached) limitInfo = JSON.parse(cached); + }catch(e){} + + const now = Date.now(); + if(limitInfo.lockedUntil && now < limitInfo.lockedUntil){ + const diff = limitInfo.lockedUntil - now; + const hours = Math.floor(diff / (3600 * 1000)); + const mins = Math.ceil((diff % (3600 * 1000)) / (60 * 1000)); + showToast(`Batas pengiriman kode tercapai. Anda harus menunggu ${hours} jam ${mins} menit lagi.`, 'error', 5000); + return; + } + + if(limitInfo.lastTime && now - limitInfo.lastTime < 30 * 1000){ + const remaining = Math.ceil((30 * 1000 - (now - limitInfo.lastTime)) / 1000); + showToast(`Silakan tunggu ${remaining} detik sebelum mengirim kembali.`, 'error', 3000); + return; + } + sendCodeBtn.disabled = true; sendCodeBtn.textContent = 'Mengirim...'; @@ -628,23 +673,58 @@ function openAuthModal(mode){ const jr = await resp.json().catch(()=>null); if(!resp.ok || (jr && jr.success === false)){ - showToast(jr && jr.error ? jr.error : 'Gagal mengirim kode', 'error', 4000); + const errMessage = jr && jr.message ? jr.message : (jr && jr.error ? jr.error : 'Gagal mengirim kode'); + showToast(errMessage, 'error', 5000); + + // If locked on server side, sync client side state + if(jr && jr.error === 'locked_24h' && jr.locked_until){ + limitInfo.lockedUntil = jr.locked_until; + try{ localStorage.setItem(limitKey, JSON.stringify(limitInfo)); }catch(e){} + } + sendCodeBtn.disabled = false; sendCodeBtn.textContent = 'Kirim Kode Verifikasi'; return; } + // Update client side rate limiting on success + limitInfo.attempts = (limitInfo.lockedUntil && now >= limitInfo.lockedUntil) ? 1 : (limitInfo.attempts + 1); + limitInfo.lastTime = now; + if(limitInfo.attempts >= 3){ + limitInfo.lockedUntil = now + 24 * 3600 * 1000; + } else { + limitInfo.lockedUntil = 0; + } + try{ localStorage.setItem(limitKey, JSON.stringify(limitInfo)); }catch(e){} + authRegisterState.code_sent = true; authRegisterState.email = email; if(jr.debug_code){ authRegisterState.code = jr.debug_code; + const codeInput = form.querySelector('input[name="verify_code"]'); + if(codeInput){ + codeInput.value = jr.debug_code; + showToast('Gagal mengirim email. Menggunakan kode pengujian: ' + jr.debug_code, 'warning', 8000); + } } showToast('Kode verifikasi dikirim ke email Anda!', 'success', 4000); - sendCodeBtn.textContent = 'Kode Terkirim ✓'; - setTimeout(() => { - sendCodeBtn.disabled = false; - }, 3000); + + // Cooldown countdown timer + let cooldown = 30; + sendCodeBtn.disabled = true; + sendCodeBtn.textContent = `Kirim Ulang (${cooldown}s)`; + const timer = setInterval(() => { + cooldown--; + if(cooldown <= 0){ + clearInterval(timer); + sendCodeBtn.disabled = false; + sendCodeBtn.textContent = 'Kirim Kode Verifikasi'; + } else { + sendCodeBtn.textContent = `Kirim Ulang (${cooldown}s)`; + } + }, 1000); + }catch(e){ console.error(e); showToast('Gagal mengirim kode verifikasi', 'error', 5000); diff --git a/poverty-mapping/src/api/auth/mail_helper.php b/poverty-mapping/src/api/auth/mail_helper.php index 50cd6d8..3e9372f 100644 --- a/poverty-mapping/src/api/auth/mail_helper.php +++ b/poverty-mapping/src/api/auth/mail_helper.php @@ -16,8 +16,8 @@ if(file_exists($dotenvPath . '.env')){ function env($k, $default = null){ $val = $_ENV[$k] ?? null; - if($val === null){ $val = getenv($k); } - return $val !== false ? $val : $default; + if($val === null || $val === ''){ $val = getenv($k); } + return ($val !== false && $val !== '') ? $val : $default; } function send_mail_phpmailer($to, $subject, $body, $isHtml = false){ diff --git a/poverty-mapping/src/api/auth/register.php b/poverty-mapping/src/api/auth/register.php index ccdf6a4..a9d2f77 100644 --- a/poverty-mapping/src/api/auth/register.php +++ b/poverty-mapping/src/api/auth/register.php @@ -40,7 +40,38 @@ if(!$verify_code || strlen($verify_code) !== 6){ exit; } // require uploaded proof file -if(!isset($_FILES['org_proof']) || $_FILES['org_proof']['error'] !== UPLOAD_ERR_OK){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_org_proof']); exit; } +if(!isset($_FILES['org_proof']) || $_FILES['org_proof']['error'] !== UPLOAD_ERR_OK){ + http_response_code(400); + echo json_encode(['success'=>false,'error'=>'missing_org_proof']); + exit; +} + +$file = $_FILES['org_proof']; +$maxSize = 5 * 1024 * 1024; // 5MB +if($file['size'] > $maxSize){ + http_response_code(400); + echo json_encode(['success'=>false,'error'=>'Ukuran bukti organisasi maksimal 5MB']); + exit; +} + +$allowedMimes = ['image/jpeg', 'image/png', 'image/svg+xml', 'application/pdf']; +$allowedExts = ['jpg', 'jpeg', 'png', 'svg', 'pdf']; +$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + +$mime = null; +if(function_exists('finfo_open')){ + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = finfo_file($finfo, $file['tmp_name']); + finfo_close($finfo); +} else { + $mime = $file['type']; +} + +if(!in_array($mime, $allowedMimes) || !in_array($ext, $allowedExts)){ + http_response_code(400); + echo json_encode(['success'=>false,'error'=>'Jenis file tidak diperbolehkan. Hanya .png, .jpg, .jpeg, .svg, atau .pdf.']); + exit; +} // Verify code matches what was sent (simple check: hash it and compare) // In production, store code server-side with TTL @@ -85,23 +116,18 @@ $newId = $conn->insert_id; $ins->close(); if(!$res){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; } -// handle file upload (org_proof) - validate extension + mime + size +// handle file upload (org_proof) - already validated above $orgProofPath = null; if(isset($_FILES['org_proof']) && $_FILES['org_proof']['error'] === UPLOAD_ERR_OK){ $file = $_FILES['org_proof']; - $maxSize = 5 * 1024 * 1024; // 5MB - $allowed = ['image/jpeg'=>['jpg','jpeg'],'image/png'=>['png'],'image/svg+xml'=>['svg'],'application/pdf'=>['pdf']]; + $uDir = __DIR__ . '/../../uploads/org_proofs'; + if(!is_dir($uDir)) { @mkdir($uDir, 0755, true); @chmod($uDir, 0755); } $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); - $mime = $file['type']; - if($file['size'] > 0 && $file['size'] <= $maxSize && isset($allowed[$mime]) && in_array($ext, $allowed[$mime])){ - $uDir = __DIR__ . '/../../uploads/org_proofs'; - if(!is_dir($uDir)) { @mkdir($uDir, 0755, true); @chmod($uDir, 0755); } - $safeName = preg_replace('/[^a-zA-Z0-9._-]/','_', basename($file['name'])); - $dst = $uDir . '/' . time() . '_' . bin2hex(random_bytes(6)) . '_' . $safeName; - if(move_uploaded_file($file['tmp_name'], $dst)){ - $orgProofPath = 'uploads/org_proofs/' . basename($dst); - try{ $up = $conn->prepare('UPDATE users SET org_proof_path = ? WHERE id = ?'); if($up){ $up->bind_param('si', $orgProofPath, $newId); $up->execute(); $up->close(); } }catch(Exception $e){} - } + $safeName = preg_replace('/[^a-zA-Z0-9._-]/','_', basename($file['name'])); + $dst = $uDir . '/' . time() . '_' . bin2hex(random_bytes(6)) . '_' . $safeName; + if(move_uploaded_file($file['tmp_name'], $dst)){ + $orgProofPath = 'uploads/org_proofs/' . basename($dst); + try{ $up = $conn->prepare('UPDATE users SET org_proof_path = ? WHERE id = ?'); if($up){ $up->bind_param('si', $orgProofPath, $newId); $up->execute(); $up->close(); } }catch(Exception $e){} } } diff --git a/poverty-mapping/src/api/auth/send_verification_code.php b/poverty-mapping/src/api/auth/send_verification_code.php index b1ed457..6b06387 100644 --- a/poverty-mapping/src/api/auth/send_verification_code.php +++ b/poverty-mapping/src/api/auth/send_verification_code.php @@ -22,24 +22,91 @@ if(!$email || !filter_var($email, FILTER_VALIDATE_EMAIL)){ exit; } +// Rate limiting table creation and validation +try { + $conn->query("CREATE TABLE IF NOT EXISTS verification_attempts ( + email VARCHAR(255) PRIMARY KEY, + attempts INT NOT NULL DEFAULT 0, + last_attempt_at DATETIME NOT NULL, + locked_until DATETIME NULL + )"); +} catch (Exception $e) {} + +$nowStr = date('Y-m-d H:i:s'); +$nowTime = time(); + +$stmt = $conn->prepare("SELECT attempts, last_attempt_at, locked_until FROM verification_attempts WHERE email = ?"); +$stmt->bind_param('s', $email); +$stmt->execute(); +$res = $stmt->get_result(); +$row = $res ? $res->fetch_assoc() : null; +$stmt->close(); + +if($row){ + $attempts = intval($row['attempts']); + $lastAttempt = strtotime($row['last_attempt_at']); + $lockedUntil = $row['locked_until'] ? strtotime($row['locked_until']) : null; + + if($lockedUntil && $nowTime < $lockedUntil){ + $diff = $lockedUntil - $nowTime; + $hours = floor($diff / 3600); + $mins = ceil(($diff % 3600) / 60); + http_response_code(429); + echo json_encode([ + 'success' => false, + 'error' => 'locked_24h', + 'message' => "Batas pengiriman kode tercapai. Anda harus menunggu {$hours} jam {$mins} menit.", + 'locked_until' => $lockedUntil * 1000 + ]); + exit; + } + + if($nowTime - $lastAttempt < 30){ + $remaining = 30 - ($nowTime - $lastAttempt); + http_response_code(429); + echo json_encode([ + 'success' => false, + 'error' => 'cooldown_active', + 'message' => "Silakan tunggu {$remaining} detik sebelum mengirim kembali.", + 'remaining' => $remaining + ]); + exit; + } +} + // Generate 6-digit code $verificationCode = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT); $codeHash = hash('sha256', $verificationCode); -// Store in session-like temp file (can use Redis/cache in production) -// For now, we'll return it and store in frontend state -// But also can check email format is valid - +$mailSuccess = false; try{ $subject = 'Kode Verifikasi Email - WebGIS'; $message = "Kode verifikasi Anda adalah:\n\n" . $verificationCode . "\n\nKode ini berlaku selama 15 menit. Jangan bagikan kode ini kepada siapapun.\n\nJika Anda tidak melakukan pendaftaran ini, abaikan email ini."; - send_mail_notify($email, $subject, $message, false); + $mailSuccess = send_mail_notify($email, $subject, $message, false); }catch(Exception $e){ - // Email send failed but still return code for testing + error_log('Mailing exception: ' . $e->getMessage()); +} + +// Update/Insert attempts on database +if($row){ + $newAttempts = ($row['locked_until'] && $nowTime >= strtotime($row['locked_until'])) ? 1 : ($row['attempts'] + 1); + $newLockedUntil = ($newAttempts >= 3) ? date('Y-m-d H:i:s', $nowTime + 24 * 3600) : null; + + $up = $conn->prepare("UPDATE verification_attempts SET attempts = ?, last_attempt_at = ?, locked_until = ? WHERE email = ?"); + $up->bind_param('isss', $newAttempts, $nowStr, $newLockedUntil, $email); + $up->execute(); + $up->close(); +} else { + $one = 1; + $nullVal = null; + $ins = $conn->prepare("INSERT INTO verification_attempts (email, attempts, last_attempt_at, locked_until) VALUES (?, ?, ?, ?)"); + $ins->bind_param('siss', $email, $one, $nowStr, $nullVal); + $ins->execute(); + $ins->close(); } $response = ['success'=>true,'email'=>$email,'message'=>'code_sent']; -if(getenv('DEBUG_MODE') === '1' || $_SERVER['REMOTE_ADDR'] === '127.0.0.1'){ +if(getenv('DEBUG_MODE') === '1' || $_SERVER['REMOTE_ADDR'] === '127.0.0.1' || !$mailSuccess){ $response['debug_code'] = $verificationCode; } echo json_encode($response); diff --git a/poverty-mapping/src/config/config.php b/poverty-mapping/src/config/config.php index 70ceb30..ac476f4 100644 --- a/poverty-mapping/src/config/config.php +++ b/poverty-mapping/src/config/config.php @@ -12,7 +12,7 @@ function load_dotenv($path){ if((substr($v,0,1)==='"' && substr($v,-1)==='"') || (substr($v,0,1)==="'" && substr($v,-1)==="'")){ $v = substr($v,1,-1); } - if(getenv($k) === false){ + if(getenv($k) === false || getenv($k) === ''){ putenv("$k=$v"); $_ENV[$k] = $v; } diff --git a/tugas-spbu/src/config/config.php b/tugas-spbu/src/config/config.php index 5353a3f..e223f78 100644 --- a/tugas-spbu/src/config/config.php +++ b/tugas-spbu/src/config/config.php @@ -12,7 +12,7 @@ function load_dotenv($path){ if((substr($v,0,1)==='"' && substr($v,-1)==='"') || (substr($v,0,1)==="'" && substr($v,-1)==="'")){ $v = substr($v,1,-1); } - if(getenv($k) === false){ + if(getenv($k) === false || getenv($k) === ''){ putenv("$k=$v"); $_ENV[$k] = $v; }