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
+32
View File
@@ -0,0 +1,32 @@
# Zkopíruj tento soubor jako .env a vyplň skutečné hodnoty.
# .env NIKDY necommituj do gitu a drž ho mimo webroot (adresář public/).
# --- Údaje pro úvodní stránku (návod ke stažení klienta a realmlist) ---
SITE_NAME="AzerothCore by Minkey"
REALMLIST_ADDRESS=wow.minkey.cz
CLIENT_VERSION="WotLK 3.3.5a (build 12340)"
# Odkaz na stažení klienta (torrent/mega/vlastní mirror) — necháš prázdné,
# pokud ho chceš zatím jen zmínit v Discordu/na fóru.
CLIENT_DOWNLOAD_URL=
# --- Připojení k databázi AzerothCore (auth databáze) ---
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=acore_auth
DB_USERNAME=acore_reg
DB_PASSWORD=zmen_mi_heslo
# --- Cloudflare Turnstile (https://dash.cloudflare.com/?to=/:account/turnstile) ---
TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=
# --- Pravidla pro registraci ---
REG_USERNAME_MIN=3
REG_USERNAME_MAX=16
REG_PASSWORD_MIN=8
REG_PASSWORD_MAX=16
# --- Rate limiting (ochrana proti spamu) ---
# Kolik pokusů o registraci povolit z jedné IP v daném časovém okně (vteřiny)
REG_RATE_LIMIT_MAX=5
REG_RATE_LIMIT_WINDOW=3600
+2
View File
@@ -0,0 +1,2 @@
.env
data/*.sqlite
+100
View File
@@ -0,0 +1,100 @@
# Registrace účtů — AzerothCore
Jednoduchá PHP registrační stránka pro AzerothCore server. Vytváří účet
přímo v `acore_auth`.`account` tabulce se správně spočítaným SRP6
salt/verifierem, takže se hráč může rovnou přihlásit ve hře.
## Ochrana proti spamu/botům
- **Cloudflare Turnstile** — captcha ověřená server-side (volitelná: pokud
necháš `TURNSTILE_SITE_KEY`/`TURNSTILE_SECRET_KEY` prázdné, widget se
nezobrazí a ověření se přeskočí — pro produkci ale doporučuji vyplnit).
- **Honeypot pole** — skryté pole `website`, které boti často vyplní;
formulář pak předstírá úspěch, ale nic se nezapíše.
- **Rate limiting** — max. N registračních pokusů z jedné IP za časové
okno (výchozí 5 / hodinu), ukládá se do lokální SQLite (`data/ratelimit.sqlite`),
nezasahuje do AzerothCore databáze.
- **CSRF token** na formuláři.
## Požadavky na serveru
- PHP 8.1+
- PHP rozšíření: `pdo_mysql`, `pdo_sqlite`, `gmp`, `session`, `openssl`
- Webserver (Apache s `mod_php`/PHP-FPM, nebo nginx + PHP-FPM)
- Síťový/lokální přístup k MySQL, kde běží `acore_auth`
## Instalace
1. Zkopíruj `.env.example` do `.env` a vyplň:
- `DB_HOST`, `DB_PORT`, `DB_DATABASE` (typicky `acore_auth`), `DB_USERNAME`, `DB_PASSWORD`
- `TURNSTILE_SITE_KEY` / `TURNSTILE_SECRET_KEY` z Cloudflare dashboardu
2. Vytvoř v MySQL **dedikovaného uživatele** jen s právy na tabulku `account`
(neházej tam root přístup):
```sql
CREATE USER 'acore_reg'@'localhost' IDENTIFIED BY 'silne-nahodne-heslo';
GRANT SELECT, INSERT ON acore_auth.account TO 'acore_reg'@'localhost';
FLUSH PRIVILEGES;
```
3. Nastav webserver tak, aby **DocumentRoot mířil na `public/`**, ne na kořen
projektu — `includes/`, `data/` a `.env` tak nebudou z webu dostupné vůbec.
Příklad Apache vhost:
```apache
<VirtualHost *:443>
ServerName registrace.tvuj-server.cz
DocumentRoot /cesta/k/wowserver-registrace/public
<Directory /cesta/k/wowserver-registrace/public>
AllowOverride All
Require all granted
</Directory>
</VirtualHost>
```
4. Uprav práva zápisu na `data/` (potřebuje tam vzniknout `ratelimit.sqlite`):
```bash
chown -R www-data:www-data data
chmod 750 data
```
5. Ověř, že GMP na serveru počítá SRP6 verifier správně (porovná se s
nezávisle spočítanou referenční hodnotou):
```bash
php includes/srp6_selftest.php
```
Očekávaný výstup je `OK - shoduje se s referenční implementací.` Pokud
vypíše chybu, něco je špatně s PHP/GMP na serveru a registrace by
vytvářela účty, se kterými se nepůjde přihlásit — než to nespadne,
dál nepokračuj.
6. Otevři stránku v prohlížeči, vyzkoušej registraci, ověř že se v `account`
tabulce objevil nový řádek a že se přihlásíš herním klientem.
## Poznámky k herním pravidlům
- Uživatelské jméno i heslo se před výpočtem SRP6 verifieru převádí na
velká písmena (`strtoupper`) a jméno se tak i ukládá — přesně jak to dělá
samotný AzerothCore/klient, aby přihlášení fungovalo case-insensitive.
- Heslo je omezené na tisknutelná ASCII znaky (`REG_PASSWORD_MIN/MAX` v `.env`,
výchozí 816 znaků) — WoW klient jiné znaky v hesle nepodporuje.
- `expansion`, `locale` a další sloupce tabulky `account` se nechávají na
výchozích hodnotách definovaných v DB schématu AzerothCore — uprav
`public/register.php`, pokud chceš nový účet zařadit jinak (např. jiný expansion level).
- Pokud web neběží přímo na stejném stroji jako MySQL, zvaž TLS pro DB
spojení nebo tunelování (SSH tunel / VPN), ať heslo/verifier neputuje po síti čistě.
## Struktura projektu
```
includes/ # PHP logika mimo webroot (DB, SRP6, Turnstile, rate limiter)
public/ # webroot — index.php (formulář), register.php (zpracování)
data/ # SQLite pro rate limiting (zapisovatelné webserverem)
.env # tajné údaje, needituj do gitu
```
+1
View File
@@ -0,0 +1 @@
*.sqlite
Executable
+1
View File
@@ -0,0 +1 @@
Require all denied
+1
View File
@@ -0,0 +1 @@
Require all denied
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
final class Database
{
private static ?PDO $connection = null;
public static function connection(): PDO
{
if (self::$connection === null) {
$config = require __DIR__ . '/config.php';
$db = $config['db'];
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
$db['host'],
$db['port'],
$db['database']
);
self::$connection = new PDO($dsn, $db['username'], $db['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
return self::$connection;
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/**
* File-based (SQLite) rate limiter keyed by IP. Kept separate from the
* AzerothCore auth database on purpose — this is purely a web-layer
* anti-spam guard, not game data.
*/
final class RateLimiter
{
private PDO $db;
private int $maxAttempts;
private int $windowSeconds;
public function __construct(string $storagePath, int $maxAttempts, int $windowSeconds)
{
$this->maxAttempts = $maxAttempts;
$this->windowSeconds = $windowSeconds;
$isNew = !is_file($storagePath);
$this->db = new PDO('sqlite:' . $storagePath);
$this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
if ($isNew) {
$this->db->exec(
'CREATE TABLE attempts (
ip TEXT NOT NULL,
created_at INTEGER NOT NULL
)'
);
$this->db->exec('CREATE INDEX idx_attempts_ip ON attempts (ip, created_at)');
}
}
public function tooManyAttempts(string $ip): bool
{
$this->cleanup($ip);
$stmt = $this->db->prepare('SELECT COUNT(*) FROM attempts WHERE ip = :ip AND created_at > :since');
$stmt->execute([
':ip' => $ip,
':since' => time() - $this->windowSeconds,
]);
return (int) $stmt->fetchColumn() >= $this->maxAttempts;
}
public function recordAttempt(string $ip): void
{
$stmt = $this->db->prepare('INSERT INTO attempts (ip, created_at) VALUES (:ip, :now)');
$stmt->execute([
':ip' => $ip,
':now' => time(),
]);
}
private function cleanup(string $ip): void
{
$stmt = $this->db->prepare('DELETE FROM attempts WHERE ip = :ip AND created_at <= :since');
$stmt->execute([
':ip' => $ip,
':since' => time() - $this->windowSeconds,
]);
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/**
* SRP6 salt/verifier generation compatible with AzerothCore's authserver
* (same constants and algorithm as TrinityCore/MaNGOS, mandated by the WoW
* client itself, so this is stable across expansions/versions).
*
* Algorithm (matches AzerothCore's SRP6::MakeRegistrationData /
* ac-nodejs-srp6's computeVerifier):
* s = random 32 bytes (salt)
* h1 = SHA1(UPPER(username) + ":" + UPPER(password))
* x = SHA1(s . h1) interpreted as a LITTLE-endian integer
* v = g^x mod N (verifier)
* Both s and v are stored little-endian, zero-padded to 32 bytes, in the
* `account`.`salt` / `account`.`verifier` BINARY(32) columns.
*/
final class Srp6
{
// Fixed 256-bit prime and generator used by the WoW client's SRP6 implementation.
private const N_HEX = '894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7';
private const G = 7;
private const KEY_LENGTH = 32;
/**
* @return array{salt: string, verifier: string} raw 32-byte binary strings
*/
public static function makeRegistrationData(string $username, string $password): array
{
if (!extension_loaded('gmp')) {
throw new RuntimeException('Chybí PHP rozšíření GMP (php-gmp), nutné pro výpočet SRP6 verifieru.');
}
$username = strtoupper($username);
$password = strtoupper($password);
$salt = random_bytes(self::KEY_LENGTH);
$h1 = sha1($username . ':' . $password, true);
$xHash = sha1($salt . $h1, true);
$x = gmp_import($xHash, 1, GMP_LSW_FIRST | GMP_LITTLE_ENDIAN);
$n = gmp_init(self::N_HEX, 16);
$g = gmp_init(self::G, 10);
$v = gmp_powm($g, $x, $n);
$verifier = self::gmpToLittleEndianBytes($v, self::KEY_LENGTH);
return [
'salt' => $salt,
'verifier' => $verifier,
];
}
private static function gmpToLittleEndianBytes(\GMP $value, int $length): string
{
$bytes = gmp_export($value, 1, GMP_LSW_FIRST | GMP_LITTLE_ENDIAN);
if (strlen($bytes) > $length) {
throw new RuntimeException('SRP6 verifier přesáhl očekávanou délku.');
}
return str_pad($bytes, $length, "\0", STR_PAD_RIGHT);
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
final class Turnstile
{
private const VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
public static function verify(string $token, string $secretKey, string $remoteIp): bool
{
if ($token === '' || $secretKey === '') {
return false;
}
$payload = http_build_query([
'secret' => $secretKey,
'response' => $token,
'remoteip' => $remoteIp,
]);
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/x-www-form-urlencoded\r\n",
'content' => $payload,
'timeout' => 8,
],
]);
$response = @file_get_contents(self::VERIFY_URL, false, $context);
if ($response === false) {
return false;
}
$data = json_decode($response, true);
return is_array($data) && ($data['success'] ?? false) === true;
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
if (session_status() === PHP_SESSION_NONE) {
session_start([
'cookie_httponly' => true,
'cookie_samesite' => 'Lax',
]);
}
$config = require __DIR__ . '/config.php';
require_once __DIR__ . '/Database.php';
require_once __DIR__ . '/Srp6.php';
require_once __DIR__ . '/Turnstile.php';
require_once __DIR__ . '/RateLimiter.php';
require_once __DIR__ . '/functions.php';
return $config;
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/**
* Loads .env (simple KEY=VALUE parser, no external dependency) and exposes
* typed config via env()/config().
*/
if (!function_exists('load_env')) {
// This file is `require`d (not require_once) from more than one place
// (bootstrap.php and Database.php), so it must always return a fresh
// config array — but its function declarations must only run once.
function load_env(string $path): void
{
if (!is_file($path)) {
return;
}
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) {
continue;
}
[$key, $value] = array_pad(explode('=', $line, 2), 2, '');
$key = trim($key);
$value = trim($value);
if ($key === '') {
continue;
}
// Strip one layer of matching quotes, e.g. SITE_NAME="AzerothCore by Minkey"
if (strlen($value) >= 2) {
$first = $value[0];
$last = $value[strlen($value) - 1];
if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) {
$value = substr($value, 1, -1);
}
}
putenv("$key=$value");
$_ENV[$key] = $value;
}
}
function env(string $key, ?string $default = null): ?string
{
$value = getenv($key);
if ($value === false || $value === '') {
return $default;
}
return $value;
}
}
load_env(__DIR__ . '/../.env');
return [
'site' => [
'name' => env('SITE_NAME', 'AzerothCore by Minkey'),
'realmlist' => env('REALMLIST_ADDRESS', 'wow.minkey.cz'),
'client_download_url' => env('CLIENT_DOWNLOAD_URL', ''),
'client_version' => env('CLIENT_VERSION', 'WotLK 3.3.5a (build 12340)'),
],
'db' => [
'host' => env('DB_HOST', '127.0.0.1'),
'port' => (int) env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'acore_auth'),
'username' => env('DB_USERNAME', ''),
'password' => env('DB_PASSWORD', ''),
],
'turnstile' => [
'site_key' => env('TURNSTILE_SITE_KEY', ''),
'secret_key' => env('TURNSTILE_SECRET_KEY', ''),
],
'rules' => [
'username_min' => (int) env('REG_USERNAME_MIN', '3'),
'username_max' => (int) env('REG_USERNAME_MAX', '16'),
'password_min' => (int) env('REG_PASSWORD_MIN', '8'),
'password_max' => (int) env('REG_PASSWORD_MAX', '16'),
],
'rate_limit' => [
'max_attempts' => (int) env('REG_RATE_LIMIT_MAX', '5'),
'window_seconds' => (int) env('REG_RATE_LIMIT_WINDOW', '3600'),
],
];
+87
View File
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
function client_ip(): string
{
// Uprav podle své infrastruktury: pokud web běží za reverzní proxy
// (nginx proxy_pass, Cloudflare, ...), musíš X-Forwarded-For nastavit
// a validovat důvěryhodně na úrovni proxy, jinak si ji klient může
// vymyslet sám. Bez proxy je REMOTE_ADDR spolehlivý zdroj.
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : '0.0.0.0';
}
function csrf_token(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function csrf_verify(?string $token): bool
{
return is_string($token)
&& !empty($_SESSION['csrf_token'])
&& hash_equals($_SESSION['csrf_token'], $token);
}
/**
* Honeypot: skryté pole, které lidský uživatel nikdy nevyplní, ale
* jednoduší boti ano.
*/
function honeypot_triggered(): bool
{
return !empty($_POST['website']);
}
/**
* @return string[] seznam chyb (prázdné pole = validní)
*/
function validate_username(string $username, array $rules): array
{
$errors = [];
$len = mb_strlen($username);
if ($len < $rules['username_min'] || $len > $rules['username_max']) {
$errors[] = sprintf(
'Uživatelské jméno musí mít %d až %d znaků.',
$rules['username_min'],
$rules['username_max']
);
}
if (!preg_match('/^[A-Za-z0-9]+$/', $username)) {
$errors[] = 'Uživatelské jméno smí obsahovat pouze písmena A-Z a číslice (bez diakritiky a speciálních znaků).';
}
return $errors;
}
function validate_password(string $password, array $rules): array
{
$errors = [];
$len = strlen($password);
if ($len < $rules['password_min'] || $len > $rules['password_max']) {
$errors[] = sprintf(
'Heslo musí mít %d až %d znaků.',
$rules['password_min'],
$rules['password_max']
);
}
if (!preg_match('/^[\x21-\x7E]+$/', $password)) {
$errors[] = 'Heslo smí obsahovat pouze běžné znaky ASCII (bez mezer a diakritiky) — herní klient WoW jiné znaky nepodporuje.';
}
return $errors;
}
function validate_email(string $email): array
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return ['Zadej platnou e-mailovou adresu.'];
}
return [];
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/**
* Ruční self-test: ověří, že Srp6::makeRegistrationData() dává stejný
* výsledek jako nezávislá referenční implementace (ověřeno proti Python/
* Node.js reimplementaci algoritmu z azerothcore/ac-nodejs-srp6).
*
* Spusť na serveru: php includes/srp6_selftest.php
* Očekávaný výstup: "OK - shoduje se s referenční implementací."
*/
require_once __DIR__ . '/Srp6.php';
$reflection = new ReflectionClass(Srp6::class);
$method = $reflection->getMethod('gmpToLittleEndianBytes');
$method->setAccessible(true);
$username = 'TESTUSER';
$password = 'TESTPASS';
$fixedSalt = hex2bin('00112233445566778899aabbccddeeff00112233445566778899aabbccddee');
// Přepočítej ručně se stejnou fixní solí jako reference (obchází random_bytes).
$h1 = sha1(strtoupper($username) . ':' . strtoupper($password), true);
$xHash = sha1($fixedSalt . $h1, true);
$x = gmp_import($xHash, 1, GMP_LSW_FIRST | GMP_LITTLE_ENDIAN);
$n = gmp_init('894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7', 16);
$g = gmp_init(7, 10);
$v = gmp_powm($g, $x, $n);
$verifier = $method->invoke(null, $v, 32);
$expected = '0eac7840ff65c7e32dbbb3b173a1cc7ca7cde40f2c3d8423777da4f2929e9257';
$actual = bin2hex($verifier);
if ($actual === $expected) {
echo "OK - shoduje se s referenční implementací.\n";
exit(0);
}
echo "CHYBA: verifier se neshoduje!\n";
echo "Očekáváno: $expected\n";
echo "Spočteno: $actual\n";
exit(1);
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>