Branch: refs/heads/master Home: https://github.com/phpmyadmin/error-reporting-server Commit: 3177efd2a87065be475459966aea896500221dee https://github.com/phpmyadmin/error-reporting-server/commit/3177efd2a87065be... Author: William Desportes <williamdes@wdes.fr> Date: 2026-11-10 (Tue, 11 November 2026) +01:00 Changed paths: A docs/DEPLOYMENT.md M README.md Log Message: ----------- docs: Move the deployment procedure out of the README The README carried the install steps, the requirements, the web server setup, the OAuth and webhook registrations and the cron jobs between two paragraphs of what the service does, and every change to how the server is deployed had to be written into that page: a reader looking for what the service is scrolled through a migration plan. The deployment procedure moves to docs/DEPLOYMENT.md as it stands, with the links to the web server examples made relative to the new file, and the README keeps a paragraph that points at it. Signed-off-by: William Desportes <williamdes@wdes.fr> Co-Authored-By: DeepSeek <noreply@deepseek.com> Commit: 058d146945902ba17d30828ee7313fce7828e590 https://github.com/phpmyadmin/error-reporting-server/commit/058d146945902ba1... Author: William Desportes <williamdes@wdes.fr> Date: 2026-11-10 (Tue, 11 November 2026) +01:00 Changed paths: M src/Controller/AppController.php M src/Mailer/Transport/TestTransport.php M tests/TestCase/Controller/AppControllerPagingTest.php M tests/TestCase/Controller/StatsControllerTest.php Log Message: ----------- docs: Read the docblocks without array shapes Scrutinizer skips a file whose docblock carries an array shape, keyed or not, so the analysis lost those files for nothing. A plain array type says the same thing to the tools that read it. Signed-off-by: William Desportes <williamdes@wdes.fr> Co-Authored-By: DeepSeek <noreply@deepseek.com> Commit: 921b41b609a14e2fa5bec96c45186cc2e857010d https://github.com/phpmyadmin/error-reporting-server/commit/921b41b609a14e2f... Author: William Desportes <williamdes@wdes.fr> Date: 2026-11-10 (Tue, 11 November 2026) +01:00 Changed paths: M src/Report.php M tests/TestCase/ReportTest.php Log Message: ----------- fix: Give every event of a report its own identity getEventId() memoised the id it generated, and getReports() calls it inside the loop that builds one event per error: every event of a report carried the same event_id. A tracker keys its events by that id, so a PHP report with 3 errors stored one event and silently lost the others, and 3800a9dd8f pinned that shared id in a test as if it were the contract. Generate a fresh id per event instead, and assert that the ids differ. That id also has to be a UUID. A tracker validates it once more when it reads back the user report that points at it, and Rustrak answers the page of the issue with "The Rustrak API answered with data this dashboard could not read" — the issue, its stack trace and its feedback are lost because 1 nibble of the id is random. Fix the version and the variant bits of the random bytes, which is the shape every Sentry SDK sends. Signed-off-by: William Desportes <williamdes@wdes.fr> Co-Authored-By: DeepSeek <noreply@deepseek.com> Commit: 9bfa9c96d322430fa3f87ecbfaa7ade9e1b9e7dd https://github.com/phpmyadmin/error-reporting-server/commit/9bfa9c96d322430f... Author: William Desportes <williamdes@wdes.fr> Date: 2026-11-10 (Tue, 11 November 2026) +01:00 Changed paths: A tests/TestCase/Forwarding/RecordingSentry.php M config/forwarding_example.php M docs/DEPLOYMENT.md M phpstan-baseline.neon M src/Controller/IncidentsController.php M src/Forwarding/Sentry.php M tests/TestCase/Controller/IncidentsControllerTest.php M tests/TestCase/Forwarding/SentryTest.php Log Message: ----------- feat: Send the reports to Sentry with the envelope protocol The tracker moved to Rustrak, which implements the sentry SDK protocol: it answers 400 to POST /api/{id}/store/ and has no /api/embed/error-page/, so every forwarded report and every feedback was rejected, and the controller swallowed the errors, which made the forwarding silently dead. Send a sentry envelope instead: a header line that names the event and the time it was sent, then, per item, a header with the length in bytes and the payload. The header carries the public key and nothing else — sentry_secret and sentry_timestamp are gone, with the userpwd option they fed. A 2xx answer is a store; anything else, a redirect included, is a failure with the status and the head of the body in the log. The feedback becomes a user_report item, in an envelope that names its event as the protocol requires, sent once per report instead of once per event, and cut with mb_strcut() so a multi-byte character is never split. Signed-off-by: William Desportes <williamdes@wdes.fr> Co-Authored-By: DeepSeek <noreply@deepseek.com> Commit: 291e852482d41cbe9ac195a80309966c04fab13f https://github.com/phpmyadmin/error-reporting-server/commit/291e852482d41cbe... Author: William Desportes <williamdes@wdes.fr> Date: 2026-11-10 (Tue, 11 November 2026) +01:00 Changed paths: A config/Migrations/20261103120000_add_forwarded_at_to_reports.php A src/Command/ForwardReportsCommand.php A tests/TestCase/Command/ForwardReportsCommandTest.php M docs/DEPLOYMENT.md M src/Controller/IncidentsController.php M src/Forwarding/Sentry.php M tests/TestCase/Controller/IncidentsControllerTest.php Log Message: ----------- feat: Forward the occurrence from a command, not from the request The forwarding ran inside POST /incidents/create, one request per event with up to five seconds each: a slow tracker held the answer to the client and the notification mail after it for up to 200 seconds, and a timeout in the middle left the report stored, unforwarded and unmailed, with the client retrying into a duplicate. Read the pending rows from bin/cake forward_reports instead, mark each with the new forwarded_at column, and stop calling the tracker from the request. An incident is one occurrence of an error and the report groups them, so the column lives on incidents and every occurrence is sent: an issue in the tracker keeps showing that it still happens. A hundred rows are read at a time, after the id of the last one read, since the driver buffers a result. The command runs on a cron, so it stays quiet: the per-row lines are printed by -v, the event ids by --debug — CakePHP has no VERY_VERBOSE and reads -vvv as -v — and a run that finds nothing to do, or another run already going, prints nothing. The flock() on tmp/forward_reports.lock keeps two runs apart and holds the PID of the run that has it. Mark the archive as handled before the cron exists, or the first run forwards every occurrence ever stored, with fresh ids and the run's date: UPDATE incidents SET forwarded_at = created; Signed-off-by: William Desportes <williamdes@wdes.fr> Co-Authored-By: DeepSeek <noreply@deepseek.com> Commit: ae530231da27cd1b84e04a5946f4942b490a849f https://github.com/phpmyadmin/error-reporting-server/commit/ae530231da27cd1b... Author: William Desportes <williamdes@wdes.fr> Date: 2026-11-10 (Tue, 11 November 2026) +01:00 Changed paths: M src/Command/ForwardReportsCommand.php M tests/TestCase/Command/ForwardReportsCommandTest.php Log Message: ----------- fix: Build the forwarded report from the JSON it was stored as The command handed fromObject() a payload it had decoded to arrays: the top level was copied and every message, culprit and frame came out empty. Read it through the JSON it came from, which is what fromString() understands, in one place the test can reach. Signed-off-by: William Desportes <williamdes@wdes.fr> Co-Authored-By: DeepSeek <noreply@deepseek.com> Commit: 4238fc1b30216ca5de7c63b1a031a9e2fab9baa4 https://github.com/phpmyadmin/error-reporting-server/commit/4238fc1b30216ca5... Author: William Desportes <williamdes@wdes.fr> Date: 2026-11-10 (Tue, 11 November 2026) +01:00 Changed paths: M src/Controller/DevelopersController.php M src/Github/GithubApi.php M src/Middleware/AuthenticationMiddleware.php M tests/TestCase/Github/GithubApiTest.php M tests/TestCase/Middleware/AuthenticationMiddlewareTest.php Log Message: ----------- security: Treat a refused GitHub answer as a refusal canCommitTo() answered null for every status but 200 and 404, and the middleware reads null as "the API could not be asked", so it kept the access the session already had: a maintainer whose token GitHub refuses with 401 or 403 kept write access for as long as their session lived. A refusal of both tokens is now false, which is what a 404 already answered. A rate limit stays an open question, since it says nothing about the developer, and the rate limit header tells the two apart. An answer that says nothing is bounded too: past a day without one, the session drops the access instead of keeping it for ever, and the question is asked again sooner than the interval. Signed-off-by: William Desportes <williamdes@wdes.fr> Co-Authored-By: DeepSeek <noreply@deepseek.com> Commit: 96e8439a03af364a2354bb16cf7663955d4e0478 https://github.com/phpmyadmin/error-reporting-server/commit/96e8439a03af364a... Author: William Desportes <williamdes@wdes.fr> Date: 2026-11-10 (Tue, 11 November 2026) +01:00 Changed paths: M src/Controller/GithubController.php M tests/TestCase/Controller/GithubControllerTest.php Log Message: ----------- security: Escape the report values in the issue body tableCell() escaped the pipes, the line breaks and the mentions, and sent the angle brackets through: GitHub renders raw HTML in a comment and in a table cell alike, so a report whose error name was <img src=x onerror=…> ran it in the page of whoever read the issue, and <https://…> became a link. Escape both brackets in the one place the two paths share, and assert on the body itself rather than on the helpers: reverting the call sites left the helper tests green. Signed-off-by: William Desportes <williamdes@wdes.fr> Co-Authored-By: DeepSeek <noreply@deepseek.com> Commit: 4f7d867b63689ba2e9eb032d1c7d93329e0939a0 https://github.com/phpmyadmin/error-reporting-server/commit/4f7d867b63689ba2... Author: William Desportes <williamdes@wdes.fr> Date: 2026-11-10 (Tue, 11 November 2026) +01:00 Changed paths: A src/Middleware/TrustedProxiesMiddleware.php A tests/TestCase/Middleware/TrustedProxiesMiddlewareTest.php M config/app.default.php M docs/server-setup.md M src/Application.php Log Message: ----------- security: Read the trusted proxies from the configuration Security.trustedProxies was documented and never read: ServerRequest::$trustProxy is the flag the framework checks, nothing set it, so clientIp() always answered the address of the proxy and the list had no effect. A middleware reads it now, and the documentation says what the framework really does with it: it compares the header against the list, it does not check that the connection comes from a proxy, so the empty default is the safe value and an operator can only judge with that in hand. Signed-off-by: William Desportes <williamdes@wdes.fr> Co-Authored-By: DeepSeek <noreply@deepseek.com> Commit: 32e23fe6c3b4033e57b5fb044e600d029edc8cd3 https://github.com/phpmyadmin/error-reporting-server/commit/32e23fe6c3b4033e... Author: William Desportes <williamdes@wdes.fr> Date: 2026-11-10 (Tue, 11 November 2026) +01:00 Changed paths: A config/Migrations/20261103140000_add_forwarded_at_to_incidents.php M README.md M config/app.default.php M docs/DEPLOYMENT.md M docs/server-setup.md M phpstan-baseline.neon M src/Command/ForwardReportsCommand.php M src/Controller/AppController.php M src/Controller/Component/GithubApiComponent.php M src/Controller/IncidentsController.php M src/Forwarding/Sentry.php M src/Github/GithubApi.php M src/Middleware/AuthenticationMiddleware.php M src/Model/Table/IncidentsTable.php M src/Report.php M src/View/Helper/IncidentsHelper.php M tests/TestCase/Command/ForwardReportsCommandTest.php M tests/TestCase/Controller/AppControllerPagingTest.php M tests/TestCase/Controller/GithubControllerTest.php M tests/TestCase/Controller/IncidentsControllerTest.php M tests/TestCase/Forwarding/RecordingSentry.php M tests/TestCase/Forwarding/SentryTest.php M tests/TestCase/Github/GithubApiTest.php M tests/TestCase/Middleware/AuthenticationMiddlewareTest.php M tests/TestCase/ReportTest.php D config/Migrations/20261103120000_add_forwarded_at_to_reports.php Log Message: ----------- security: Keep the access current, and the identity of the client The permission is asked again every half day rather than every quarter of an hour: a refusal is answered as soon as it is asked, so a long interval costs no security and saves the token budget the issue creation needs. The client address is what salts the identity of a report, and moving the forwarding to a command took the request away from it: the command rebuilds the report from what the ingest stored, where no address is left. The ingest stores the salted hash of it with the payload — the hash, never the address, since the class goes out of its way to make the address unfindable — and the identity is read from there, or every user of a browser shares one identity. A repeat of the same error adds an incident to the existing report, and that incident is forwarded: the stored payload is narrowed to the error it was built from, or every occurrence would replay all of them. Security.trustedProxies is documented as what the framework does with it: the list holds the addresses of the proxies, which are removed from the X-Forwarded-For chain to leave the client, not the addresses of the clients. Signed-off-by: William Desportes <williamdes@wdes.fr> Co-Authored-By: DeepSeek <noreply@deepseek.com> Commit: 5737477b5af349a28de25be77f437123a2a7f723 https://github.com/phpmyadmin/error-reporting-server/commit/5737477b5af349a2... Author: William Desportes <williamdes@wdes.fr> Date: 2026-11-10 (Tue, 11 November 2026) +01:00 Changed paths: A config/Migrations/20261109120000_add_report_lookup_indexes.php M phpstan-baseline.neon M src/Model/Table/IncidentsTable.php M src/Model/Table/NotificationsTable.php Log Message: ----------- perf: Index the report lookups and read the developers once Every report submission looks for the report of a nearby error before it creates one, on location, line and version, and nothing indexed that: on a table of a hundred thousand reports the lookup is a full scan, measured at 86 to 115 ms per error, so a submission of forty errors spends seconds in scans alone. Index the three columns, with a prefix on the text one, and index related_to, which the related reports are read on. The ingest also read a row back only to get the report id it had just built the incident with, and read every developer again for each report it created — one scan and one query per developer, per report. Pass the id along and read the developers once per submission. Signed-off-by: William Desportes <williamdes@wdes.fr> Co-Authored-By: DeepSeek <noreply@deepseek.com> Commit: cea52613fe450f0c27776d61053933a544b49285 https://github.com/phpmyadmin/error-reporting-server/commit/cea52613fe450f0c... Author: William Desportes <williamdes@wdes.fr> Date: 2026-11-10 (Tue, 11 November 2026) +01:00 Changed paths: M src/Command/ForwardReportsCommand.php M tests/TestCase/Command/ForwardReportsCommandTest.php Log Message: ----------- fix: Send the error an incident was built from, or nothing The match compared the line of the incident with the line of the error, and both sides were wrong: incidents have no linenumber column, and the payload names its line lineNum, not line. Both sides were therefore always 0, so the match degenerated to "the first error of that file". A report with two errors in one file sent the first one twice and never sent the second, which is exactly the case where the tracker needs both. Compare the three fields the ingest copies out of the error — file, type and message — cut to the width of their columns, so the comparison is exact. An incident whose error cannot be found sent the whole submission instead, which is one event per error for a single occurrence, with fresh ids each time. Send nothing and log it: the tracker must not receive the same data again. Signed-off-by: William Desportes <williamdes@wdes.fr> Co-Authored-By: DeepSeek <noreply@deepseek.com> Commit: a942123c538c414f1b5cab24114a7f35dab20778 https://github.com/phpmyadmin/error-reporting-server/commit/a942123c538c414f... Author: William Desportes <williamdes@wdes.fr> Date: 2026-11-10 (Tue, 11 November 2026) +01:00 Changed paths: A src/Forwarding/TrackerThrottledException.php A src/Forwarding/TrackerUnreachableException.php M src/Forwarding/Sentry.php M tests/TestCase/Forwarding/SentryTest.php Log Message: ----------- perf: Reach the tracker on one connection, and give up when it is gone The transport opened a cURL handle for every event, so a backlog paid a TCP and TLS handshake per event; keep one handle for the run and reset it at the end. A tracker that does not answer then cost the full timeout of every event before the run gave up: after three failed connections in a row the run now stops, logs it and exits with an error, instead of spending its whole budget chewing through the backlog. A 429 is not a refusal of the payload either: it says the tracker wants to be left alone, so it stops the run as well, and carries the delay the tracker asked for so the next run can respect it. Signed-off-by: William Desportes <williamdes@wdes.fr> Co-Authored-By: DeepSeek <noreply@deepseek.com> Commit: debe9a7d17cee8750f2b6d784e526b87c973741d https://github.com/phpmyadmin/error-reporting-server/commit/debe9a7d17cee875... Author: William Desportes <williamdes@wdes.fr> Date: 2026-11-10 (Tue, 11 November 2026) +01:00 Changed paths: M src/Github/GithubApi.php M src/Middleware/AuthenticationMiddleware.php Log Message: ----------- perf: Do not spend the shared token on a rate-limited answer When GitHub answered a rate limit, the permission check asked again with the token of the server: the same budget the issue creation needs, for an answer that would say the same thing. Return "could not ask" instead and log it. A check that could not be asked was retried sixty seconds later, per session, so a GitHub outage turned into two calls a minute per signed-in developer, half of them on the shared token. Wait a quarter of an hour. Signed-off-by: William Desportes <williamdes@wdes.fr> Co-Authored-By: DeepSeek <noreply@deepseek.com> Commit: 52c34293e96e0bb61cfc31f48a5755720816e749 https://github.com/phpmyadmin/error-reporting-server/commit/52c34293e96e0bb6... Author: William Desportes <williamdes@wdes.fr> Date: 2026-11-10 (Tue, 11 November 2026) +01:00 Changed paths: M docs/DEPLOYMENT.md M src/Controller/IncidentsController.php Log Message: ----------- docs: Say what a refused incident does, and what the mail prints The deployment guide and a comment claimed a refused incident produces no stderr output, and that only a transport failure reaches it. Both are false: the failure is logged through the cron_jobs scope, which the command's console log engine echoes to stderr, so a cron mails it on every run while the incident stays pending. The comment on the notification mail listed the two incident lists and the related reports as values nobody printed. Two of them are not read by the template, the similar fields are. Signed-off-by: William Desportes <williamdes@wdes.fr> Co-Authored-By: DeepSeek <noreply@deepseek.com> Commit: c3ce540cfc4fd5bb919ce861b0f85d8f89570319 https://github.com/phpmyadmin/error-reporting-server/commit/c3ce540cfc4fd5bb... Author: William Desportes <williamdes@wdes.fr> Date: 2026-11-10 (Tue, 11 November 2026) +01:00 Changed paths: M src/Model/Table/IncidentsTable.php Log Message: ----------- security: Bound the line number and the version an incident stores Two client values reached a column of a fixed width with nothing to stop them: a line number of ten digits overflows int(11), and a version with a suffix keeps it — "5.2.1-" followed by anything passes the version rules on its numeric prefix, and getStrippedPmaVersion() stores the suffix too. On a server in strict mode the insert is refused and the request answers 500; on one that is not, the value is silently truncated to something that no longer matches the rule it passed. Read the line through one clamp, used by the lookup and by the stored value so they keep agreeing, and measure the version that is stored rather than the prefix that was accepted. The rejected version is also logged escaped and cut: a newline in it forged a line of the cron log, and a long one filled the disk before the body cap. Signed-off-by: William Desportes <williamdes@wdes.fr> Co-Authored-By: DeepSeek <noreply@deepseek.com> Commit: 9116ae3e6d4f45b4f4cf4a07b4d963366fce2c6d https://github.com/phpmyadmin/error-reporting-server/commit/9116ae3e6d4f45b4... Author: William Desportes <williamdes@wdes.fr> Date: 2026-11-10 (Tue, 11 November 2026) +01:00 Changed paths: M src/Report.php M src/View/Helper/IncidentsHelper.php Log Message: ----------- security: Survive a payload whose values are not the shape it claims Three client-controlled values reached code that assumes a shape they do not have, and each one took a page down or poisoned the queue: - a stack frame that is a string, not a frame: getStacktrace() read a field of it before checking, which is a TypeError, so any maintainer opening the incident or its report got a 500 — and the incident can be attached to a report whose location is public in its GitHub issue. Skip anything that is not a frame. - an internal____ key this class does not declare: it was assigned to a typed property, which throws, so the forwarder could never send that incident and retried it for ever. Only the declared properties are read, and any other internal____ key is dropped. - a message that is a list or an object: decode() was typed to a string and threw while rendering. Read any value, and answer an empty string. A version without a minor part also warned while building the tags, which a payload without pma_version triggers. Signed-off-by: William Desportes <williamdes@wdes.fr> Co-Authored-By: DeepSeek <noreply@deepseek.com> Commit: c45840e0fefc66952511ade8ee221c2393c43e93 https://github.com/phpmyadmin/error-reporting-server/commit/c45840e0fefc6695... Author: William Desportes <williamdes@wdes.fr> Date: 2026-11-10 (Tue, 11 November 2026) +01:00 Changed paths: M src/Controller/GithubController.php M src/Controller/IncidentsController.php M tests/TestCase/Controller/GithubControllerTest.php Log Message: ----------- security: Refuse a big body early and escape the issue cell fully The size cap compared the request body after reading all of it, so a body of hundreds of megabytes was buffered in memory before the 413 — which is the failure an oversized body is trying to cause, and post_max_size does not bound a JSON body. Refuse on the announced length before reading anything. In the issue body, a backslash in a value escaped the escape that keeps its pipe inside the cell, a link or an image in a cell was still live and fetched by whoever read the issue, and an entity-encoded at sign notified just as the plain one did. Double the backslash first, escape the brackets, and neutralise the encoded at sign. Signed-off-by: William Desportes <williamdes@wdes.fr> Co-Authored-By: DeepSeek <noreply@deepseek.com> Commit: b3e323fd573ad44d882b46f9ccad0c7e0b25f22d https://github.com/phpmyadmin/error-reporting-server/commit/b3e323fd573ad44d... Author: William Desportes <williamdes@wdes.fr> Date: 2026-11-10 (Tue, 11 November 2026) +01:00 Changed paths: M docs/DEPLOYMENT.md Log Message: ----------- docs: Say what a deployment has to do now that forwarding is a command The deploy steps stopped at the migrations, and the three things that make the migration safe were only described in the section about the command: the model cache keeps the schema from before the migration, every stored incident looks like it is waiting to be forwarded, and nothing reaches the tracker until the cron exists. A deployment that follows the list as it was written stops forwarding in silence. Signed-off-by: William Desportes <williamdes@wdes.fr> Co-Authored-By: DeepSeek <noreply@deepseek.com>