A drop-in PHP web panel for L2J Final Epilogue game servers. Player dashboard, voting, donations, multi-language support, and a two-tier admin system โ all in a single self-contained folder.
Dark fantasy aesthetic ยท CSRF-protected ยท Mobile-first ยท Zero JS dependencies for core flows
- Features
- Screenshots
- Requirements
- Quick Start
- Configuration
- Voting (Hopzone)
- Donations
- reCAPTCHA
- Internationalization
- File Layout
- Database Schema
- Security
- Customization
- Troubleshooting
- License
Public landing page
- Live server-status banner (TCP probe to game-server port, falls back to DB-reachable)
- Hall of Fame (top 5 by level), Top Voters of the week
- Configurable hero copy, server rates, and feature toggles โ all editable from the admin UI
- Inline login form, register CTA
- Auto-detected language with manual override (7 locales)
Player dashboard
- Account summary: total Adena across all characters, character count, highest level
- Per-character roster with level, class, PvP/PK, Adena, online status
- Auto-detects active character if multiple on the account
Voting
- Hopzone integration with three verification modes:
trust(no check),json_api,text_api - Per-account cooldown (default 12h, configurable)
- Item rewards delivered directly to the chosen character's inventory
- Audit log + per-week top voters leaderboard
Donations
- PayPal (Smart Buttons SDK with server-side capture verification)
- Stripe (hosted Checkout + HMAC-verified webhooks)
- Editable package catalog (CRUD in admin UI โ price, item, quantity, sort order)
- Three layers of fraud protection (see Anti-fraud guarantees)
- Audit log of every donation attempt (paid, pending, failed, mismatch)
Two-tier admin
- Admin (accessLevel โฅ 7): ban/unban, character moderation, edit non-sensitive settings
- Super-Admin (accessLevel โฅ 100): promote/demote other admins, edit payment & reCAPTCHA keys, delete accounts
- Visual lock indicators (๐) on restricted UI; server-side gating regardless of UI state
Internationalization
- 7 languages: English (default), Spanish, Russian, Portuguese, French, German, Slovak
- Browser-language auto-detection via
Accept-Languageheader - Manual override stored in 1-year cookie
- Easy to add more languages โ just drop a file in
lang/
Security
- CSRF tokens on every state-changing form (
hash_equalscomparison) - POST โ redirect โ GET to prevent resubmission
- Session regeneration on login
- Constant-time password comparison
- Webhook signature verification (Stripe HMAC; PayPal server-side capture)
Live previews ship with the zip. Open
index-{lang}-preview.htmlin any browser to see each language render without booting PHP.
| Public landing | Player dashboard | Admin overview |
|---|---|---|
| Hero with login + status banner | Adena ledger + character roster | Ban / promote / character moderation |
| PHP | 7.4 or newer (8.x recommended) |
| PHP extensions | pdo_mysql, curl, json, mbstring, openssl |
| Database | MySQL 5.7+ / MariaDB 10.3+ โ same DB the L2J server uses |
| Web server | Apache 2.4+ or nginx 1.18+ with PHP-FPM |
| HTTPS | Required for production (Stripe/PayPal webhooks won't fire on HTTP) |
# 1. Drop the panel folder onto your web root
unzip l2-panel.zip -d /var/www/
# 2. Edit DB credentials
cd /var/www/l2-panel
nano inc/config.php # set DB_HOST, DB_NAME, DB_USER, DB_PASS
# 3. Bootstrap your first super-admin in MySQL
mysql -u root -p l2jdb -e "
UPDATE accounts SET accessLevel = 100 WHERE login = 'your_login';
"
# 4. (Optional) Set ownership for the web user
chown -R www-data:www-data /var/www/l2-panel
# 5. Browse to https://your-domain/
# Sign in with your L2J account โ click 'Admin' in the navThat's it. The custom panel tables (web_settings, web_votes,
web_vote_config, web_donate_packages, web_donations) are created
automatically on first read โ no schema migration needed.
Edit inc/config.php โ the only file you must touch:
define('DB_HOST', '127.0.0.1');
define('DB_PORT', 3306);
define('DB_NAME', 'l2jdb'); // Login server DB (`accounts` table)
define('DB_USER', 'root');
define('DB_PASS', '');
// If your login + game DBs are split, set the game-server schema here.
// Most L2J Final Epilogue setups use one DB โ leave it the same.
define('GAME_DB_NAME', 'l2jdb');Everything else is editable from the admin panel โ no PHP edits required for currency, server status probe, donation packages, voting config, forum URL, reCAPTCHA, or copy.
Sign in as an admin โ click Admin โ Settings. The page is organized into fieldsets:
| Section | What lives here |
|---|---|
| Branding & copy | Site name, hero tagline, footer note, forum URL |
| Game Server | Silver item ID, silver label, game-server host + port for live status probe |
| Server rates | Decorative rate display on the landing page |
| Sections to show | Hall of Fame, Top Voters, Chronicle features, Server rates |
| reCAPTCHA ๐ | Enable + site/secret keys (super-admin only) |
Two access levels, mapped to L2J's accessLevel column:
| Tier | accessLevel | Bootstrap | Can do |
|---|---|---|---|
| Player | 0 | (default) | Login, vote, donate |
| Admin (GM) | โฅ 7 | UPDATE accounts SET accessLevel = 7 WHERE login = 'gm'; |
Ban/unban, reset passwords, character moderation, non-sensitive settings |
| Super-Admin | โฅ 100 | UPDATE accounts SET accessLevel = 100 WHERE login = 'owner'; |
All admin actions plus promote/demote others, edit reCAPTCHA & payment keys, delete accounts |
The constants ADMIN_ACCESS_LEVEL and SUPER_ADMIN_ACCESS_LEVEL in
inc/config.php can be tuned if your L2J fork uses different conventions.
Configure under Admin โ Voting.
| Field | Description |
|---|---|
| Status | Master enable/disable |
| Hopzone URL | Full URL to your server's Hopzone listing |
| Cooldown | Hours between votes per account (default 12) |
| Verify mode | trust / json_api / text_api (see below) |
| API key | If using json_api or text_api |
| Reward item ID | L2J item id (default 57 = Adena) |
| Reward count | Quantity granted per vote |
trustโ claim the reward immediately on return. Easiest setup, but can be gamed by anyone who reloads the claim page.json_apiโ checks Hopzone's JSON API. Recommended.text_apiโ for legacy Hopzone deployments returning plain text.
The verify URL accepts {KEY} and {IP} placeholders that get
substituted at request time.
Configure under Admin โ Donations. The page has four blocks:
- Stat cards โ today / 30-day / all-time totals
- Package catalog โ edit price, item id, count, sort, enabled flag
- Master toggle + currency + thank-you message
- PayPal + Stripe provider blocks (both ๐ super-admin only)
- Stripe Dashboard โ Developers โ Webhooks โ Add endpoint:
https://your-domain/path-to-panel/stripe-webhook.php - Subscribe to event:
checkout.session.completed - Copy the signing secret (
whsec_โฆ) - In the panel: paste your
pk_live_โฆ,sk_live_โฆ, andwhsec_โฆkeys - The "โ Active" indicator turns green when all three are filled
- PayPal Developer Dashboard โ My Apps & Credentials
- Create a REST API app, note the Client ID + Secret
- In the panel: paste them in, choose Sandbox (testing) or Live
- The "โ Active" indicator turns green when both fields are filled
PayPal uses the JavaScript SDK with server-side capture verification โ
no webhook URL required (the browser-side flow is finalized by a server
call from donate-paypal.php).
Three independent layers โ each works on its own:
- Server-generated reference IDs. Clients never propose them โ the panel mints a 24-char random hex per donation attempt.
- Provider transaction-ID dedup. The
web_donationstable has aUNIQUE(provider, tx_id)constraint. Replays fail at the database level. - Amount + currency re-check. Before granting items, the panel re-validates the captured amount and currency against the original package. Mismatches go to status
mismatchand refuse to grant.
The receiving character should ideally be offline when the webhook fires โ L2J's IdFactory race is well known and unrelated to this panel. The thank-you page nudges users in that direction.
Optional Google reCAPTCHA v2 ("I'm not a robot") on login + register.
- Get keys at Google reCAPTCHA admin (use v2 Checkbox)
- Sign in to the panel as a super-admin
- Admin โ Settings โ reCAPTCHA: paste site key + secret key, tick "Enable"
Regular admins see the fieldset locked with a ๐ badge โ they can read but not modify. Server-side gating drops any tampered POSTs.
Supported languages out of the box:
| ๐ฌ๐ง English | ๐ช๐ธ Espaรฑol | ๐ท๐บ ะ ัััะบะธะน | ๐ต๐น Portuguรชs | ๐ซ๐ท Franรงais | ๐ฉ๐ช Deutsch | ๐ธ๐ฐ Slovenฤina |
?lang=de โ cookie l2_lang โ Accept-Language header โ en (default)
The first signal that resolves to a supported locale wins. Explicit
choices via the picker set a 1-year cookie at Path=/, SameSite=Lax.
- Copy
lang/en.phptolang/<code>.php(e.g.lang/it.php) - Translate the values โ leave keys untouched
- Add an entry to
supported_languages()inauth.php:'it' => ['name' => 'Italiano', 'flag' => '๐ฎ๐น'],
Missing keys in any locale automatically fall back to English โ partial translations are safe.
Translation covers player-facing pages only: index, login, register, dashboard, vote, donate, donate-paypal, donate-return. Admin pages remain English by design โ they're a tool for one or two operators, not for end-users.
l2-panel/
โโโ README.md
โโโ style.css โ single-file stylesheet (~2700 lines)
โ
โ โโ Public + player pages (entry points / bookmarkable URLs) โโ
โโโ index.php โ public landing page
โโโ login.php โ sign-in
โโโ register.php โ sign-up
โโโ logout.php
โโโ dashboard.php โ player dashboard (chars + Adena)
โโโ vote.php โ Hopzone voting
โโโ donate.php โ donation package picker
โโโ donate-paypal.php โ PayPal SDK interim page
โโโ donate-return.php โ post-payment landing (Stripe success_url)
โโโ lang.php โ language switcher endpoint
โ
โ โโ Webhooks (URLs registered with Stripe / PayPal โ DO NOT MOVE) โโ
โโโ stripe-webhook.php โ Stripe HMAC-verified webhook
โโโ paypal-confirm.php โ PayPal server-side order capture
โ
โโโ admin/ โ admin pages โ gated by require_admin()
โ โโโ index.php โ server overview
โ โโโ accounts.php โ account moderation (ban/promote/etc)
โ โโโ characters.php โ character moderation
โ โโโ voting.php โ Hopzone config + audit log
โ โโโ donations.php โ Stripe/PayPal config + audit log
โ โโโ settings.php โ site copy, rates, reCAPTCHA, game-server probe
โ
โโโ inc/ โ shared PHP partials (web access denied)
โ โโโ .htaccess โ deny all (Apache 2.2 + 2.4)
โ โโโ config.php โ DB credentials, tier constants, session setup
โ โโโ auth.php โ all helpers โ auth, CSRF, voting,
โ โ donations, settings, i18n, base_url()
โ โโโ nav.php โ shared player+admin navigation
โ โโโ admin-header.php โ admin page chrome (top)
โ โโโ admin-footer.php โ admin page chrome (bottom)
โ
โโโ lang/ โ translation tables (web access denied)
โโโ .htaccess โ deny all
โโโ en.php โ canonical English (181 keys)
โโโ es.php โ Spanish
โโโ ru.php โ Russian
โโโ pt.php โ Portuguese
โโโ fr.php โ French
โโโ de.php โ German
โโโ sk.php โ Slovak
Why this split? Player pages stay at root so existing bookmarks
(/dashboard.php, /vote.php, /donate.php) keep working. Webhook
endpoints stay at root so Stripe/PayPal configurations don't need to be
re-registered. Admin pages move to admin/ so URLs become cleaner
(/admin/accounts.php instead of /admin-accounts.php). Shared partials
move to inc/ with an .htaccess deny rule so they can't be hit
directly via URL โ they're only loaded via require_once from other
pages. Translation tables in lang/ likewise.
All cross-page links use the base_url() helper so the panel works the
same whether deployed at /, /panel/, or any other subdirectory.
The panel reads two existing L2J tables and creates five of its own. Custom tables are created automatically โ no migration script needed.
| Table | Used for |
|---|---|
accounts |
login, password, accessLevel, lastactive, lastIP |
characters |
char list, level, classid, online, pvpkills, pkkills |
items |
summing inventory/paperdoll/warehouse for "total Adena" |
| Table | Purpose |
|---|---|
web_settings |
k/v store for site settings (currency, copy, reCAPTCHA, payment keys) |
web_vote_config |
per-topsite voting config (Hopzone) |
web_votes |
audit log of every vote claim |
web_donate_packages |
editable donation catalog |
web_donations |
every donation attempt with status (pending, paid, failed, mismatch) |
The web_donations table has UNIQUE(provider, tx_id) to prevent
webhook replay attacks at the database level.
| Passwords | L2J's native Base64(SHA-1(plaintext)) โ same hash the game uses, so panel and client share a single credential |
| Constant-time login | verify_account() always runs hash_equals against either the real or a dummy hash, so non-existent accounts take the same time as wrong-password attempts (closes the user-enumeration timing oracle) |
| CSRF | Token in session + hidden form field; hash_equals comparison; X-CSRF-Token header accepted for JSON endpoints |
| Sessions | Cookie hardened with HttpOnly, SameSite=Lax, and Secure on HTTPS (auto-detected including X-Forwarded-Proto); regenerated on login AND register (anti-fixation); name configurable via SESSION_NAME |
| Language cookie | SameSite=Lax + Secure on HTTPS; the lang.php switcher strips CRLF/null from ?return= and rejects non-relative paths to prevent open-redirect and response-splitting |
| POST-redirect-GET | Every admin action redirects after the DB write to prevent resubmission |
| Webhook verification | Stripe HMAC signature with constant-time compare; PayPal server-side order-capture verification |
| Idempotency | UNIQUE(provider, tx_id) on donations + status='paid' short-circuit in fulfill_donation() |
| Self-protection | Admins can't ban/demote themselves; regular admins can't act on super-admins |
| Server-side gating | Tampered POSTs that try to escalate privilege or write super-only fields are dropped silently |
| Output escaping | All user data goes through htmlspecialchars(); placeholder substitution in t() escapes too |
| No raw SQL with user input | All queries use PDO prepared statements |
| No DB-error leakage to users | Public pages (login, register, dashboard, vote, donate) catch PDOException and show a generic "Database error" message; the actual exception is error_log()'d for the admin to inspect |
| CSS browser parity | All backdrop-filter rules paired with -webkit-backdrop-filter so glassmorphism effects render correctly on Safari iOS/macOS |
Most copy is editable in the admin Settings page โ no PHP edits needed.
Single stylesheet at style.css (~2,650 lines). Design tokens at the
top:
:root {
--gold: #d4a857;
--gold-hi: #e6c074;
--gold-lo: #8a6f3a;
--emerald: #6fae7a;
--crimson: #b94a4a;
--bg-0: #07070b;
--bg-1: #0e0d14;
/* โฆmore */
}Tweak these, the rest of the system follows.
L2 Final Epilogue uses Adena (item id 57) by default. To use a custom coin (Donate Coin, Silver Coin, etc.), edit Admin โ Settings โ Game Server โ Silver item ID + Silver label. No file edits required.
Each admin page follows the same template:
<?php
require_once __DIR__ . '/auth.php';
require_admin(); // or require_super_admin() for sensitive ones
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
check_csrf();
// โฆhandle actionโฆ
header('Location: ' . basename(__FILE__));
exit;
}
require __DIR__ . '/admin-header.php';
?>
<h1 class="page-title">Your Page</h1>
<!-- markup -->
<?php require __DIR__ . '/admin-footer.php'; ?>Then add a sub-nav entry in nav.php under $adminItems.
"Database error: SQLSTATE[HY000] [2002] Connection refused"
Your DB_HOST / DB_PORT is wrong, MySQL isn't running, or it's not
listening on TCP. Test with:
mysql -h 127.0.0.1 -P 3306 -u $DB_USER -p $DB_NAME -e 'SELECT 1'"Forbidden โ this page is for game masters only" after I set accessLevel=7
The session is cached. Sign out, sign back in, then try again. The account's accessLevel is checked fresh on each request, but session identity isn't refreshed unless you log out.
The Online/Offline pill always shows OFFLINE
The TCP probe needs a configured host. Go to Admin โ Settings โ Game
Server, fill in gameserver_host (e.g. 127.0.0.1) and
gameserver_port (default 7777), save. The "โ Game server is
responding" indicator below confirms the probe works.
Empty host = probe disabled = falls back to "DB-reachable" check, which will only show OFFLINE if MySQL itself is down.
Stripe payments succeed but the reward never lands
Your webhook isn't reaching the panel. Check:
- HTTPS โ Stripe refuses to deliver to
http:// - Endpoint URL โ must match exactly what's in
Admin โ Donations - Signing secret โ copy from Stripe dashboard, no leading/trailing whitespace
- Server logs โ look for
stripe-webhook.phprequests; 401 = bad signature, 200 = good - Subscribed event โ must include
checkout.session.completed
You can re-trigger a webhook from Stripe's dashboard for any past test payment without making a new charge.
PayPal payments succeed but the reward never lands
PayPal uses server-side capture, not webhooks. If the JS SDK's
onApprove callback fires but the panel doesn't grant the reward, check
the browser console โ paypal-confirm.php returns JSON with the result.
Most common cause: client ID/secret mismatch between what the SDK loads
(public key) and what the server uses (secret key).
"That action requires super-admin privileges" but I'm an admin
Promotion/demotion, account deletion, payment keys, and reCAPTCHA keys are restricted to super-admins (accessLevel โฅ 100). Run:
UPDATE accounts SET accessLevel = 100 WHERE login = 'your_login';Then sign out and back in.
The language switcher doesn't persist between visits
The cookie is l2_lang at Path=/, SameSite=Lax. If your panel lives
at a sub-path and the cookie's Path doesn't match, browsers won't send
it back. The default Path=/ covers all cases โ only change it if you
know what you're doing.
"Cannot delete account โ still has characters"
Intentional. Delete the characters in-game (or via the admin UI's
character moderation page) before deleting the account. This protects
against orphan rows in characters that would otherwise crash the L2J
server on next boot.
Character is online during donation/vote โ they don't see the items
L2J's IdFactory caches inventory in-memory while a character is online. Items inserted via SQL won't appear until the character relogs. The thank-you page already mentions this โ you can pre-empt the support ticket by reminding players up-front in your forum.
MIT โ do whatever you want, attribution appreciated but not required.
Lineage II is a registered trademark of NCSOFT Corp. This panel is an unofficial fan project and is not affiliated with NCSOFT.
PRs welcome. Before submitting:
- Run the audit script in
auth.php(CSRF coverage, brace balance, key parity) - Test responsive layout at 375 / 768 / 1280 px
- If you add UI strings, update all 7 locale files โ
en.phpis the source of truth, missing keys fall back to English but UI consistency suffers
For new features, open an issue first to discuss scope.