v1.0.2-beta
2026-06-12
Latest
Beta
Security release. A full self-audit of the codebase turned up a handful of real issues. The most serious: X-Forwarded-For was trusted as the client IP — spoofable, which bypassed the IP allowlist and brute-force lockout. Read-only mode could be slipped past via the EXPLAIN endpoint and comment-prefixed writes. A stored XSS lived in the process list. And 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 force_https can no longer lock you out. No breaking changes — drop-in upgrade from 1.0.1-beta.
Security
X-Forwarded-For is no longer trusted by default. It was consulted before REMOTE_ADDR, so on a direct deployment any client could send the header to spoof their IP — bypassing the IP allowlist, defeating brute-force lockout (rotate the header → unlimited attempts), and poisoning the activity log. 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. Empty by default, which means forwarded headers are ignored entirely.
- Read-only mode hardened. Stacked queries are blocked at the driver (
MYSQL_ATTR_MULTI_STATEMENTS=false), so EXPLAIN SELECT 1; DROP TABLE x can't smuggle a write through the explain endpoint. The explain_query endpoint gained the read-only guard every other write path already had. And isWriteQuery() now strips leading comments before keyword-matching and catches writing CTEs (WITH … DELETE …) — both previously slipped past as non-writes.
- Stored XSS in the process list fixed. Every field in the process-list row was HTML-escaped except the database name, which can legally contain markup. A database named with an
onerror payload would execute when an admin opened the Processes tab. Now escaped like the rest.
- DSN parameter injection guarded. The
db request parameter is rejected if it contains ; or control characters before being interpolated into the PDO DSN.
- TOTP codes can no longer be replayed. A valid 2FA code was accepted for its full ±1 step (~90s) 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 the system temp directory into the web-protected
logs/ directory — the old location is world-readable on shared hosts.
- Legacy plaintext credentials auto-upgrade to bcrypt. A hand-edited plaintext password in
config.php still 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, so every saved-query action returned a 500 / malformed JSON. The whole feature was dead. 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 — and because the redirect was a permanent 301, browsers cached it indefinitely, so even fixing config.php didn't stop the auto-redirect until the cache was cleared. Now: the redirect is a 302; the setting can only be enabled from an already-HTTPS session; and the Settings checkbox is disabled over plain HTTP with an inline explanation.
- Latent
ReferenceError in the old update-banner code. A fallback path referenced an undefined repo variable. Never triggered in practice but would have crashed if it had.
New features
- Keyboard-shortcut overlay rebuilt. The
? overlay now has live search (matches key labels, descriptions, and section names — sql pulls in the whole SQL Editor section), a single-column layout that gives descriptions the full row width, platform-aware key labels (⌘ ⇧ ⌥ ⏎ on Mac), command-palette Esc behavior (first Esc clears search, second closes), an empty state, 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, with light (
#16a34a) and dark (#4ade80) variants selected via prefers-color-scheme, plus a 32px PNG fallback and a 180px apple-touch-icon. Wired into the main UI, installer, login, and 2FA head sections.
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 for 24h, fetches server-side via cURL (with a file_get_contents fallback), serves stale cache if the upstream is down, and sends no cookies or referer. One outbound request per install per day instead of one per browser per day. Runs before the DB connection, so the banner still works when MySQL is down.
- 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, pulse dot, theme-driven colors across all 20 themes). Security updates get a distinct amber treatment and omit the dismiss button. Respects
prefers-reduced-motion.
Deployment
- nginx config shipped. The repo only carried an
.htaccess, so on nginx none of the file protections applied and GET /logs/queries.log would serve the query history as plaintext. Added nginx.conf.example mirroring the Apache rules, plus a deny-all logs/.htaccess as belt-and-suspenders.
.gitattributes added pinning all text files to LF, so Windows checkouts stop producing phantom line-ending churn in diffs.
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 is always 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 — the limit is PHP↔MySQL round-trip latency. Use the
mysql CLI (mysql < dump.sql) for multi-gigabyte dumps.
v1.0.1-beta
2026-05-19
Beta
Hardening release. First public bug-report cycle 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 SQL imports that wraps them in transactions for a 5–20× speedup on large dumps. No breaking changes — drop-in upgrade from 1.0.0-beta.
New features
- Live progress UI for SQL imports. Replaces the previous "form submit, wait two minutes, hope for the best" experience. Streams real progress through four phases: file upload (real bytes-uploaded / total via
XMLHttpRequest.upload.onprogress), pre-scan to count statements (gives the bar a real denominator), execution (rate-limited NDJSON events ~10/sec showing X / Y statements · N errors · MM:SS), and inline result rendering with no page reload. Falls back to the original synchronous form submit if JavaScript isn't available.
- Fast import mode. Toggle on the SQL import form, default ON. Disables
foreign_key_checks and unique_checks for the duration of the import, wraps DML statements in transactions, and commits 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 — earlier transactions (at previous CREATE/ALTER/DROP boundaries) remain. The result card reports this honestly so users know which tables are in place after an abort. Turn off for per-statement isolation if needed.
- Multi-row INSERT exports. Exports now produce
INSERT INTO t (cols) VALUES (...),(...),(...); form, batched every 500 rows or ~4 MB. Cuts statement count and file size by ~500× compared to the previous one-INSERT-per-row format. Resulting dumps import dramatically faster, especially with fast mode on.
- Password generator and strength meter in the installer. Generate button uses
crypto.getRandomValues() to produce a 20-character password from a 78-char alphabet (avoids ambiguous chars like 0/O, 1/l/I, and shell-unfriendly chars like \ ' ` $). Fills both password fields, auto-reveals them, copies to clipboard. Live strength meter shows a 5-segment bar (red → green) and a 4-rule visible checklist. Submit button stays disabled until the password is strong enough and matches the confirmation.
- Reveal-eye toggle on both password fields in the installer.
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. Cause: 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.
- Database connection errors during install are now 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.
- Installer step navigation is now guarded. Direct GET access to
?step=2 or ?step=3 (via the back button, manual URL editing, or a stale bookmark) reliably falls back to step 1 instead of rendering a blank form. Only POST submissions can advance through the installer.
- 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 (accent green) with a soft pulsing dot, 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
- New
Database::splitSqlStatementsStreaming() — refactored variant of the existing tokenizer that returns [completeStatements, remainder, currentDelimiter]. When the parser encounters an unterminated statement, quoted string, comment, or DELIMITER directive at the end of its input, it stops and returns the unconsumed portion as the remainder, to be prepended to the next chunk.
- New
Database::executeSqlDumpFromFile() streams the file from disk, executes each statement as it's parsed. Takes an optional progress callback and an optional fast flag.
- New
Database::countStatementsInFile() — fast pre-scan that runs the streaming parser in count-only mode.
- New
Database::streamTableData() — unbuffered cursor + multi-row INSERT batching. Replaces the old fetchAll-based exportTable() for HTTP export paths.
- New
Database::streamTableCsv() — companion CSV variant.
- New
ajax.php action import_stream streams NDJSON progress events. Drains output buffers, sends X-Accel-Buffering: no for nginx, calls set_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
mysql CLI directly (mysql < dump.sql), which is significantly faster than anything PHP can achieve.
upload_max_filesize and post_max_size in php.ini still cap what PHP will accept regardless of any code-level improvements. The default is often 8–100 MB on shared hosts. Raise these in php.ini for larger uploads, or use the CLI for very large imports.
v1.0.0-beta
2026-05-11
Beta
First public release. Ledger is now available for general use. Previously developed privately as
DBForge between December 2025 and May 2026, now rebranded and 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. 428 references swept across the codebase in three case forms. New logo (brackets framing three horizontal bars). JS globals
DBForge.* are now Ledger.*. Install path moves to the repo root. Cookies and localStorage keys formerly prefixed dbforge_ are now ledger_. Existing users log in once and re-pick their theme.
New features
- Multi-statement execution in the SQL editor. Paste a full migration with multiple
CREATE TABLE, INSERT, ALTER statements separated by semicolons. Each 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.
- DELIMITER directive support. The splitter handles
DELIMITER // blocks correctly, so stored procedure definitions with BEGIN ... END// bodies parse cleanly.
- USE works in the SQL editor. Paste
USE mydb; CREATE TABLE foo (...); INSERT INTO foo ... and it executes correctly. Previously the second and third statements ran against the wrong database because each statement reconnected and lost the USE context.
- Bootstrapping scripts work. Paste
CREATE DATABASE new; USE new; CREATE TABLE t (...); and it runs end-to-end. Previously the initial connection to new failed because the database didn't exist yet.
- Index management overhaul. Composite indexes display on a single row with kind badges (PRIMARY/UNIQUE/INDEX/FULLTEXT/SPATIAL). Add Index form with click-to-select column picker and numbered order badges. Per-index Drop button with confirmation.
- phpMyAdmin-compatible export style. New style selector below the mode radio. The phpMyAdmin format produces a 4-pass output (tables → data → indexes → constraints) that imports cleanly on servers where
FOREIGN_KEY_CHECKS can't be disabled.
- ER diagram Reset button. Red button with confirmation deletes the saved layout for the current database and rebuilds from auto-layout against the live schema. Useful when tables have been added or removed since the layout was saved.
Fixed
- Multi-statement read-only enforcement.
isWriteQuery() previously only checked the first keyword in the input, so a read-only user pasting USE foo; DROP TABLE bar; would have the DROP slip through. Every statement in the batch is now checked.
- Stale ER diagram schema. AJAX responses were being cached indefinitely because no cache-control headers were sent. Schema changes (adding/removing tables) didn't reflect in the diagram without a hard refresh. Added
Cache-Control: no-store, Pragma: no-cache, and Expires: 0 globally to all 70+ AJAX endpoints.
Internal
- New
Database::executeQueries() routes single statements through the existing fast path (backward-compatible) and multi-statement input through a per-statement loop with aggregated results. Connects once and reuses one PDO across all statements in the batch, preserving session state.
- New public
Database::splitSqlStatements() handles backtick-quoted identifiers, doubled-quote string escapes ('it''s'), hash comments (#), DELIMITER directives, and pure-comment lines. The old private splitter is removed; the executeSqlDump importer uses the new one transparently.
- When input contains
CREATE DATABASE, DROP DATABASE, or USE, the batch connects without a target database to avoid "unknown database" errors at connect time. If the original URL had a database set, a USE is issued after connect to restore intended context.
USE, CREATE/DROP DATABASE, CREATE/DROP SCHEMA, and SET now route through PDO::exec() instead of prepare() + execute(). PHP-PDO mishandles prepared USE on some MySQL versions.
- Added
WITH (CTE) to the SELECT detector so CTEs are recognized as resultset queries.
Project infrastructure
SECURITY.md — vulnerability disclosure policy with supported versions table, in-scope / out-of-scope rules, and responsible-disclosure expectations.
CONTRIBUTING.md — bug-report expectations, what won't be merged (no Composer, no npm, no build step), code style by language.
CODE_OF_CONDUCT.md — Contributor Covenant 2.1, rewritten in plain language with a maintainer-accountability paragraph.
- Issue and PR templates under
.github/ — structured bug-report and feature-request forms, PR checklist that catches no-dependencies and no-build-step rules, config that disables blank issues and routes security reports to the private advisory channel.
.github/FUNDING.yml scaffold for optional sponsor links (commented out — uncomment to activate).
- Repository structure flattened. Git repo root and install root are now the same directory. Previously a wrapper
ledger/ subfolder forced users to dig in after extracting a release zip.
Development history
Dec 2025 – May 2026
Development before the public 1.0.0-beta release is preserved in the git commit log and tag list. The project was originally developed under the name
DBForge between December 2025 and May 2026 before being rebranded to Ledger for public release. Earlier internal alpha versions (1.x-alpha series) are not documented in the public changelog. See
commit history if you want the full archaeological record.