246 lines
12 KiB
PHP
246 lines
12 KiB
PHP
<?php
|
|
require __DIR__ . '/session.php';
|
|
require __DIR__ . '/db.php';
|
|
require __DIR__ . '/lib/mailer.php';
|
|
require __DIR__ . '/lib/ticket.php';
|
|
start_app_session();
|
|
|
|
if (isset($_SESSION['username'])) {
|
|
header('Location: /');
|
|
exit;
|
|
}
|
|
|
|
const REGISTRATION_WINDOW_SECONDS = 3600;
|
|
const MAX_REGISTRATIONS_PER_IP = 5;
|
|
|
|
function registrationAttemptCount(PDO $pdo, string $identifier, int $windowSeconds): int
|
|
{
|
|
$stmt = $pdo->prepare("SELECT COUNT(*) FROM registration_attempts WHERE identifier = :identifier AND created_at > datetime('now', :window)");
|
|
$stmt->execute(['identifier' => $identifier, 'window' => '-' . $windowSeconds . ' seconds']);
|
|
return (int) $stmt->fetchColumn();
|
|
}
|
|
|
|
function recordRegistrationAttempt(PDO $pdo, string $identifier): void
|
|
{
|
|
$pdo->prepare('INSERT INTO registration_attempts (identifier) VALUES (:identifier)')->execute(['identifier' => $identifier]);
|
|
}
|
|
|
|
$errors = [];
|
|
$username = '';
|
|
$email = '';
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
$username = trim($_POST['username'] ?? '');
|
|
$email = trim($_POST['email'] ?? '');
|
|
$password = $_POST['password'] ?? '';
|
|
|
|
$email = filter_var($email, FILTER_SANITIZE_EMAIL);
|
|
|
|
$pdo = getDbConnection();
|
|
$pdo->exec('CREATE TABLE IF NOT EXISTS registration_attempts (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
identifier TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)');
|
|
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_registration_attempts_identifier ON registration_attempts (identifier)');
|
|
|
|
$ipIdentifier = 'ip:' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
|
|
|
if (!csrf_verify($_POST['csrf_token'] ?? null)) {
|
|
$errors['csrf'] = 'Your session has expired. Please refresh the page and try again.';
|
|
} elseif (registrationAttemptCount($pdo, $ipIdentifier, REGISTRATION_WINDOW_SECONDS) >= MAX_REGISTRATIONS_PER_IP) {
|
|
$errors['rate_limit'] = 'Too many accounts have been created from this network. Please try again later.';
|
|
} else {
|
|
if ($username === '') {
|
|
$errors['username'] = 'Username is required.';
|
|
} elseif (strlen($username) < 3 || strlen($username) > 12) {
|
|
$errors['username'] = 'Username must be between 3 and 12 characters long.';
|
|
} elseif (!preg_match('/^[a-z0-9]+$/', $username)) {
|
|
$errors['username'] = 'Username can only contain lowercase letters (a-z) and numbers (0-9).';
|
|
} else {
|
|
$stmt = $pdo->prepare('SELECT 1 FROM users WHERE username = :username');
|
|
$stmt->execute(['username' => $username]);
|
|
if ($stmt->fetch()) {
|
|
$errors['username'] = 'That username is not available.';
|
|
}
|
|
}
|
|
|
|
if ($email === '') {
|
|
$errors['email'] = 'Email is required.';
|
|
} elseif (strlen($email) > 100) {
|
|
$errors['email'] = 'Email cannot exceed 100 characters.';
|
|
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|
$errors['email'] = 'Please enter a valid email address.';
|
|
} else {
|
|
$stmt = $pdo->prepare('SELECT 1 FROM users WHERE email = :email');
|
|
$stmt->execute(['email' => $email]);
|
|
if ($stmt->fetch()) {
|
|
$errors['email'] = "We couldn't create your account with those details. If you already have an account, try logging in instead.";
|
|
}
|
|
}
|
|
|
|
if ($password === '') {
|
|
$errors['password'] = 'Password is required.';
|
|
} elseif (strlen($password) < 8) {
|
|
$errors['password'] = 'Password must be at least 8 characters long.';
|
|
} elseif (strlen($password) > 72) {
|
|
$errors['password'] = 'Password cannot exceed 72 characters.';
|
|
}
|
|
|
|
if (empty($errors)) {
|
|
$hashedPassword = password_hash($password, PASSWORD_DEFAULT);
|
|
|
|
try {
|
|
$stmt = $pdo->prepare('INSERT INTO users (username, email, password, credits) VALUES (:username, :email, :password, 0)');
|
|
$stmt->execute([
|
|
'username' => $username,
|
|
'email' => $email,
|
|
'password' => $hashedPassword,
|
|
]);
|
|
// Capture the new user's id before any other insert runs on this connection —
|
|
// SQLite's lastInsertId() returns the most recent rowid inserted on the
|
|
// connection regardless of table, so recordRegistrationAttempt()'s own insert
|
|
// would otherwise clobber it and log the session in as the wrong user.
|
|
$newUserId = (int) $pdo->lastInsertId();
|
|
recordRegistrationAttempt($pdo, $ipIdentifier);
|
|
} catch (PDOException $e) {
|
|
if (str_contains($e->getMessage(), 'UNIQUE constraint failed: users.username')) {
|
|
$errors['username'] = 'That username is not available.';
|
|
} elseif (str_contains($e->getMessage(), 'UNIQUE constraint failed: users.email')) {
|
|
$errors['email'] = "We couldn't create your account with those details. If you already have an account, try logging in instead.";
|
|
} else {
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
if (empty($errors)) {
|
|
start_app_session();
|
|
session_regenerate_id(true);
|
|
$_SESSION['user_id'] = $newUserId;
|
|
$_SESSION['username'] = $username;
|
|
$_SESSION['session_version'] = 0;
|
|
|
|
$verifyToken = createTicket($newUserId, 'email_verify', EMAIL_VERIFICATION_LIFETIME_SECONDS);
|
|
$appUrl = rtrim(getenv('APP_URL') ?: 'https://ourapp.local', '/');
|
|
$verifyLink = $appUrl . '/verify-email.php?token=' . urlencode($verifyToken);
|
|
sendEmailVerificationEmail($email, $username, $verifyLink);
|
|
|
|
header('Location: /');
|
|
exit;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$pageTitle = 'Register';
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<?php require __DIR__ . '/lib/head.php'; ?>
|
|
<body class="bg-amber-50 min-h-screen flex flex-col">
|
|
<?php require __DIR__ . '/lib/header.php'; ?>
|
|
<div class="flex-1 flex items-center justify-center bg-gradient-to-b from-amber-50 via-pink-50 to-white p-4 py-12">
|
|
<div class="w-full max-w-4xl bg-white rounded-3xl shadow-xl ring-1 ring-gray-100 overflow-hidden grid md:grid-cols-2">
|
|
<div class="relative hidden md:block">
|
|
<img src="assets/recently3.webp" alt="" aria-hidden="true" class="absolute inset-0 h-full w-full object-cover">
|
|
<div class="absolute inset-0 bg-gradient-to-b from-black/10 via-transparent to-black/40"></div>
|
|
<div class="relative h-full flex flex-col justify-between p-8 text-white">
|
|
<p class="italic font-semibold text-xl leading-snug drop-shadow">
|
|
Create weird.<br>Wonderful critters.
|
|
</p>
|
|
<div class="rounded-2xl bg-white/90 backdrop-blur text-gray-800 px-4 py-3 flex items-center gap-3 shadow-md">
|
|
<i data-lucide="star" class="h-4 w-4"></i>
|
|
<span class="text-sm font-medium">Your next adventure is waiting.</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="p-8 sm:p-10">
|
|
<h1 class="text-2xl font-extrabold text-gray-900">Start your collection.</h1>
|
|
<p class="mt-1 text-gray-500 text-sm">Create an account and bring your imagination to life.</p>
|
|
|
|
<?php if (isset($errors['csrf']) || isset($errors['rate_limit'])): ?>
|
|
<div class="mt-4 rounded-lg bg-red-50 text-red-700 text-sm px-4 py-3">
|
|
<?php echo htmlspecialchars($errors['csrf'] ?? $errors['rate_limit'], ENT_QUOTES, 'UTF-8'); ?>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<form action="register.php" method="post" novalidate class="mt-6 space-y-4">
|
|
<?php echo csrf_field(); ?>
|
|
<div>
|
|
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">Username</label>
|
|
<div class="relative">
|
|
<i data-lucide="user" class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400"></i>
|
|
<input
|
|
type="text" id="username" name="username"
|
|
value="<?php echo htmlspecialchars($username, ENT_QUOTES, 'UTF-8'); ?>"
|
|
placeholder="CritterExplorer"
|
|
class="w-full rounded-xl border border-gray-300 pl-10 pr-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-purple-400"
|
|
>
|
|
</div>
|
|
<?php if (isset($errors['username'])): ?>
|
|
<p class="mt-1 text-sm text-red-600"><?php echo htmlspecialchars($errors['username'], ENT_QUOTES, 'UTF-8'); ?></p>
|
|
<?php endif; ?>
|
|
</div>
|
|
<div>
|
|
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
|
<div class="relative">
|
|
<i data-lucide="mail" class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400"></i>
|
|
<input
|
|
type="text" id="email" name="email"
|
|
value="<?php echo htmlspecialchars($email, ENT_QUOTES, 'UTF-8'); ?>"
|
|
placeholder="you@example.com"
|
|
class="w-full rounded-xl border border-gray-300 pl-10 pr-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-purple-400"
|
|
>
|
|
</div>
|
|
<?php if (isset($errors['email'])): ?>
|
|
<p class="mt-1 text-sm text-red-600"><?php echo htmlspecialchars($errors['email'], ENT_QUOTES, 'UTF-8'); ?></p>
|
|
<?php endif; ?>
|
|
</div>
|
|
<div>
|
|
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
|
<div class="relative">
|
|
<i data-lucide="lock" class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400"></i>
|
|
<input
|
|
type="password" id="password" name="password"
|
|
placeholder="At least 8 characters"
|
|
class="w-full rounded-xl border border-gray-300 pl-10 pr-10 py-2.5 focus:outline-none focus:ring-2 focus:ring-purple-400"
|
|
>
|
|
<button
|
|
type="button"
|
|
onclick="togglePasswordVisibility('password', this)"
|
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
|
aria-label="Show password"
|
|
>
|
|
<i data-lucide="eye" class="h-4 w-4"></i>
|
|
<i data-lucide="eye-off" class="h-4 w-4 hidden"></i>
|
|
</button>
|
|
</div>
|
|
<?php if (isset($errors['password'])): ?>
|
|
<p class="mt-1 text-sm text-red-600"><?php echo htmlspecialchars($errors['password'], ENT_QUOTES, 'UTF-8'); ?></p>
|
|
<?php endif; ?>
|
|
</div>
|
|
<button type="submit" class="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-gray-900 text-white font-semibold py-3 hover:bg-gray-800">
|
|
Create my account <i data-lucide="arrow-right" class="h-4 w-4"></i>
|
|
</button>
|
|
</form>
|
|
|
|
<p class="mt-6 text-center text-sm text-gray-600">
|
|
Already have an account? <a href="login.php" class="text-purple-600 font-semibold hover:underline">Log in</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function togglePasswordVisibility(inputId, button) {
|
|
const input = document.getElementById(inputId);
|
|
const isPassword = input.type === 'password';
|
|
input.type = isPassword ? 'text' : 'password';
|
|
button.querySelectorAll('[data-lucide]').forEach((icon) => icon.classList.toggle('hidden'));
|
|
}
|
|
</script>
|
|
<?php require __DIR__ . '/lib/footer.php'; ?>
|
|
</body>
|
|
</html>
|