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:
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
.env
|
||||||
|
data/*.sqlite
|
||||||
@@ -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í 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
|
||||||
|
```
|
||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
*.sqlite
|
||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
Require all denied
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Require all denied
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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'),
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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\<lokalizace>\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>
|
||||||
@@ -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;
|
||||||
@@ -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">← 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>
|
||||||
Reference in New Issue
Block a user