PHP WebShell

Текущая директория: /var/www/bitcardoApp/auth

Просмотр файла: challenge.php

<?php
// auth/challenge.php — Delightful UX: TOTP first, Email OTP fallback, resend cooldown
require_once __DIR__ . '/../config/bootstrap.php';
if (!defined('OTP_DEV_MODE')) {
  define('OTP_DEV_MODE', true); // <-- you can omit this entirely; bootstrap already provides it
}

// Optional libs if present
$libDevice = __DIR__ . '/../lib/device.php';
if (file_exists($libDevice)) require_once $libDevice;
$libTotp = __DIR__ . '/../lib/totp.php';
if (file_exists($libTotp)) require_once $libTotp;

// [ADDED: RL BOOTSTRAP] -------------------------------------------
$rateLib  = __DIR__ . '/../lib/rate_limit.php';
$rlActive = false;
if (file_exists($rateLib)) {
  require_once $rateLib;
  $rlActive = function_exists('is_enabled') ? is_enabled('rate_limit_enabled', true) : true;

  // tokens for OTP/TOTP throttling
  $ipToken  = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
  // we’ll bucket by user to stop rapid attempts on one account
  $usrToken = 'user_' . (int)($_SESSION['user_id'] ?? 0);

  // policy (OTP stricter than login)
  $OTP_SEND_LIMIT       = 5;   // sends per 15m per user
  $OTP_SEND_WINDOW_SEC  = 900;
  $OTP_SEND_LOCK_SEC    = 900;

  $OTP_VERIFY_LIMIT     = 8;   // wrong OTP attempts per 15m per user
  $OTP_VERIFY_WINDOW_SEC= 900;
  $OTP_VERIFY_LOCK_SEC  = 900;

  $TOTP_VERIFY_LIMIT      = 10; // totp guesses per 15m per user
  $TOTP_VERIFY_WINDOW_SEC = 900;
  $TOTP_VERIFY_LOCK_SEC   = 900;
}
// ---------------------------------------------------------------

// Guards
if (empty($_SESSION['user_id'])) { header('Location: /auth/login.php'); exit; }

$userId    = (int)$_SESSION['user_id'];
$userEmail = $_SESSION['email'] ?? '';
$otpOn     = function_exists('is_enabled') ? is_enabled('otp_enabled', true) : true;
$emailOn   = function_exists('is_enabled') ? is_enabled('email_otp', true)   : true;
$totpOn    = function_exists('is_enabled') ? is_enabled('totp_enabled', true) : true;
$trustDays = (int)(function_exists('get_settings') ? get_settings('otp_trust_days', 30) : 30);

if (!$otpOn) { header('Location: /user/dashboard/index.php'); exit; }

// ----- Config: resend cooldown seconds -----
$cooldownSec = 60;

// ----- Helpers -----
function page_redirect($url){ header('Location: '.$url); exit; }
function send_email_simple($to, $subject, $bodyHtml) {
  // Try PHPMailer if present, else mail()
  if (class_exists('PHPMailer\PHPMailer\PHPMailer')) {
    $mail = new PHPMailer\PHPMailer\PHPMailer(true);
    try {
      if (defined('SMTP_HOST') && SMTP_HOST) {
        $mail->isSMTP();
        $mail->Host       = SMTP_HOST;
        $mail->Port       = defined('SMTP_PORT') ? SMTP_PORT : 587;
        $mail->SMTPAuth   = (defined('SMTP_USER') && SMTP_USER) || (defined('SMTP_PASS') && SMTP_PASS);
        if ($mail->SMTPAuth) {
          $mail->Username = defined('SMTP_USER') ? SMTP_USER : '';
          $mail->Password = defined('SMTP_PASS') ? SMTP_PASS : '';
        }
        if (defined('SMTP_SECURE') && SMTP_SECURE) {
          $mail->SMTPSecure = SMTP_SECURE; // 'tls' or 'ssl'
        }
      }
      $from = defined('SMTP_FROM') ? SMTP_FROM : 'no-reply@bitcardo.com';
      $fromName = defined('SMTP_FROM_NAME') ? SMTP_FROM_NAME : 'Bitcardo';
      $mail->setFrom($from, $fromName);
      $mail->addAddress($to);
      $mail->isHTML(true);
      $mail->Subject = $subject;
      $mail->Body    = $bodyHtml;
      $mail->AltBody = strip_tags($bodyHtml);
      $mail->send();
      return true;
    } catch (\Throwable $e) {
      error_log('[EMAIL] PHPMailer send failed: '.$e->getMessage());
    }
  }
  // Fallback: native mail()
  $headers  = "MIME-Version: 1.0\r\n";
  $headers .= "Content-type: text/html; charset=UTF-8\r\n";
  $from = defined('SMTP_FROM') ? SMTP_FROM : 'no-reply@bitcardo.com';
  $fromName = defined('SMTP_FROM_NAME') ? SMTP_FROM_NAME : 'Bitcardo';
  $headers .= "From: {$fromName} <{$from}>\r\n";
  $ok = @mail($to, $subject, $bodyHtml, $headers);
  if (!$ok) error_log('[EMAIL] mail() returned false. Check server mail config.');
  return $ok;
}

