Initial commit: AzerothCore registration site

PHP registration page for AzerothCore with SRP6 salt/verifier generation,
Cloudflare Turnstile + honeypot + rate limiting anti-spam, and a WoW-themed
landing page with client setup instructions.
This commit is contained in:
2026-08-05 11:41:24 +02:00
commit 2b9a673912
19 changed files with 1294 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 KiB

+433
View File
@@ -0,0 +1,433 @@
:root {
color-scheme: dark;
--gold-bright: #f0d78c;
--gold: #c9a227;
--gold-dim: #7a5c14;
--ink: #0a0805;
--panel: #14100a;
--panel-light: #1d1710;
--parchment: #e8dcc0;
--parchment-dim: #a99a7c;
--blood: #b23a2f;
--moss: #6b8f3f;
}
* {
box-sizing: border-box;
}
html, body {
height: 100%;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 32px 16px;
font-family: "EB Garamond", Georgia, "Times New Roman", serif;
color: var(--parchment);
background: url("background.jpg") center center / cover no-repeat fixed, #05040a;
position: relative;
overflow-x: hidden;
}
/* dark overlay + vignette over the background artwork, so the gold/parchment
UI stays readable regardless of how bright the underlying image is */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background:
radial-gradient(ellipse at 50% 45%, rgba(5, 4, 8, 0.35) 0%, rgba(4, 3, 6, 0.72) 60%, rgba(3, 2, 4, 0.9) 100%),
repeating-linear-gradient(0deg, rgba(255,255,255,0.012) 0px, rgba(255,255,255,0.012) 1px, transparent 1px, transparent 3px);
z-index: 0;
}
.frame {
position: relative;
z-index: 1;
width: 100%;
max-width: 460px;
padding: 3px;
background: linear-gradient(135deg, var(--gold-dim), var(--gold-bright) 15%, var(--gold-dim) 30%, #4a3a10 50%, var(--gold-bright) 70%, var(--gold-dim) 85%, var(--gold-bright));
border-radius: 6px;
box-shadow:
0 0 0 1px #000,
0 20px 60px rgba(0, 0, 0, 0.7),
0 0 40px rgba(201, 162, 39, 0.08);
}
.frame-wide {
max-width: 640px;
}
.card {
position: relative;
background:
linear-gradient(180deg, var(--panel-light) 0%, var(--panel) 12%, var(--panel) 88%, var(--panel-light) 100%);
border: 1px solid #000;
border-radius: 4px;
padding: 40px 36px 34px;
box-shadow:
inset 0 0 0 1px rgba(201, 162, 39, 0.35),
inset 0 2px 12px rgba(0, 0, 0, 0.6);
}
/* ornamental corner brackets, drawn in CSS to echo carved-stone UI frames */
.card::before,
.card::after,
.frame::before,
.frame::after {
content: "";
position: absolute;
width: 22px;
height: 22px;
border: 2px solid var(--gold-bright);
z-index: 2;
pointer-events: none;
opacity: 0.9;
}
.frame::before { top: -3px; left: -3px; border-right: none; border-bottom: none; }
.frame::after { top: -3px; right: -3px; border-left: none; border-bottom: none; }
.card::before { bottom: -3px; left: -3px; border-right: none; border-top: none; }
.card::after { bottom: -3px; right: -3px; border-left: none; border-top: none; }
.crest {
width: 54px;
height: 54px;
margin: 0 auto 10px;
background: radial-gradient(circle at 35% 30%, var(--gold-bright), var(--gold) 55%, var(--gold-dim) 100%);
clip-path: polygon(50% 0%, 90% 20%, 100% 60%, 50% 100%, 0% 60%, 10% 20%);
box-shadow: 0 0 18px rgba(201, 162, 39, 0.45), inset 0 0 0 2px rgba(0,0,0,0.4);
}
h1 {
margin: 0 0 6px;
text-align: center;
font-family: "Cinzel Decorative", "Cinzel", Georgia, serif;
font-weight: 700;
font-size: 1.5rem;
line-height: 1.3;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--gold-bright);
background: linear-gradient(180deg, #fff3cf 0%, var(--gold-bright) 30%, var(--gold) 65%, #8a6a1c 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: none;
filter: drop-shadow(0 2px 1px rgba(0, 0, 0, 0.9));
}
h1 .realm-name {
font-size: 1.05rem;
letter-spacing: 0.12em;
}
.divider {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin: 14px 0 26px;
color: var(--gold-dim);
}
.divider::before,
.divider::after {
content: "";
height: 1px;
flex: 1;
background: linear-gradient(90deg, transparent, var(--gold) 50%, transparent);
}
.divider span {
color: var(--gold);
font-size: 0.95rem;
transform: rotate(45deg);
display: inline-block;
}
label {
display: block;
margin: 18px 0 6px;
font-family: "Cinzel", Georgia, serif;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--gold);
}
input[type="text"],
input[type="email"],
input[type="password"] {
width: 100%;
padding: 11px 13px;
background: #0d0a06;
border: 1px solid #3a2f1a;
border-radius: 2px;
color: var(--parchment);
font-family: "EB Garamond", Georgia, serif;
font-size: 1.05rem;
box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.6);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
input::placeholder {
color: #5a5040;
}
input:focus {
outline: none;
border-color: var(--gold);
box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.6), 0 0 0 2px rgba(201, 162, 39, 0.25), 0 0 14px rgba(201, 162, 39, 0.25);
}
.hint {
margin: 5px 2px 0;
font-size: 0.78rem;
font-style: italic;
color: var(--parchment-dim);
}
button[type="submit"] {
position: relative;
display: block;
width: 100%;
margin-top: 30px;
padding: 13px;
background: linear-gradient(180deg, #3a2f18 0%, #241a0d 55%, #1a1209 100%);
border: 1px solid var(--gold-dim);
border-radius: 2px;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
inset 0 0 0 1px rgba(0, 0, 0, 0.6),
0 4px 10px rgba(0, 0, 0, 0.5);
cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.05s ease;
}
button[type="submit"] span {
font-family: "Cinzel", Georgia, serif;
font-weight: 600;
font-size: 0.9rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--gold-bright);
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.9);
}
button[type="submit"]:hover {
border-color: var(--gold-bright);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.1),
inset 0 0 0 1px rgba(0, 0, 0, 0.6),
0 0 18px rgba(201, 162, 39, 0.35);
}
button[type="submit"]:active {
transform: translateY(1px);
}
.cf-turnstile {
margin-top: 22px;
display: flex;
justify-content: center;
}
.alert {
padding: 12px 16px;
border-radius: 2px;
margin-bottom: 20px;
font-size: 0.92rem;
font-style: italic;
text-align: center;
}
.alert-success {
background: linear-gradient(180deg, rgba(107, 143, 63, 0.18), rgba(107, 143, 63, 0.06));
border: 1px solid rgba(150, 190, 90, 0.5);
box-shadow: inset 0 0 12px rgba(107, 143, 63, 0.15);
color: #c9e8a0;
}
.alert-error {
background: linear-gradient(180deg, rgba(178, 58, 47, 0.2), rgba(178, 58, 47, 0.07));
border: 1px solid rgba(210, 90, 75, 0.55);
box-shadow: inset 0 0 12px rgba(178, 58, 47, 0.18);
color: #f0b2a8;
}
.alert-error ul {
margin: 0;
padding: 0;
list-style: none;
}
.alert-error li::before {
content: "☠ ";
}
/* Honeypot: skryté vizuálně, ale přítomné v DOM pro boty */
.hp-field {
position: absolute;
left: -9999px;
top: -9999px;
}
.back-link {
display: inline-block;
margin-bottom: 18px;
font-family: "Cinzel", Georgia, serif;
font-size: 0.72rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--parchment-dim);
text-decoration: none;
transition: color 0.15s ease;
}
.back-link:hover {
color: var(--gold-bright);
}
/* --- Landing page: steps list --- */
.steps-section h2 {
margin: 0 0 22px;
text-align: center;
font-family: "Cinzel", Georgia, serif;
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--gold);
}
.steps {
list-style: none;
margin: 0;
padding: 0;
counter-reset: step;
display: flex;
flex-direction: column;
gap: 26px;
}
.steps li {
counter-increment: step;
display: flex;
gap: 18px;
align-items: flex-start;
}
.steps li::before {
content: counter(step);
flex: none;
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
font-family: "Cinzel", Georgia, serif;
font-weight: 700;
font-size: 0.95rem;
color: var(--ink);
background: radial-gradient(circle at 35% 30%, var(--gold-bright), var(--gold) 60%, var(--gold-dim) 100%);
clip-path: polygon(50% 0%, 90% 20%, 100% 60%, 50% 100%, 0% 60%, 10% 20%);
box-shadow: 0 0 10px rgba(201, 162, 39, 0.35);
}
.step-body {
flex: 1;
min-width: 0;
padding-top: 4px;
}
.step-body h3 {
margin: 0 0 6px;
font-family: "Cinzel", Georgia, serif;
font-size: 0.98rem;
font-weight: 600;
letter-spacing: 0.03em;
color: var(--gold-bright);
}
.step-body p {
margin: 0 0 8px;
font-size: 0.95rem;
line-height: 1.5;
color: var(--parchment);
}
.step-body .hint {
margin: 6px 0 0;
}
.path,
.realmlist-code {
display: block;
width: 100%;
margin: 4px 0 10px;
padding: 9px 12px;
background: #0d0a06;
border: 1px solid #3a2f1a;
border-radius: 2px;
box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.6);
font-family: "Courier New", Consolas, monospace;
font-size: 0.85rem;
word-break: break-all;
color: var(--parchment-dim);
}
.realmlist-code {
color: var(--gold-bright);
}
.btn-link {
display: inline-block;
margin-top: 4px;
padding: 9px 20px;
background: linear-gradient(180deg, #3a2f18 0%, #241a0d 55%, #1a1209 100%);
border: 1px solid var(--gold-dim);
border-radius: 2px;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
inset 0 0 0 1px rgba(0, 0, 0, 0.6);
font-family: "Cinzel", Georgia, serif;
font-weight: 600;
font-size: 0.8rem;
letter-spacing: 0.08em;
text-transform: uppercase;
text-decoration: none;
color: var(--gold-bright);
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.9);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.btn-link:hover {
border-color: var(--gold-bright);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.1),
inset 0 0 0 1px rgba(0, 0, 0, 0.6),
0 0 18px rgba(201, 162, 39, 0.35);
}
.btn-link.btn-primary {
background: linear-gradient(180deg, #4a3a10 0%, #2c2109 55%, #1a1209 100%);
border-color: var(--gold);
}
@media (max-width: 480px) {
.card {
padding: 32px 22px 26px;
}
h1 {
font-size: 1.25rem;
}
.steps li {
gap: 12px;
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
$config = require __DIR__ . '/../includes/bootstrap.php';
$siteName = htmlspecialchars($config['site']['name'], ENT_QUOTES);
$realmlist = htmlspecialchars($config['site']['realmlist'], ENT_QUOTES);
$clientVersion = htmlspecialchars($config['site']['client_version'], ENT_QUOTES);
$clientUrl = $config['site']['client_download_url'];
?>
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $siteName ?></title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&family=Cinzel+Decorative:wght@700&family=EB+Garamond:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<div class="frame frame-wide">
<main class="card">
<div class="crest" aria-hidden="true"></div>
<h1>AzerothCore<br><span class="realm-name">by Minkey</span></h1>
<div class="divider"><span>⚔</span></div>
<section class="steps-section">
<h2>Jak začít hrát</h2>
<ol class="steps">
<li>
<div class="step-body">
<h3>Stáhni si herního klienta</h3>
<p>Server běží na verzi <strong><?= $clientVersion ?></strong>.</p>
<?php if ($clientUrl !== ''): ?>
<a class="btn-link" href="<?= htmlspecialchars($clientUrl, ENT_QUOTES) ?>">Stáhnout klienta</a>
<?php else: ?>
<a href="https://download.therawow.com/TheraWoW-Client.zip" style="text-decoration:none"><p class="hint">Odkaz na stažení</p></a>
<?php endif; ?>
</div>
</li>
<li>
<div class="step-body">
<h3>Uprav realmlist</h3>
<p>Otevři soubor v instalační složce klienta:</p>
<code class="path">World of Warcraft\Data\&lt;lokalizace&gt;\realmlist.wtf</code>
<p>Smaž jeho obsah a nahraď tímto řádkem:</p>
<code class="realmlist-code">set realmlist <?= $realmlist ?></code>
<p class="hint">Soubor ulož a nech ho tak — klient se pak připojí na můj server místo oficiálního Blizzardu.</p>
</div>
</li>
<li>
<div class="step-body">
<h3>Vytvoř si účet</h3>
<p>Zaregistruj se a přihlas se rovnou do hry.</p>
<a class="btn-link btn-primary" href="registrace.php">Vytvořit účet</a>
</div>
</li>
<li>
<div class="step-body">
<h3>Spusť Wow.exe a hraj</h3>
<p>Přihlas se svým novým účtem a vyraž do Azerothu.</p>
</div>
</li>
</ol>
</section>
</main>
</div>
</body>
</html>
+110
View File
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
$config = require __DIR__ . '/../includes/bootstrap.php';
function fail(array $errors, array $old = []): never
{
$_SESSION['flash_errors'] = $errors;
$_SESSION['flash_old'] = $old;
header('Location: registrace.php');
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: registrace.php');
exit;
}
$old = [
'username' => trim((string) ($_POST['username'] ?? '')),
'email' => trim((string) ($_POST['email'] ?? '')),
];
if (!csrf_verify($_POST['csrf_token'] ?? null)) {
fail(['Neplatný nebo vypršelý formulář, zkus to prosím znovu.']);
}
// Honeypot: boti pole často vyplní. Předstíráme úspěch, aby se bot
// nedozvěděl, že byl odhalen, ale žádná data se nezapíšou.
if (honeypot_triggered()) {
$_SESSION['flash_success'] = 'Účet byl úspěšně vytvořen. Nyní se můžeš přihlásit do hry.';
header('Location: registrace.php');
exit;
}
$ip = client_ip();
$rateLimiter = new RateLimiter(__DIR__ . '/../data/ratelimit.sqlite', ...array_values($config['rate_limit']));
if ($rateLimiter->tooManyAttempts($ip)) {
fail(['Příliš mnoho pokusů o registraci z tvé IP adresy. Zkus to prosím později.'], $old);
}
$username = $old['username'];
$email = $old['email'];
$password = (string) ($_POST['password'] ?? '');
$passwordConfirm = (string) ($_POST['password_confirm'] ?? '');
$errors = [];
$errors = array_merge($errors, validate_username($username, $config['rules']));
$errors = array_merge($errors, validate_email($email));
$errors = array_merge($errors, validate_password($password, $config['rules']));
if ($password !== $passwordConfirm) {
$errors[] = 'Zadaná hesla se neshodují.';
}
$turnstileSecret = $config['turnstile']['secret_key'];
if ($turnstileSecret !== '') {
$turnstileToken = (string) ($_POST['cf-turnstile-response'] ?? '');
if (!Turnstile::verify($turnstileToken, $turnstileSecret, $ip)) {
$errors[] = 'Ověření „nejsem robot“ se nezdařilo, zkus to prosím znovu.';
}
}
if (!empty($errors)) {
// Neúspěšný pokus se počítá do rate limitu, aby útočník nemohl
// zkoušet donekonečna jen proto, že validace selhala.
$rateLimiter->recordAttempt($ip);
fail($errors, $old);
}
$pdo = Database::connection();
$stmt = $pdo->prepare('SELECT id FROM account WHERE username = :username');
$stmt->execute([':username' => strtoupper($username)]);
if ($stmt->fetch() !== false) {
$rateLimiter->recordAttempt($ip);
fail(['Toto uživatelské jméno už je obsazené.'], $old);
}
$srp = Srp6::makeRegistrationData($username, $password);
try {
$insert = $pdo->prepare(
'INSERT INTO account (username, salt, verifier, email, reg_mail, joindate, last_ip)
VALUES (:username, :salt, :verifier, :email, :reg_mail, NOW(), :last_ip)'
);
$insert->execute([
':username' => strtoupper($username),
':salt' => $srp['salt'],
':verifier' => $srp['verifier'],
':email' => $email,
':reg_mail' => $email,
':last_ip' => $ip,
]);
} catch (PDOException $e) {
$rateLimiter->recordAttempt($ip);
if ($e->getCode() === '23000') {
fail(['Toto uživatelské jméno už je obsazené.'], $old);
}
error_log('Registration insert failed: ' . $e->getMessage());
fail(['Registraci se nepodařilo dokončit, zkus to prosím později.'], $old);
}
$rateLimiter->recordAttempt($ip);
$_SESSION['flash_success'] = 'Účet byl úspěšně vytvořen. Nyní se můžeš přihlásit do hry.';
unset($_SESSION['csrf_token']);
header('Location: registrace.php');
exit;
+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
$config = require __DIR__ . '/../includes/bootstrap.php';
$errors = $_SESSION['flash_errors'] ?? [];
$old = $_SESSION['flash_old'] ?? [];
$success = $_SESSION['flash_success'] ?? null;
unset($_SESSION['flash_errors'], $_SESSION['flash_old'], $_SESSION['flash_success']);
$siteKey = htmlspecialchars($config['turnstile']['site_key'], ENT_QUOTES);
$token = csrf_token();
?>
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Registrace účtu — WoW server</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&family=Cinzel+Decorative:wght@700&family=EB+Garamond:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/style.css">
<?php if ($siteKey !== ''): ?>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<?php endif; ?>
</head>
<body>
<div class="frame">
<main class="card">
<a class="back-link" href="index.php">&larr; Zpět na úvod</a>
<div class="crest" aria-hidden="true"></div>
<h1>Registrace účtu</h1>
<div class="divider"><span>⚔</span></div>
<?php if ($success): ?>
<div class="alert alert-success">
<?= htmlspecialchars($success, ENT_QUOTES) ?>
</div>
<?php endif; ?>
<?php if (!empty($errors)): ?>
<div class="alert alert-error">
<ul>
<?php foreach ($errors as $error): ?>
<li><?= htmlspecialchars($error, ENT_QUOTES) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php if (!$success): ?>
<form method="post" action="register.php" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($token, ENT_QUOTES) ?>">
<!-- Honeypot: skryté před lidmi přes CSS, boti pole často vyplní -->
<div class="hp-field" aria-hidden="true">
<label for="website">Nechte prázdné</label>
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
</div>
<label for="username">Uživatelské jméno</label>
<input
type="text"
id="username"
name="username"
minlength="<?= (int) $config['rules']['username_min'] ?>"
maxlength="<?= (int) $config['rules']['username_max'] ?>"
value="<?= htmlspecialchars($old['username'] ?? '', ENT_QUOTES) ?>"
required
>
<p class="hint">Pouze písmena A-Z a číslice, <?= (int) $config['rules']['username_min'] ?><?= (int) $config['rules']['username_max'] ?> znaků.</p>
<label for="email">E-mail</label>
<input
type="email"
id="email"
name="email"
value="<?= htmlspecialchars($old['email'] ?? '', ENT_QUOTES) ?>"
required
>
<label for="password">Heslo</label>
<input
type="password"
id="password"
name="password"
minlength="<?= (int) $config['rules']['password_min'] ?>"
maxlength="<?= (int) $config['rules']['password_max'] ?>"
required
>
<label for="password_confirm">Heslo znovu</label>
<input
type="password"
id="password_confirm"
name="password_confirm"
minlength="<?= (int) $config['rules']['password_min'] ?>"
maxlength="<?= (int) $config['rules']['password_max'] ?>"
required
>
<?php if ($siteKey !== ''): ?>
<div class="cf-turnstile" data-sitekey="<?= $siteKey ?>"></div>
<?php endif; ?>
<button type="submit"><span>Zaregistrovat se</span></button>
</form>
<?php endif; ?>
</main>
</div>
</body>
</html>