commit 2b9a6739122ece4a26e9d3a1a4f9928c229ca6f6 Author: vojta Date: Wed Aug 5 11:41:24 2026 +0200 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. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ef972bc --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..315cd8a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +data/*.sqlite diff --git a/README.md b/README.md new file mode 100644 index 0000000..ebb5685 --- /dev/null +++ b/README.md @@ -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 + + ServerName registrace.tvuj-server.cz + DocumentRoot /cesta/k/wowserver-registrace/public + + + AllowOverride All + Require all granted + + + ``` + +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í 8–16 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 +``` diff --git a/data/.gitignore b/data/.gitignore new file mode 100755 index 0000000..9b1dffd --- /dev/null +++ b/data/.gitignore @@ -0,0 +1 @@ +*.sqlite diff --git a/data/.htaccess b/data/.htaccess new file mode 100755 index 0000000..b66e808 --- /dev/null +++ b/data/.htaccess @@ -0,0 +1 @@ +Require all denied diff --git a/includes/.htaccess b/includes/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/includes/.htaccess @@ -0,0 +1 @@ +Require all denied diff --git a/includes/Database.php b/includes/Database.php new file mode 100644 index 0000000..2dcff67 --- /dev/null +++ b/includes/Database.php @@ -0,0 +1,30 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + } + + return self::$connection; + } +} diff --git a/includes/RateLimiter.php b/includes/RateLimiter.php new file mode 100644 index 0000000..2a1a3d5 --- /dev/null +++ b/includes/RateLimiter.php @@ -0,0 +1,65 @@ +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, + ]); + } +} diff --git a/includes/Srp6.php b/includes/Srp6.php new file mode 100644 index 0000000..c9af693 --- /dev/null +++ b/includes/Srp6.php @@ -0,0 +1,65 @@ + $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); + } +} diff --git a/includes/Turnstile.php b/includes/Turnstile.php new file mode 100644 index 0000000..757a85a --- /dev/null +++ b/includes/Turnstile.php @@ -0,0 +1,37 @@ + $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; + } +} diff --git a/includes/bootstrap.php b/includes/bootstrap.php new file mode 100644 index 0000000..f0fcf5f --- /dev/null +++ b/includes/bootstrap.php @@ -0,0 +1,19 @@ + 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; diff --git a/includes/config.php b/includes/config.php new file mode 100644 index 0000000..10825dc --- /dev/null +++ b/includes/config.php @@ -0,0 +1,82 @@ += 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'), + ], +]; diff --git a/includes/functions.php b/includes/functions.php new file mode 100644 index 0000000..af381af --- /dev/null +++ b/includes/functions.php @@ -0,0 +1,87 @@ + $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 []; +} diff --git a/includes/srp6_selftest.php b/includes/srp6_selftest.php new file mode 100644 index 0000000..1019455 --- /dev/null +++ b/includes/srp6_selftest.php @@ -0,0 +1,44 @@ +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); diff --git a/public/assets/background.jpg b/public/assets/background.jpg new file mode 100644 index 0000000..9718b3e Binary files /dev/null and b/public/assets/background.jpg differ diff --git a/public/assets/style.css b/public/assets/style.css new file mode 100644 index 0000000..51f6aaa --- /dev/null +++ b/public/assets/style.css @@ -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; + } +} diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..a8999bf --- /dev/null +++ b/public/index.php @@ -0,0 +1,72 @@ + + + + + + +<?= $siteName ?> + + + + + + +
+
+ +

AzerothCore
by Minkey

+
+ +
+

Jak začít hrát

+ +
    +
  1. +
    +

    Stáhni si herního klienta

    +

    Server běží na verzi .

    + + Stáhnout klienta + +

    Odkaz na stažení

    + +
    +
  2. +
  3. +
    +

    Uprav realmlist

    +

    Otevři soubor v instalační složce klienta:

    + World of Warcraft\Data\<lokalizace>\realmlist.wtf +

    Smaž jeho obsah a nahraď tímto řádkem:

    + set realmlist +

    Soubor ulož a nech ho tak — klient se pak připojí na můj server místo oficiálního Blizzardu.

    +
    +
  4. +
  5. +
    +

    Vytvoř si účet

    +

    Zaregistruj se a přihlas se rovnou do hry.

    + Vytvořit účet +
    +
  6. +
  7. +
    +

    Spusť Wow.exe a hraj

    +

    Přihlas se svým novým účtem a vyraž do Azerothu.

    +
    +
  8. +
+
+
+
+ + diff --git a/public/register.php b/public/register.php new file mode 100644 index 0000000..a19fb00 --- /dev/null +++ b/public/register.php @@ -0,0 +1,110 @@ + 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; diff --git a/public/registrace.php b/public/registrace.php new file mode 100644 index 0000000..108d1cc --- /dev/null +++ b/public/registrace.php @@ -0,0 +1,113 @@ + + + + + + +Registrace účtu — WoW server + + + + + + + + + +
+
+ ← Zpět na úvod + +

Registrace účtu

+
+ + +
+ +
+ + + +
+
    + +
  • + +
+
+ + + +
+ + + + + + + +

Pouze písmena A-Z a číslice, znaků.

+ + + + + + + + + + + +
+ + + +
+ +
+
+ +