function otp_can_send_again(mysqli $conn, int $userId, string $channel='email', int $cooldownSec=60): bool {
  $stmt = $conn->prepare("SELECT created_at FROM user_otps WHERE user_id=? AND channel=? AND consumed_at IS NULL ORDER BY uotp_id DESC LIMIT 1");
  $stmt->bind_param('is', $userId, $channel);
  $stmt->execute();
  $stmt->bind_result($createdAt);
  if ($stmt->fetch()) {
    $stmt->close();
    if ($createdAt) {
      $last = new DateTimeImmutable($createdAt);
      return (time() - $last->getTimestamp()) >= $cooldownSec;
    }
  } else { $stmt->close(); }
  return true;
}

function otp_seconds_until_send(mysqli $conn, int $userId, string $channel='email', int $cooldownSec=60): int {
  $stmt = $conn->prepare("SELECT created_at FROM user_otps WHERE user_id=? AND channel=? AND consumed_at IS NULL ORDER BY uotp_id DESC LIMIT 1");
  $stmt->bind_param('is', $userId, $channel);
  $stmt->execute();
  $stmt->bind_result($createdAt);
  if ($stmt->fetch()) {
    $stmt->close();
    if ($createdAt) {
      $last = new DateTimeImmutable($createdAt);
      $remain = $cooldownSec - (time() - $last->getTimestamp());
      return max(0, (int)$remain);
    }
  } else { $stmt->close(); }
  return 0;
}

