mirror of
https://github.com/token2/TOTPVault.git
synced 2026-03-13 11:16:00 -07:00
323 lines
11 KiB
PHP
323 lines
11 KiB
PHP
<?php
|
|
// public/index.php — Front controller / router
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
$uri = strtok(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '?');
|
|
|
|
|
|
|
|
error_reporting(E_ALL);
|
|
ini_set('display_errors', '1');
|
|
ini_set('log_errors', '1');
|
|
|
|
define('BASE_DIR', __DIR__);
|
|
|
|
require_once BASE_DIR . '/src/Database.php';
|
|
require_once BASE_DIR . '/src/Crypto.php';
|
|
require_once BASE_DIR . '/src/TOTP.php';
|
|
require_once BASE_DIR . '/src/Auth.php';
|
|
require_once BASE_DIR . '/src/OAuthP.php';
|
|
require_once BASE_DIR . '/src/Profile.php';
|
|
require_once BASE_DIR . '/src/MagicLink.php';
|
|
|
|
// Start session early
|
|
session_set_cookie_params(['httponly'=>true,'samesite'=>'Lax','secure'=>isset($_SERVER['HTTPS'])]);
|
|
session_start();
|
|
|
|
|
|
|
|
|
|
|
|
// Helpers
|
|
function jsonResponse(mixed $data, int $code = 200): never {
|
|
http_response_code($code);
|
|
header('Content-Type: application/json');
|
|
echo json_encode($data);
|
|
exit;
|
|
}
|
|
|
|
function redirect(string $url): never {
|
|
$base = rtrim((require __DIR__ . '/config/config.php')['app_url'], '/');
|
|
if (str_starts_with($url, '/')) {
|
|
$url = $base . $url;
|
|
}
|
|
header('Location: ' . $url);
|
|
exit;
|
|
}
|
|
|
|
function view(string $tpl, array $vars = []): never {
|
|
extract($vars);
|
|
require BASE_DIR . '/templates/' . $tpl . '.php';
|
|
exit;
|
|
}
|
|
|
|
function csrf_token(): string {
|
|
if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(32));
|
|
return $_SESSION['csrf'];
|
|
}
|
|
|
|
function verify_csrf(): void {
|
|
$token = $_POST['_csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
|
if (!hash_equals($_SESSION['csrf'] ?? '', $token)) {
|
|
jsonResponse(['error' => 'Invalid CSRF token'], 403);
|
|
}
|
|
}
|
|
|
|
// ── Routing ────────────────────────────────────────────────────────────────
|
|
|
|
$method = $_SERVER['REQUEST_METHOD'];
|
|
$uri = strtok(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '?');
|
|
$auth = new Auth();
|
|
$user = $auth->currentUser();
|
|
|
|
// ── Public routes ──────────────────────────────────────────────────────────
|
|
|
|
if ($uri === '/' && $method === 'GET') {
|
|
if ($user) redirect('/dashboard');
|
|
view('landing', ['user' => null]);
|
|
}
|
|
|
|
|
|
// ── Magic link: request ────────────────────────────────────────────────────
|
|
|
|
if ($uri === '/auth/magic-request' && $method === 'POST') {
|
|
$email = strtolower(trim($_POST['email'] ?? ''));
|
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|
redirect('/?error=' . urlencode('Please enter a valid email address.'));
|
|
}
|
|
try {
|
|
$magicLink = new MagicLink();
|
|
$url = $magicLink->create($email);
|
|
$subject = 'Your sign-in link for OTP Vault';
|
|
$body = "Sign in to OTP Vault\n\nClick this link (expires in 15 minutes):\n{$url}\n\nIf you didn't request this, ignore this email.";
|
|
|
|
|
|
sendMail($email, $subject, $body);
|
|
} catch (RuntimeException $e) {
|
|
redirect('/?error=' . urlencode($e->getMessage()));
|
|
}
|
|
redirect('/?magic=sent&email=' . urlencode($email));
|
|
}
|
|
|
|
// ── Magic link: verify ─────────────────────────────────────────────────────
|
|
|
|
if ($uri === '/auth/magic' && $method === 'GET') {
|
|
$token = $_GET['token'] ?? '';
|
|
$email = $_GET['email'] ?? '';
|
|
try {
|
|
$magicLink = new MagicLink();
|
|
$verifiedEmail = $magicLink->verify($token, $email);
|
|
$dbUser = $magicLink->findOrCreateUser($verifiedEmail, $auth);
|
|
$auth->loginUser($dbUser['id']);
|
|
redirect('/dashboard');
|
|
} catch (RuntimeException $e) {
|
|
redirect('/?error=' . urlencode($e->getMessage()));
|
|
}
|
|
}
|
|
|
|
|
|
// ── OAuth ──────────────────────────────────────────────────────────────────
|
|
|
|
if (preg_match('#^/auth/login/(\w+)$#', $uri, $m) && $method === 'GET') {
|
|
$provider = $m[1];
|
|
try {
|
|
$oauth = new OAuthP($provider);
|
|
redirect($oauth->getAuthUrl());
|
|
} catch (Exception $e) {
|
|
redirect('/?error=' . urlencode($e->getMessage()));
|
|
}
|
|
}
|
|
|
|
if (preg_match('#^/auth/callback/(\w+)$#', $uri, $m) && $method === 'GET') {
|
|
$provider = $m[1];
|
|
try {
|
|
if (!empty($_GET['error'])) throw new RuntimeException($_GET['error_description'] ?? $_GET['error']);
|
|
$oauth = new OAuthP($provider);
|
|
$userData = $oauth->handleCallback($_GET['code'] ?? '', $_GET['state'] ?? '');
|
|
$dbUser = $auth->findOrCreateUser($userData);
|
|
$auth->loginUser($dbUser['id']);
|
|
redirect('/dashboard');
|
|
} catch (Exception $e) {
|
|
redirect('/?error=' . urlencode($e->getMessage()));
|
|
}
|
|
}
|
|
|
|
|
|
function sendMail(string $to, string $subject, string $body): bool {
|
|
$config = require __DIR__ . '/config/config.php';
|
|
$mail = $config['mail'];
|
|
|
|
|
|
$apiKey = $mail['mailersend_key'];
|
|
$from = $mail['from_email'];
|
|
$fromName = $mail['from_name'] ;
|
|
|
|
$payload = json_encode([
|
|
'from' => ['email' => $from, 'name' => $fromName],
|
|
'to' => [['email' => $to]],
|
|
'subject' => $subject,
|
|
'text' => $body,
|
|
]);
|
|
|
|
$ch = curl_init('https://api.mailersend.com/v1/email');
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => $payload,
|
|
CURLOPT_HTTPHEADER => [
|
|
'Authorization: Bearer ' . $apiKey,
|
|
'Content-Type: application/json',
|
|
'Accept: application/json',
|
|
],
|
|
CURLOPT_TIMEOUT => 15,
|
|
]);
|
|
$response = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$error = curl_error($ch);
|
|
curl_close($ch);
|
|
|
|
if ($error) {
|
|
error_log("MailerSend cURL error: {$error}");
|
|
return false;
|
|
}
|
|
if ($httpCode !== 202) {
|
|
error_log("MailerSend failed: HTTP {$httpCode} — {$response}");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
|
|
if ($uri === '/auth/logout' && $method === 'GET') {
|
|
$auth->logout();
|
|
redirect('/');
|
|
}
|
|
|
|
// ── Require login from here ────────────────────────────────────────────────
|
|
|
|
if (!$user) {
|
|
if ($method === 'GET') {
|
|
redirect('/');
|
|
}
|
|
jsonResponse(['error' => 'Unauthorized'], 401);
|
|
}
|
|
|
|
// ── Dashboard ──────────────────────────────────────────────────────────────
|
|
|
|
if ($uri === '/dashboard' && $method === 'GET') {
|
|
$profileSvc = new Profile();
|
|
$profiles = $profileSvc->listForUser($user['id']);
|
|
view('dashboard', ['user' => $user, 'profiles' => $profiles]);
|
|
}
|
|
|
|
//Import Tools
|
|
if (preg_match('#^/tools/([\w-]+)$#', $uri, $m)) {
|
|
$tool = $m[1];
|
|
$file = BASE_DIR . '/tools/' . $tool . '.php';
|
|
if (!file_exists($file)) {
|
|
http_response_code(404);
|
|
view('404', ['user' => $user]);
|
|
}
|
|
require $file;
|
|
exit;
|
|
}
|
|
|
|
|
|
// ── API: OTP ───────────────────────────────────────────────────────────────
|
|
|
|
if ($uri === '/api/otp' && $method === 'GET') {
|
|
$profileId = (int)($_GET['id'] ?? 0);
|
|
try {
|
|
$profileSvc = new Profile();
|
|
jsonResponse($profileSvc->generateOTP($profileId, $user['id']));
|
|
} catch (Exception $e) {
|
|
jsonResponse(['error' => $e->getMessage()], 403);
|
|
}
|
|
}
|
|
|
|
// ── API: Profiles ──────────────────────────────────────────────────────────
|
|
|
|
if ($uri === '/api/profiles' && $method === 'POST') {
|
|
verify_csrf();
|
|
$data = json_decode(file_get_contents('php://input'), true) ?? $_POST;
|
|
try {
|
|
$profileSvc = new Profile();
|
|
$id = $profileSvc->create($user['id'], $data);
|
|
jsonResponse(['success' => true, 'id' => $id]);
|
|
} catch (Exception $e) {
|
|
jsonResponse(['error' => $e->getMessage()], 400);
|
|
}
|
|
}
|
|
|
|
if (preg_match('#^/api/profiles/(\d+)$#', $uri, $m)) {
|
|
$profileId = (int)$m[1];
|
|
$profileSvc = new Profile();
|
|
|
|
if ($method === 'PUT') {
|
|
verify_csrf();
|
|
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
|
$ok = $profileSvc->update($profileId, $user['id'], $data);
|
|
jsonResponse(['success' => $ok]);
|
|
}
|
|
|
|
if ($method === 'DELETE') {
|
|
verify_csrf();
|
|
$ok = $profileSvc->delete($profileId, $user['id']);
|
|
jsonResponse(['success' => $ok]);
|
|
}
|
|
|
|
if ($method === 'GET') {
|
|
$profile = $profileSvc->get($profileId, $user['id']);
|
|
if (!$profile) jsonResponse(['error' => 'Not found'], 404);
|
|
unset($profile['secret_encrypted']);
|
|
jsonResponse($profile);
|
|
}
|
|
}
|
|
|
|
// ── API: Shares ────────────────────────────────────────────────────────────
|
|
|
|
if (preg_match('#^/api/profiles/(\d+)/shares$#', $uri, $m)) {
|
|
$profileId = (int)$m[1];
|
|
$profileSvc = new Profile();
|
|
|
|
if ($method === 'GET') {
|
|
jsonResponse($profileSvc->getShares($profileId, $user['id']));
|
|
}
|
|
|
|
if ($method === 'POST') {
|
|
verify_csrf();
|
|
$data = json_decode(file_get_contents('php://input'), true) ?? $_POST;
|
|
$email = trim(strtolower($data['email'] ?? ''));
|
|
$canEdit = !empty($data['can_edit']);
|
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|
jsonResponse(['error' => 'Invalid email address'], 400);
|
|
}
|
|
if ($email === strtolower($user['email'])) {
|
|
jsonResponse(['error' => 'You cannot share with yourself'], 400);
|
|
}
|
|
$ok = $profileSvc->share($profileId, $user['id'], $email, $canEdit);
|
|
jsonResponse(['success' => $ok]);
|
|
}
|
|
|
|
if ($method === 'DELETE') {
|
|
verify_csrf();
|
|
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
|
$email = trim(strtolower($data['email'] ?? ''));
|
|
$ok = $profileSvc->unshare($profileId, $user['id'], $email);
|
|
jsonResponse(['success' => $ok]);
|
|
}
|
|
}
|
|
|
|
// ── API: Generate secret ───────────────────────────────────────────────────
|
|
|
|
if ($uri === '/api/generate-secret' && $method === 'GET') {
|
|
jsonResponse(['secret' => TOTP::generateSecret()]);
|
|
}
|
|
|
|
// ── 404 ────────────────────────────────────────────────────────────────────
|
|
|
|
http_response_code(404);
|
|
view('404', ['user' => $user]);
|