Repository navigation
Releases
Release v1.1.1-beta
Full Changelog: v1.0.2-beta...v1.1.1-beta
Release v1.0.2-beta
Ledger 1.0.2-beta is a security and polish release. A full self-audit of the codebase turned up a handful of real issues — the most serious being that X-Forwarded-For was trusted as the client IP (spoofable, which bypassed the IP allowlist and brute-force lockout), that read-only mode could be slipped past via the EXPLAIN endpoint and comment-prefixed writes, and that the Saved Queries feature was outright broken by a dropped function signature. All fixed here.
On top of the security work: the keyboard-shortcut overlay was rebuilt with live search, the update check moved to a server-side proxy, the update notification became a corner toast, a proper favicon set shipped, and the force_https setting can no longer lock you out.
The feature set from 1.0.1-beta is unchanged. No breaking changes. If you're on 1.0.1-beta, this is a drop-in upgrade.
🔒 Security
X-Forwarded-For is no longer trusted by default
getClientIp() consulted X-Forwarded-For and X-Real-IP before REMOTE_ADDR. On a direct deployment those headers are fully attacker-controlled, so the value Ledger used as "the client IP" could be spoofed — which defeated three things at once:
- IP allowlist — send
X-Forwarded-For: <an-allowlisted-ip>and walk straight through it. - Brute-force lockout — rotate the header each request and the per-IP counter never trips, so login attempts are effectively unlimited. (Spoofing a victim's IP could also lock the victim out.)
- Audit log — the recorded source IP was whatever the attacker chose.
Forwarded headers are now honored only when the request originates from an IP in the new trusted_proxies config list, and the left-most entry is validated with filter_var(..., FILTER_VALIDATE_IP). The list is empty by default, which means forwarded headers are ignored entirely. Configure it under Settings → Trusted Proxies if Ledger sits behind a reverse proxy.
Read-only mode hardened
Three gaps closed:
- Stacked queries are blocked at the driver (
MYSQL_ATTR_MULTI_STATEMENTS=false), soEXPLAIN SELECT 1; DROP TABLE xcan't smuggle a write through the explain endpoint. - The
explain_queryAJAX action gained the same read-only guard every other write path already had — it previously accepted raw SQL with no check. isWriteQuery()now strips leading SQL comments before keyword-matching and recognizes writing CTEs (WITH … DELETE …). Both/* */ DELETE …andWITH x AS (…) DELETE …previously slipped past as non-writes.
The keyword heuristic is still a heuristic; for a hard boundary, run Ledger with a grant-limited read-only MySQL user.
Stored XSS in the Processes view
Every field in the process-list row was HTML-escaped except the database name, which can legally contain markup (CREATE DATABASE accepts almost anything in backticks). A database named with an onerror payload would execute when an admin opened the Processes tab. Now escaped like the rest. The shared escapeHtml helper in ledger.js was also aligned with the other two copies to escape single quotes.
Other hardening
- DSN parameter injection guarded. The
dbrequest parameter is rejected if it contains;or control characters before being interpolated into the PDO DSN. - TOTP codes can't be replayed. A valid 2FA code was accepted for its full ±1 step (~90 s) window with no record of use. The matched time-step is now burned per-user, so a captured code can't be reused within its window.
- Lockout state moved out of system temp. Failed-attempt tracking lived in
sys_get_temp_dir(), world-readable on shared hosts. It now lives in the web-protectedlogs/directory. - Legacy plaintext credentials auto-upgrade to bcrypt. A hand-edited plaintext password in
config.phpstill logs in once, then is silently rewritten as a bcrypt hash so it never persists as plaintext.
🛠 Fixed
Saved Queries no longer fatals
A dropped function ledger_saved_queries_file(): string signature left the function undefined while the rest of the file still parsed. Every saved-query action (save, list, update, delete) returned a 500 / malformed JSON — the whole feature was dead on arrival. Restored.
force_https can no longer lock you out
Enabling "Force HTTPS redirect" on a server with no working HTTPS listener (e.g. default XAMPP, no 443) previously 301'd every request to an unreachable https:// URL. Because the redirect was a permanent 301, browsers cached it indefinitely — so even after fixing config.php the browser kept auto-redirecting out of its own cache until cleared.
Three fixes: the redirect is now a 302; the setting can only be enabled from an already-HTTPS session (proving HTTPS works before committing to it); and the Settings checkbox is disabled over plain HTTP with an inline explanation, so you learn about the constraint before clicking Save.
Latent ReferenceError in the update banner
A fallback path in the old banner-rendering code referenced an undefined repo variable. Never triggered in practice (the upstream always supplied a URL) but would have crashed if it hadn't.
✨ New Features
Keyboard-shortcut overlay rebuilt
The ? overlay was a full rewrite:
- Live search at the top — matches key labels, descriptions, and section names (searching
sqlpulls in the entire SQL Editor section), autofocused on open. - Single-column layout so descriptions get the full row width and no longer wrap mid-phrase.
- Platform-aware keys —
⌘ ⇧ ⌥ ⏎on Mac (detected vianavigator.platform), with a hint line saying which set is shown. - Command-palette Esc behavior — first Esc clears the search, second closes the overlay.
- Empty state when search returns nothing, and a mobile-stacked layout under 600px.
Favicon set
Previously no favicon link existed in any head section, so browsers showed the default globe. Ships the three-bar ledger mark on a rounded-square green background:
- Light (
#16a34abackground, white bars) and dark (#4ade80background, dark bars) variants, selected viaprefers-color-scheme. - 32px PNG fallback for older browsers, 180px apple-touch-icon for iOS home-screen pins.
- Wired into all four head sections: main UI, installer, login, and 2FA verification.
🎨 Changed
Update check runs through a server-side proxy
The browser previously fetched tryledger.dev/api/version.json directly via CORS, which failed silently whenever a CDN stripped the CORS headers — and exposed every admin's IP to the endpoint. The browser now calls a same-origin ajax.php?action=check_update, handled by a new UpdateCheck class that:
- Caches the upstream response in
logs/version-cache.jsonfor 24 hours. - Fetches server-side via cURL (with a
file_get_contentsfallback) when stale, sending no cookies and no referer. - Serves stale cache marked
stale:trueif the upstream is down, so the banner survives an outage. - Runs before the DB connection, so the notification still works when MySQL is down.
Net effect: one outbound request per install per day instead of one per browser per day, and the same-origin fetch can't be blocked by CORS or stripped by a CDN.
Update notification is now a toast
The full-width banner that pushed page content down was replaced with a small top-right toast — slide-in animation, pulse dot, theme-driven colors across all 20 themes. Security updates get a distinct amber treatment and omit the dismiss button (security updates demand action, not dismissal). Respects prefers-reduced-motion.
📦 Deployment
- nginx config shipped. The repo only carried an
.htaccess, so on nginx none of the file protections applied —GET /logs/queries.logwould serve the query history as plaintext. Addednginx.conf.examplemirroring the Apache rules, plus a deny-alllogs/.htaccessas belt-and-suspenders. .gitattributesadded pinning all text files to LF, so Windows checkouts stop producing phantom LF↔CRLF churn in diffs.
⚙️ Internal
Auth::getClientIp()— defaults toREMOTE_ADDR; only reads forwarded headers from a configuredtrusted_proxiesIP, taking and validating the left-most entry.Auth::isWriteQuery()— strips leading comments and detects writing CTEs before the keyword match.LedgerTOTP::verifyAt()— new method returning the matched time-step, used byAuthto burn used steps and reject replays.Database::connect()— setsMYSQL_ATTR_MULTI_STATEMENTS=falseand validates thedbname against DSN injection.UpdateCheck— new class backing the server-side update proxy with disk caching and a stale-cache fallback.
⚠️ Known Issues
- The read-only keyword heuristic isn't a hard boundary. It now resists the known bypasses, but classifying SQL by string-matching in PHP will always be best-effort. For an enforced boundary, connect Ledger with a read-only MySQL account.
- Very large imports are still slow even with fast mode on. Carried over from 1.0.1-beta — a 1.6M-statement dump projects ~10–15 minutes on a typical server. The limit is PHP↔MySQL round-trip latency; use the
mysqlCLI (mysql < dump.sql) for multi-gigabyte dumps. upload_max_filesizeandpost_max_sizeinphp.inistill cap what PHP will accept. Raise these for larger uploads, or use the CLI.
🚀 Install
cd /path/to/htdocs
git clone https://github.com/ClearanceClarence/Ledger.git ledgerOpen http://localhost/ledger/ and follow the 3-step installer.
Or download the Source code (zip) below, extract it into your web root, and open it in a browser.
For Docker, see [DOCKER.md](https://github.com/ClearanceClarence/Ledger/blob/main/DOCKER.md).
🔄 Upgrading from 1.0.1-beta
No breaking chan...
Release v1.0.1-beta
Ledger 1.0.1-beta is the first hardening release after the public 1.0.0-beta launch. First contact with real-world usage uncovered three real problems: the installer was too forgiving of bad input, post-login redirects didn't preserve the user's destination, and exports/imports broke on databases larger than the default memory_limit. This release fixes all three.
Also brings a quality-of-life redesign of the header info chips and an honest Fast mode toggle for the SQL import that wraps it in transactions for a 5–20× speedup on large dumps.
The feature set from 1.0.0-beta is unchanged. No breaking changes. If you're on 1.0.0-beta, this is a drop-in upgrade.
✨ New Features
Live progress UI for SQL imports
Replaces the previous "form submit, wait two minutes, hope for the best" experience. The new flow streams real progress through four phases:
- Upload — real bytes-uploaded / total via
XMLHttpRequest.upload.onprogress - Counting — quick pre-scan to give the bar a real denominator
- Importing —
X / Y statements · N errors · MM:SS, updated ~10 times per second - Done — inline result card, no page reload
NDJSON streaming (one JSON object per line) on the response side, rate-limited to ~10 updates/sec to avoid saturating the network on tiny dumps. Falls back to the original synchronous form submit if JavaScript or FormData isn't available.
Fast import mode
New toggle on the SQL import form, default on. Wraps DML statements in transactions and disables foreign_key_checks / unique_checks for the duration of the import, committing at each DDL boundary.
- Typical speedup is 5–20× on INSERT-heavy dumps because per-statement fsyncs become per-transaction fsyncs.
- On error, the current transaction rolls back and the import aborts. Tables committed at earlier
CREATE/ALTER/DROPboundaries remain. The result card explains this honestly so users know which tables are in place after an abort. - Advanced users can turn the toggle off for per-statement isolation.
Multi-row INSERT exports
Exports now produce INSERT INTO t (cols) VALUES (...),(...),(...); form instead of one INSERT per row. Batched every 500 rows or ~4 MB (whichever first, staying safely under max_allowed_packet).
- Cuts exported statement count and file size by ~500×.
- Resulting dumps import dramatically faster, especially with Fast mode on.
Password generator and strength meter in the installer
The admin-account step on step 2 of the installer now has:
- Generate button — produces a 20-character password via
crypto.getRandomValues()from a 78-char alphabet that avoids ambiguous chars (0/O,1/l/I) and shell-unfriendly chars (\'`$). Fills both password fields, auto-reveals them, and copies to clipboard. - Live strength meter — 5-segment bar (red → green) with a 4-rule visible checklist (length, max length, not a common password, not equal to username).
- Reveal-eye toggle on both password fields.
- Submit button is disabled until the password is strong enough and matches the confirmation.
🛠 Fixed
Post-login redirect now preserves the destination URL
Visiting /?db=astrahedron&tab=sql while logged out previously redirected to the login screen correctly, but successful login landed the user on / instead of their intended URL. The cause was an interaction with the session cookie's SameSite=Strict flag that sometimes prevented the cookie from being delivered on the capturing GET request.
Fix: pass the return URL through both the session AND a hidden form field, prefer POST over session. Open-redirect protection unchanged — rejects full URLs, protocol-relative URLs, absolute paths, scheme markers, control characters, auth-flow actions, and anything over 2048 chars.
Installer is now more forgiving (and stricter)
- Database connection errors are actionable. Instead of
Connection failed: SQLSTATE[HY000] [1045] Access denied for user 'rooting'@'localhost' (using password: NO), the installer translates common SQLSTATE codes into plain English with specific hints — wrong password vs empty password, DNS failure vs connection refused vs timeout, SSL handshake issues, bind-address restrictions. - Step navigation is guarded. Direct GET access to
?step=2or?step=3(back button, manual URL editing, stale bookmark) reliably falls back to step 1 instead of rendering a blank form. Only POST submissions can advance. - Password rules are stricter and visible. Minimum 8 characters (was 6), maximum 72 characters (bcrypt's truncation limit — was unlimited, so users could think they had a 256-char password when bcrypt only saw the first 72), banned against a ~100-entry list of most-leaked passwords, username can't equal password. All enforced server-side; the client-side meter is advisory only.
SQL exports stream instead of buffering
All four export routes (export_sql, export_csv, single-statement export_db, phpMyAdmin-compatible export_db) now stream rows directly to output via an unbuffered PDO cursor. Memory stays constant regardless of table size. Previously a ~300 MB database would exhaust memory_limit before any output was sent.
SQL imports stream from disk
The uploaded file is read in 64 KB chunks via fopen / fread instead of file_get_contents(), and the statement parser is now resumable across chunk boundaries (handles partial strings, comments, and DELIMITER directives at chunk edges). The result array is capped at 50 success + 100 error entries with a truncated flag; total counts remain accurate.
For a 12 MB / 100K-statement test dump, peak memory dropped from ~30 MB to ~145 KB.
🎨 Changed
Header info chips redesigned
Pill shape replaces rounded rectangles. Each chip type carries a --chip-accent CSS variable that colors only its icon:
- Server chip (accent green) with a soft pulsing dot to telegraph "connection healthy"
- Database version (info blue)
- Database count (purple)
- Uptime (warning amber)
- PHP version (neutral, dimmed)
Uses theme variables throughout, so all 20 themes pick up the styling automatically via the base+overlay theme architecture. Respects prefers-reduced-motion.
⚙️ Internal
Database::splitSqlStatementsStreaming()— refactored variant of the existing tokenizer that returns[completeStatements, remainder, currentDelimiter]. When the parser encounters an unterminated statement, quoted string, comment, orDELIMITERdirective at the end of its input, it stops and returns the unconsumed portion as the remainder.Database::executeSqlDumpFromFile()— streams the file from disk, executes each statement as it's parsed. Takes an optional progress callback and an optionalfastflag.Database::countStatementsInFile()— fast pre-scan that runs the streaming parser in count-only mode.Database::streamTableData()— unbuffered cursor + multi-row INSERT batching. Replaces the oldfetchAll-basedexportTable()for HTTP export paths.Database::streamTableCsv()— companion CSV variant.ajax.phpactionimport_stream— streams NDJSON progress events. Drains output buffers, sendsX-Accel-Buffering: nofor nginx, callsset_time_limit(0).
⚠️ Known Issues
- Very large imports are still slow even with fast mode on. A 1.6M-statement dump projects ~10–15 minutes of run-time on a typical server even after the transactional batching wins. The fundamental limit is round-trip latency between PHP and MySQL. Users importing multi-gigabyte dumps should use the
mysqlCLI directly (mysql < dump.sql), which is significantly faster than anything PHP can achieve. upload_max_filesizeandpost_max_sizeinphp.inistill cap what PHP will accept regardless of any code-level improvements. The default is often 8–100 MB on shared hosts. Raise these inphp.inifor larger uploads, or use the CLI for very large imports.
🚀 Install
cd /path/to/htdocs
git clone https://github.com/ClearanceClarence/Ledger.git ledgerOpen http://localhost/ledger/ and follow the 3-step installer.
Or download the Source code (zip) below, extract it into your web root, and open it in a browser.
For Docker, see [DOCKER.md](https://github.com/ClearanceClarence/Ledger/blob/main/DOCKER.md).
🔄 Upgrading from 1.0.0-beta
No breaking changes. To upgrade:
cd /path/to/ledger
git pullOr for zip-based installs: extract the new release over your existing files (keep config.php and the logs/ directory).
No database migrations. No config changes required. Existing sessions, saved queries, ER diagram layouts, and query history are preserved.
If you have Check for updates enabled (default), 1.0.0-beta installs will see a banner pointing at this release within 24 hours.
Pure PHP + vanilla JS. Zero dependencies. Drop-in install.
Full changelog: [CHANGELOG.md](https://github.com/ClearanceClarence/Ledger/blob/main/CHANGELOG.md)
Release v1.0.0-beta — first public release
Ledger is now publicly available. Previously developed privately as DBForge between December 2025 and May 2026, now rebranded and released for general use.
The feature set is complete and tested in active development, but this is the first version validated outside of internal use. Expect rough edges on PHP versions, MariaDB forks, and hosting environments other than the development setup. Please file issues for anything that breaks.
🎉 Rebrand: DBForge → Ledger
- New name and identity. 428 references swept across the codebase in three case forms.
- New logo. Brackets framing three horizontal bars — a book/ledger metaphor. Available as a square mark and full wordmark.
- JavaScript globals.
DBForge.*is nowLedger.*. - PHP constant.
DBFORGE_VERSIONis nowLEDGER_VERSION. - Install path. Moves to the repo root after the directory flatten (see below).
- Storage keys. Cookies and
localStorageformerly prefixeddbforge_are nowledger_. Existing users log in once and re-pick their theme.
✨ New Features
Multi-statement SQL execution
Paste a full migration with multiple CREATE TABLE, INSERT, and ALTER statements separated by semicolons. Each statement runs in sequence with its own result card showing row counts, errors, and timing. Execution stops on the first failure — use START TRANSACTION / COMMIT for atomicity.
DELIMITERdirectives parse correctly, so stored procedure definitions withBEGIN ... END//bodies work as expected.USEworks mid-script. PasteUSE mydb; CREATE TABLE foo (...); INSERT INTO foo ...and the second and third statements run against the right database. Previously each statement reconnected and lost theUSEcontext.- Bootstrapping scripts work end-to-end.
CREATE DATABASE new; USE new; CREATE TABLE t (...);runs cleanly because the connection no longer fails on an unknown-database error.
Index management
The Indexes panel on the Structure tab got a complete overhaul.
- Composite indexes display on a single row with their columns comma-separated.
- Kind badges: PRIMARY (gold), UNIQUE (blue), INDEX (purple), FULLTEXT and SPATIAL (purple with type label).
- Add Index form with type dropdown (PRIMARY / UNIQUE / INDEX / FULLTEXT / SPATIAL), optional name, and a click-to-select column picker with numbered order badges.
- Per-index Drop button with confirmation. PRIMARY KEY drops get extra-cautious wording.
phpMyAdmin-compatible SQL exports
The database SQL export got a new Style selector below the mode radio.
- Single-statement — existing behavior, everything inline in one CREATE TABLE.
- phpMyAdmin-compatible — 4-pass output: tables → data → indexes → constraints. Byte-for-byte interchangeable with phpMyAdmin's own dumps. Imports cleanly on servers where
FOREIGN_KEY_CHECKScan't be disabled.
ER diagram improvements
- Reset button (red, with confirmation) deletes the saved layout for the current database and rebuilds from auto-layout against the live schema.
- Stale-schema bug fixed. AJAX responses were being cached indefinitely, so adding or removing tables didn't reflect in the diagram without a hard refresh. Added global no-cache headers to all 70+ AJAX endpoints.
Update notifications
Logged-in users see a banner in the admin UI when a newer Ledger release is available.
- Browser-side check — the request goes from your browser to
https://tryledger.dev/api/version.jsonat most once every 24 hours. No server-side phone-home; the installed PHP makes no outbound calls. - Security releases get a distinct, non-dismissible banner so they stand out from routine updates.
- Per-version dismissal. Dismissing a banner only suppresses that version — the next release shows up normally.
- Opt-out under Settings → Check for updates (default on). The endpoint sends no parameters, no cookies, and only sees what a standard web access log records for any HTTP request. Full disclosure in SECURITY.md.
🔒 Security
- Read-only enforcement is now multi-statement-aware. Previously
isWriteQuery()only checked the first keyword, so a read-only user pastingUSE foo; DROP TABLE bar;would have the DROP slip through. Every statement in a batch is now checked. - Privacy disclosure formalized.
SECURITY.mdnow has a dedicated Privacy & network behavior section documenting exactly what data leaves your install (nothing from PHP; one optional client-side request totryledger.dev/api/version.jsonif update checks are enabled).
📦 Repository Structure
The directory layout was flattened. The git repo root and the install root are now the same directory.
- Before: clone produced a
Ledger/folder containing aledger/subfolder containing all the code. - After: clone produces a
Ledger/folder you can drop directly into your web root. - Docker users can now run
docker-compose upfrom the repo root with nocdinto a subdirectory.
📚 Community Files
The standard open-source community file set is now in place:
SECURITY.md— vulnerability disclosure policy, supported versions, in-scope/out-of-scope rules, plus the new privacy sectionCONTRIBUTING.md— bug-report expectations, what won't be merged, code styleCODE_OF_CONDUCT.md— Contributor Covenant 2.1, rewritten in plain language.github/ISSUE_TEMPLATE/— structured bug report and feature request forms.github/pull_request_template.md— PR checklist.github/FUNDING.yml— sponsor-link scaffold (commented out)
⚠️ Migration Notes
This is the first public release, so most readers won't be upgrading from anything. For anyone who had internal DBForge installs:
- Install path changed. Move your install or extract this release into a fresh directory.
- Cookies and localStorage prefix changed from
dbforge_toledger_. Log in once and re-pick your theme. - Custom JavaScript that calls
DBForge.confirm()etc. needs updating toLedger.confirm().
Database connection settings, saved queries, ER diagram layouts, and query history are preserved (they live in logs/, which is unchanged).
🚀 Install
cd /path/to/htdocs
git clone https://github.com/ClearanceClarence/Ledger.git ledgerOpen http://localhost/ledger/ and follow the 3-step installer.
Or download the Source code (zip) below, extract it into your web root, and open it in a browser.
For Docker, see DOCKER.md.
Pure PHP + vanilla JS. Zero dependencies. Drop-in install.
Full changelog: CHANGELOG.md