function otp_issue_and_send(mysqli $conn, int $userId, string $email): bool {
  // Invalidate previous unconsumed codes (optional)
  $conn->query("UPDATE user_otps SET consumed_at=NOW() WHERE user_id={$userId} AND channel='email' AND consumed_at IS NULL");

  $code = str_pad((string)random_int(0, 999999), 6, '0', STR_PAD_LEFT);
  $selector = bin2hex(random_bytes(12));
  $hash = hash('sha256', $code);
  $ip = $_SERVER['REMOTE_ADDR'] ?? '';
  $expiry = (new DateTimeImmutable('+10 minutes'))->format('Y-m-d H:i:s');

  $stmt = $conn->prepare("INSERT INTO user_otps (user_id, channel, selector, token_hash, expires_at, ip) VALUES (?, 'email', ?, ?, ?, ?)");
  if (!$stmt) { error_log('[OTP] prepare failed: '.$conn->error); return false; }
  $stmt->bind_param('issss', $userId, $selector, $hash, $expiry, $ip);
  $ok = $stmt->execute();
  if (!$ok) { error_log('[OTP] execute failed: '.$conn->error); $stmt->close(); return false; }
  $stmt->close();

  // Polished email template
  $brand = defined('SMTP_FROM_NAME') ? SMTP_FROM_NAME : 'Bitcardo';
  $year  = date('Y');
  $codeSpaced = implode(' ', str_split($code, 3));
  $html = <<<HTML
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f6f8fb;padding:24px 0;">
  <tr>
    <td align="center">
      <table role="presentation" width="560" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;border:1px solid #e8eef3;box-shadow:0 4px 18px rgba(7,98,137,.06);font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;">
        <tr><td style="padding:24px 24px 0 24px;text-align:center;">
          <div style="display:inline-block;background:#076289;color:#fff;font-weight:700;padding:8px 14px;border-radius:999px;">$brand Security</div>
        </td></tr>
        <tr><td style="padding:16px 24px 4px 24px;text-align:center;">
          <h1 style="margin:0;font-size:20px;color:#0f172a;">Verify it’s you</h1>
          <p style="margin:8px 0 0 0;color:#334155;font-size:14px;line-height:1.5">Use the code below to finish signing in. This keeps your account and funds protected.</p>
        </td></tr>
        <tr><td style="padding:8px 24px 24px 24px;text-align:center;">
          <div style="display:inline-block;border:2px dashed #076289;border-radius:12px;padding:14px 18px;">
            <div style="font-size:28px;font-weight:800;letter-spacing:4px;color:#0f172a;">$codeSpaced</div>
            <div style="color:#64748b;font-size:12px;margin-top:6px;">Expires in 10 minutes</div>
          </div>
        </td></tr>
        <tr><td style="padding:0 24px 24px 24px;text-align:center;">
          <div style="color:#475569;font-size:13px;line-height:1.6">
            Didn’t request this? You can safely ignore this email.
          </div>
        </td></tr>
        <tr><td style="padding:16px 24px 24px 24px;border-top:1px solid #e8eef3;text-align:center;color:#94a3b8;font-size:12px;">
          &copy; $year $brand
        </td></tr>
      </table>
    </td>
  </tr>
</table>
HTML;

  $sentOk = send_email_simple($email, "{$brand} Verification Code", $html);

  if (!$sentOk && defined('OTP_DEV_MODE') && OTP_DEV_MODE) {
    $_SESSION['__DEV_OTP_CODE__'] = $code;
    error_log('[OTP] DEV_MODE enabled — exposing OTP on page for testing.');
    return true;
  }

  return $sentOk;
}

function otp_verify(mysqli $conn, int $userId, string $code): bool {
  $code = preg_replace('/\D+/', '', $code);
  if (strlen($code) !== 6) return false;

  $stmt = $conn->prepare("SELECT uotp_id, token_hash, expires_at, attempts FROM user_otps
                          WHERE user_id=? AND channel='email' AND consumed_at IS NULL
                          ORDER BY uotp_id DESC LIMIT 1");
  $stmt->bind_param('i', $userId);
  $stmt->execute();
  $stmt->bind_result($id, $tokenHash, $expiresAt, $attempts);
  if (!$stmt->fetch()) { $stmt->close(); return false; }
  $stmt->close();

  if (new DateTimeImmutable($expiresAt) < new DateTimeImmutable()) {
    $u = $conn->prepare("UPDATE user_otps SET consumed_at=NOW() WHERE uotp_id=?");
    $u->bind_param('i', $id); $u->execute(); $u->close();
    return false;
  }

  $ok = hash_equals($tokenHash ?? '', hash('sha256', $code));
  $a = $conn->prepare("UPDATE user_otps SET attempts=LEAST(attempts+1, 250) WHERE uotp_id=?");
  $a->bind_param('i', $id); $a->execute(); $a->close();

  if ($ok) {
    $c = $conn->prepare("UPDATE user_otps SET consumed_at=NOW() WHERE uotp_id=?");
    $c->bind_param('i', $id); $c->execute(); $c->close();
    return true;
  }
  return false;
}

// ----- Determine TOTP status -----
$hasTotp = false; $secret = null;
if ($totpOn && file_exists($libTotp)) {
  $stmt = $conn->prepare("SELECT secret_base32, enabled FROM user_totp WHERE user_id=? LIMIT 1");
  $stmt->bind_param('i', $userId);
  $stmt->execute();
  $stmt->bind_result($secret, $enabled);
  if ($stmt->fetch() && $enabled) $hasTotp = true;
  $stmt->close();
}

// ----- Handle POST (TOTP or Email OTP) -----
$error = '';
$sent  = false;

// Calculate initial cooldown (if a code was sent recently)
$cooldownRemain = $emailOn && !$hasTotp ? otp_seconds_until_send($conn, $userId, 'email', $cooldownSec) : 0;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {

  // TOTP path
  if (isset($_POST['totp']) && $hasTotp && file_exists($libTotp)) {
    // [ADDED: CSRF + RL]
    csrf_verify_or_fail($_POST['csrf'] ?? null, '/auth/login.php');
    if ($rlActive && function_exists('rl_check_and_inc')) {
      $r = rl_check_and_inc($conn, 'totp_verify', $usrToken, $TOTP_VERIFY_LIMIT, $TOTP_VERIFY_WINDOW_SEC, $TOTP_VERIFY_LOCK_SEC);
      if (!$r['ok']) {
        $error = "Too many attempts. Try again in {$r['locked_for']}s.";
      }
    }

    if (!$error) {
      $code = preg_replace('/\D+/', '', $_POST['totp'] ?? '');
      if ($secret && function_exists('totp_verify') && totp_verify($secret, $code, 1)) {
        if ($rlActive && function_exists('rl_reset')) rl_reset($conn, 'totp_verify', $usrToken);
        if (function_exists('device_mark_trusted')) device_mark_trusted($conn, $userId, $trustDays);
        page_redirect('/user/dashboard/index.php');
      } else {
        $error = 'Invalid authenticator code. Try again.';
      }
    }
  }

  // Email OTP send
  if (isset($_POST['send_email_code']) && $emailOn && !$hasTotp) {
    // [ADDED: CSRF + RL]
    csrf_verify_or_fail($_POST['csrf'] ?? null, '/auth/login.php');
    if ($rlActive && function_exists('rl_check_and_inc')) {
      $r = rl_check_and_inc($conn, 'otp_send', $usrToken, $OTP_SEND_LIMIT, $OTP_SEND_WINDOW_SEC, $OTP_SEND_LOCK_SEC);
      if (!$r['ok']) {
        $error = "Too many code requests. Try again in {$r['locked_for']}s.";
      }
    }

    if (!$error) {
      if (!$userEmail) {
        $error = 'No email on file for your account.';
      } else if (!otp_can_send_again($conn, $userId, 'email', $cooldownSec)) {
        $error = 'Please wait a moment before requesting a new code.';
        $cooldownRemain = otp_seconds_until_send($conn, $userId, 'email', $cooldownSec);
      } else {
        if (otp_issue_and_send($conn, $userId, $userEmail)) {
          $sent = true;
          $cooldownRemain = otp_seconds_until_send($conn, $userId, 'email', $cooldownSec);
          if ($cooldownRemain <= 0) $cooldownRemain = $cooldownSec;
        } else {
          $error = 'Could not send code right now. Please try again.';
        }
      }
    }
  }

  // Email OTP verify
  if (isset($_POST['email_code']) && $emailOn && !$hasTotp) {
    // [ADDED: CSRF + RL]
    csrf_verify_or_fail($_POST['csrf'] ?? null, '/auth/login.php');
    if ($rlActive && function_exists('rl_check_and_inc')) {
      $r = rl_check_and_inc($conn, 'otp_verify', $usrToken, $OTP_VERIFY_LIMIT, $OTP_VERIFY_WINDOW_SEC, $OTP_VERIFY_LOCK_SEC);
      if (!$r['ok']) {
        $error = "Too many attempts. Try again in {$r['locked_for']}s.";
      }
    }

    if (!$error) {
      $code = $_POST['email_code'] ?? '';
      if (otp_verify($conn, $userId, $code)) {
        if ($rlActive && function_exists('rl_reset')) rl_reset($conn, 'otp_verify', $usrToken);
        if (function_exists('device_mark_trusted')) device_mark_trusted($conn, $userId, $trustDays);
        page_redirect('/user/dashboard/index.php');
      } else {
        $error = 'Invalid or expired code. Request a new one and try again.';
        $cooldownRemain = otp_seconds_until_send($conn, $userId, 'email', $cooldownSec);
      }
    }
  }
}

// ======================= VIEW (polished & reassuring) =======================
include __DIR__ . '/header.php';
?>
<style>
  /* Soft gradient background ribbon */
  .secure-bg {
    background: radial-gradient(1200px 400px at 50% -150px, rgba(7,98,137,.18), transparent 60%),
                linear-gradient(180deg, rgba(7,98,137,.06), transparent 40%);
  }

  /* Card */
  .secure-card {
    border: 1px solid rgba(7,98,137,.12);
    border-radius: 16px;
    box-shadow: 0 10px 30px rgba(7,98,137,.08);
    background: #fff;
  }

  /* Animated shield */
  .shield-wrap { width: 84px; height: 84px; margin: 0 auto 8px; position: relative; }
  .shield-pulse {
    position: absolute; inset: -6px;
    border-radius: 50%;
    background: radial-gradient(circle, rgba(7,98,137,.15), transparent 60%);
    animation: pulse 2.2s ease-in-out infinite;
  }
  @keyframes pulse { 0% { opacity:.8; transform: scale(.9); } 50% { opacity:.35; transform: scale(1.05);} 100% { opacity:.8; transform: scale(.9);} }
  .shield {
    width: 84px; height: 84px; border-radius: 22px;
    background: linear-gradient(160deg, #0a7bab, #065c80);
    display: grid; place-items: center; color: #fff;
    box-shadow: 0 8px 18px rgba(7,98,137,.25);
  }
  .lock { width: 36px; height: 36px; animation: breathe 2.2s ease-in-out infinite; }
  @keyframes breathe { 0%{ transform: translateY(0);} 50%{ transform: translateY(-2px);} 100%{ transform: translateY(0);} }

  .steps { display:flex; gap:10px; justify-content:center; margin: 10px 0 16px;}
  .step-dot { width:8px;height:8px;border-radius:50%; background:#d6e7ef; }
  .step-dot.active { background:#076289; }
  .step-label { font-size:12px; color:#6b7280; text-align:center; margin-top:-6px; }

  .secure-input { padding:10px 12px; border:1px solid #e5eaee; border-radius:10px; }
  .secure-input:focus { border-color:#0a7bab; box-shadow: 0 0 0 3px rgba(10,123,171,.12); }

  .btn-secure-primary{
    background:#076289; border-color:#076289; color:#fff !important;
  }
  .btn-secure-primary:hover, .btn-secure-primary:focus{
    background:#fff; border-color:#076289; color:#076289 !important; box-shadow:0 0 0 3px rgba(7,98,137,.12);
  }
  .btn-rounded { border-radius: 999px; }
  .muted { color:#6b7280; }
  .dev-badge { background:#fff3cd;border:1px solid #ffeeba;color:#856404;border-radius:10px;padding:8px 10px; }
</style>

<div class="secure-bg">
  <div class="container">
    <div class="offset-md-4 col-md-4 pt-5">
      <div class="form-signin text-center mt-0 pt-3 px-3 secure-card">

        <div class="shield-wrap mt-3">
          <div class="shield-pulse" aria-hidden="true"></div>
          <div class="shield" aria-hidden="true">
            <svg class="lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
              <rect x="4" y="10" width="16" height="10" rx="2" ry="2" fill="currentColor" opacity=".18"></rect>
              <path d="M8 10V7a4 4 0 0 1 8 0v3" stroke="#fff"/>
              <circle cx="12" cy="15" r="1.6" fill="#fff"></circle>
              <path d="M12 16.6v1.8" stroke="#fff"></path>
            </svg>
          </div>
        </div>

        <h2 class="mb-1 fw-semibold">Security Check</h2>
        <p class="muted mb-1">
          Protecting your money is our first priority. <br> This quick step keeps your Bitcardo account safe.
        </p>

        <div class="steps" role="group" aria-label="Verification progress">
          <span class="step-dot active"></span>
          <span class="step-dot"></span>
          <span class="step-dot"></span>
        </div>
        <div class="step-label">Step 1 of 3 &mdash; Verify it’s you</div>

        <?php if (!empty($error)): ?>
          <div class="alert alert-danger py-2 mt-3 mx-2"><?= htmlspecialchars($error) ?></div>
        <?php endif; ?>

        <div class="px-2 pb-3 pt-1">
          <?php if ($hasTotp && $totpOn && file_exists($libTotp)): ?>
            <p class="muted">Open your authenticator app and enter the 6-digit code.</p>
            <form method="post" class="text-start mt-2">
              <input type="hidden" name="csrf" value="<?= htmlspecialchars(csrf_token()) ?>">
              <label for="totp" class="form-label ms-1">Authenticator code</label>
              <input name="totp" id="totp" class="form-control secure-input" inputmode="numeric" autocomplete="one-time-code" required>
              <button type="submit" class="w-100 btn btn-secure-primary btn-rounded btn-lg mt-3">Verify & continue</button>
            </form>
            <p class="mt-3"><small><a href="/security/totp/setup.php" class="text-decoration-none">Manage TOTP &amp; backup codes</a></small></p>

          <?php elseif ($emailOn): ?>
            <?php if (!empty($_SESSION['__DEV_OTP_CODE__'])): ?>
              <div class="dev-badge text-start mx-1 mt-2">
                <strong>DEV ONLY</strong>: OTP is
                <code><?= htmlspecialchars($_SESSION['__DEV_OTP_CODE__']) ?></code>
              </div>
              <?php unset($_SESSION['__DEV_OTP_CODE__']); ?>
            <?php endif; ?>

            <?php if (empty($sent) && $cooldownRemain === 0): ?>
              <div class="muted mt-2">We’ll send a one-time code to <strong><?= htmlspecialchars($userEmail) ?></strong>.</div>
              <form method="post" class="mt-3">
                <input type="hidden" name="csrf" value="<?= htmlspecialchars(csrf_token()) ?>">
                <input type="hidden" name="send_email_code" value="1">
                <button type="submit" id="sendBtn" class="w-100 btn btn-secure-primary btn-rounded btn-lg">
                  Send verification code
                </button>
              </form>
              <p class="muted mt-2" style="font-size:12px;">It’s fast and keeps your funds protected.</p>

            <?php else: ?>
              <div class="alert alert-primary text-start mt-3 mx-1">
                Code sent to <strong><?= htmlspecialchars($userEmail) ?></strong>. <br>Expires in 10 minutes.
              </div>

              <form method="post" id="verifyForm" class="text-start mt-2">
                <input type="hidden" name="csrf" value="<?= htmlspecialchars(csrf_token()) ?>">
                <label for="email_code" class="form-label ms-1">Enter 6-digit code</label>
                <input name="email_code" id="email_code" class="form-control secure-input" inputmode="numeric" autocomplete="one-time-code" required>
                <button type="submit" class="w-100 btn btn-secure-primary btn-rounded btn-lg mt-3">Verify & continue</button>
              </form>

              <form method="post" id="resendForm" class="text-center mt-2">
                <input type="hidden" name="csrf" value="<?= htmlspecialchars(csrf_token()) ?>">
                <input type="hidden" name="send_email_code" value="1">
                <button type="submit" id="resendBtn" class="btn btn-light"<?= $cooldownRemain > 0 ? ' disabled' : '' ?>>
                  Resend code
                </button>
                <span id="cooldownText" class="text-muted" style="<?= $cooldownRemain > 0 ? '' : 'display:none;' ?>">
                  (wait <span id="cooldownNum"><?= (int)$cooldownRemain ?></span>s)
                </span>
              </form>

              <script>
                (function(){
                  var remain = <?= (int)$cooldownRemain ?>;
                  var btn    = document.getElementById('resendBtn');
                  var text   = document.getElementById('cooldownText');
                  var num    = document.getElementById('cooldownNum');

                  if (btn && text && num && remain > 0) {
                    btn.setAttribute('disabled','disabled');
                    text.style.display = '';
                    num.textContent = remain;

                    var t = setInterval(function(){
                      remain--;
                      if (remain <= 0) {
                        clearInterval(t);
                        btn.removeAttribute('disabled');
                        text.style.display = 'none';
                        num.textContent = '0';
                      } else {
                        num.textContent = remain;
                      }
                    }, 1000);
                  }
                })();
              </script>
            <?php endif; ?>

          <?php else: ?>
            <div class="alert alert-secondary text-start mx-1 mt-2">
              Verification is required, but no challenge methods are enabled for your account.
            </div>
            <a href="/user/dashboard/index.php" class="btn btn-secondary btn-rounded mt-2">Back to dashboard</a>
          <?php endif; ?>
        </div>

        <div class="muted pb-4" style="font-size:12px;">
          Secured by Bitcardo • Bank-grade encryption • You’re in control
        </div>
      </div>
    </div>
  </div>
</div>

<?php include __DIR__ . '/footer.php'; ?>

Выполнить команду


Для локальной разработки. Не используйте в интернете!