# Phase 4 — Security Audit and Wiki Regex

Arc Post-Mortem Summary. Covered the two security-facing items the v10 to-do left before release: a full security audit (item 31) and an audit and rewrite of the wiki-page parsing regexes (item 16). Two live-site bugs were tackled first because they were knocking real servers over: the bulk-email bot mailbox scan that hung the media updater on pollett.org, and an out-of-memory crash of the web server on yioop.com. Phase 4.1 (auth, cookie, and header hardening) and Phase 4.2 landed here; the arc closed 2026-07-10.

Arc started 2026-06-29, after Phase 3.75 closed the authentication work. This arc covers the two security-facing items the v10 to-do leaves before release: a full security audit (item 31) and an audit and rewrite of the wiki-page parsing regexes (item 16). Two live-site bugs are also in flight and are tackled first, since they are knocking real servers over: the bulk-email bot mailbox scan that hangs the media updater on pollett.org, and an out-of-memory crash of the web server on yioop.com. This is an initial plan; the audit items will gain detail as each is opened. Marker legend: ✓ done, ▶ next, ? decision to make, unmarked = planned.

Phase 4 closed 2026-07-10: the security audit and the wiki-parser rewrite are complete, the last step being the JavaScript help.js port with per-case JavaScript unit tests. The out-of-memory work logged here is the defensive clamp; the remaining out-of-memory attention and a separate pollett.org MailServer.php crash carry into Phase 4.5, along with the Server Settings split.

## Plan

1. Run a full security audit before v10 release (item 31). Several strands, some already started in earlier arcs:
   1. ✓ Crypto. `crawlHash` (`Utility.php`) is md5 with its halves XOR-folded to eight bytes and is wired into the binary index formats and 164 callsites, so it cannot be swapped wholesale. A sibling `crawlAuthHash($string)` returning `hash_hmac('sha256', $string, AUTH_KEY)` was added and the CSRF token was moved to it in earlier work; the remaining job is to find and migrate the other security-relevant callsites while leaving every indexing callsite on `crawlHash`. In progress: the audit found six AUTH_KEY-keyed callsites. The git app-code token, and now the secret-ballot FORM_HASH, csv_form_hash, and page_hash, are moved to `crawlAuthHash` (real HMAC-SHA256); generate and verify were migrated together so pairs stay consistent, and the ballot verify now compares with `hash_equals`. The two user-folder-name hashes are kept on `crawlHash` — changing them would rename every existing user's data folder — but the duplicated folder-and-prefix computation was pulled into a single `UserModel::userFolderAndPrefix` method. AUTH_KEY generation now comes from a CSPRNG (`base64Hash(random_bytes(AUTH_KEY_NUM_BYTES))`) instead of a hash of the work directory and time. A follow-up removed the pointless inner `crawlHash` from the FORM_HASH tamper check: it now HMACs the page directly (`crawlAuthHash($tmp_page)`) so a tampered form must forge a full SHA-256, not just collide an 8-byte fold. The CSV form-hash path was strengthened the same way: its content fingerprint (embedded in the `[{form-hash}]` placeholder at save in GroupModel and recomputed at verify in SocialComponent) moved from the 8-byte `crawlHash` fold to a full `hash("sha256", ...)`. Both sides hash the page body (`page_objects` holds the parsed body, head split off separately), so save and verify stay in step; WikiElement still just HMACs whatever fingerprint is embedded, so it was untouched. Existing ballot form pages carry the old fingerprint until re-saved, which is acceptable for the current low-traffic window.
   2. ✓ Constant-time comparison. The CSRF check in `Controller::checkCSRFToken` still compares with `==`; switch it to `hash_equals` so a token cannot be guessed by timing. Found already done in the earlier CSRF-to-`crawlAuthHash` work: the check uses `hash_equals` over `crawlAuthHash`. The ballot-form-hash comparison touched above was switched to `hash_equals` at the same time.
   3. ✓ Drop dead crypto fallbacks. Now that the PHP floor is well past 8.0, remove the `mcrypt_create_iv` and `mt_rand` branches from `crawlCrypt`.
   4. ✓ Response and cookie hardening. Confirm the session cookie sets HttpOnly, Secure, and SameSite, and add a Content-Security-Policy and `X-Content-Type-Options: nosniff`. Done: the session cookie now sets `HttpOnly` and `SameSite=Lax` (new `SAME_SITE_COOKIE` config, threaded through index.php and the atto server's `setCookie`/`sessionStart`, which also now emits `Secure` over HTTPS); a `frame-ancestors 'self'` CSP and `nosniff` were already set centrally in index.php for every response. The session cookie is the only cookie the codebase sets, so the sweep covered it all.
   5. ✓ Input and output paths. Spot-check that request values pass through `Controller::clean`, that view and element echoes are escaped, and that file uploads are checked for path traversal, MIME type, and executable content. Spot-check done: request values go through `Controller::clean`, whose `file_name` type already neutralizes `..`; no view or element echoes a raw `$_REQUEST` value; upload base names are reduced with `pathinfo` BASENAME. One gap closed: `getGroupPageResourcesFolders` (the upload write path) did not strip `..` from its sub-path the way its URL sibling does, so that strip was added. Uploaded files are served by `ResourceController` with `readfile`/`fread` and never `include`d or `eval`ed, so an uploaded script is downloaded, not run — executable content is mitigated by how resources are served rather than by an extension blocklist.
   Decided: the cookie and header hardening lands here in Phase 4.1, not deferred to Phase 5, and the sweep goes as wide as the codebase allows — every cookie- and header-setting path was checked, not just one spot.
2. Audit and rewrite the wiki-page parsing regexes (item 16). The current wiki markup is parsed by a stack of regular expressions that are fragile and carry two kinds of risk: crafted markup that slips script past them (XSS), and patterns that backtrack catastrophically on hostile input (ReDoS). The job is to build an adversarial corpus of wiki source that exercises both, then either harden the individual patterns or replace them with a small tokenizer and parser. This overlaps with the input/output strand of item 31. ? Decide, when opening this item: whether to harden the existing regexes in place or move to a tokenizer, which is the larger but more durable change. Decided: move to a tokenizer and parser. A block scanner reads the source into a block tree, an inline lexer runs over each block's text, and the resulting tree renders to escaped html with a url-scheme allowlist at the leaves, so nesting is correct to any depth and the xss and redos classes are closed by construction. The same design is written twice, in php (WikiParser) and in js (help.js parseWikiContent, today the weaker of the two), and covers both the mediawiki-style and the markdown engines; the markdown syntax, a later student-added bolt-on meant to track github markdown, also gains its own help page. Opening findings: the nesting failures are real and demonstrated (a fourth list level renders as a literal star, a table inside a list item stays literal, a link inside a heading is not stripped for the table of contents and breaks the heading); the xss and redos risks, by contrast, were not reproducible with direct probes (base-address prefixing, a scheme allowlist, entity escaping, and fast pcre all held), so the durable win here is correct nesting, with the security classes closed as a structural bonus. First step, in place: a golden corpus of wiki source captured through today's parser as per-case reference output, with a parity test, so the rewrite changes show up page by page as reviewed diffs. The steps, in order:
   1. ✓ Golden corpus regression net capturing today's output for both engines.
   2. Grow the new parser inside WikiParser as parse2 and its helper methods, beside the old parse so it can be tried on real pages, until it renders the whole mediawiki syntax. It decodes the entity-encoded text the controller hands the parser, so it is a drop-in for parse. The pieces, done and remaining:
      1. ✓ Paragraphs and blank-line breaks.
      2. ✓ Headings with anchor ids.
      3. ✓ Horizontal rules.
      4. ✓ Colon indents.
      5. ✓ Ordered, unordered, and mixed lists nesting to any depth.
      6. ✓ Pre blocks and nowiki masking.
      7. ✓ Bold, italic, and bold-italic emphasis.
      8. ✓ Links with a url-scheme allowlist.
      9. ✓ The allowed inline html tags.
      10. ✓ Tables: caption, headers, cells, and class and style only.
      11. ✓ Styling templates (center, left, right, class, id, style) nesting to any depth.
      12. ✓ Table of contents from the tree, fixing the link-in-heading case.
      13. ✓ Named-transclusion templates ({{Main|…}}, {{See also|…}}, Hatnote).
      14. ✓ Toggle blocks ({{toggle|…}}, {{block|…}}, {{end-block}}).
      15. ✓ Resources: ((resource:…)) and the chart, CSV, and QR family, passed through for the component to substitute as the old parser does.
      16. ✓ Math wrapped for the math renderer.
      17. ✓ The reference list, cite tags to footnotes.
   3. ✓ Switch parse over to parse2 and delete the old regex engine. Done: parse's mediawiki branch now runs the tree engine and the old regex engine is gone (parseMediawiki, processRegexes, makeReferences, the cite, nowiki, pre, and link callbacks, cleanLinksAndParagraphs, and the matches and replaces tables with their config — about 490 lines). parse2 and its helpers were renamed to drop the "2". Markdown stays on the old parseMarkdown until step d; fetchLinks and the link tables stay. The 13 mediawiki corpus references were regenerated from the new engine; the 3 markdown references are unchanged.
   4. ✓ Add the markdown engine as a MarkdownParser class, sharing a Parser base only if the code sharing is real, and give markdown its own help page. Done: the sharing turned out real, so rather than a separate class the markdown engine runs on the one cursor-based WikiParser — both engines share the MarkUpScanner, the block and inline template readers, the table-of-contents builder, and the reference and footnote collection. Markdown was filled out to more of GitHub's flavor (indented code, reference-style links, footnotes, and the full delimiter-stack emphasis algorithm) and its table of contents was put on the wiki path's footing. Markdown got its own help page, Markdown Syntax, mirroring the wiki Syntax page, and the syntax-summary link now points at it for a group set to the markdown engine and at the wiki Syntax page for a mediawiki group. Along the way the `{{search:...}}` box was made a parser token, so it is left alone inside a nowiki example and its form is no longer wrapped in a paragraph (which was invalid html); the recent-places dropdown was unwrapped the same way. A follow-up closed a related gap: a display-time token such as `[{recent_places}]` shown as an example inside nowiki or a pre block now has its opening broken so it stays literal, instead of being drawn as its widget, since those widget swaps run over the finished html and cannot see that the token sat inside nowiki.
   5. Port the finished design to js (help.js parseWikiContent), checked against the php output on the same corpus. As a first step the help panel was made draggable by its title bar and resizable from its corner, since a reader often wants it moved out of the way of the page beneath it or made larger; the panel switches to fixed positioning on the first drag and is kept inside the window, and a press on the close control still closes it. Because the title bar was made sticky so it stays in view while the help text scrolls, the close control was moved inside that bar and given an explicit stacking order above the title so it sits on top of the bar; a sticky bar paints above a floated sibling, which had hidden the control behind the bar. The parser port itself is now done: the cursor-based scanner and the mediawiki engine of the wiki parser were ported to help.js, replacing the old regular-expression parseWikiContent and parseLists with a thin wrapper of the same name that drops any head-variable section, parses the body against this group's read url, and turns the cross-group markers and resource references the parser leaves into links the way the wiki view does. It matches the server parser byte for byte on all thirteen mediawiki corpus references and on the real wiki Syntax page.
3. ✓ Self-host the developer site: a git-repository wiki page type. Retire seekquarry.com's old viewgit and MantisBT by making a wiki page type that turns a page into a browsable, pushable git repository whose discussion is the issue tracker. Self-contained: the server reads the bare repo with a pure-PHP object and pack reader and never runs git; clients clone, pull, and push over dumb WebDAV confined to the page's resource folder; permissions inherit the group, so read access allows clone and edit access allows push. Queued behind the audit and regex items above.
   1. ✓ Pure-PHP loose-object reader: refs, and commit, tree, and blob parsing, with size-bounded inflation and name-verified reads.
   2. ✓ Pure-PHP packfile reader: index lookup, offset- and name-based delta resolution, and malformed-pack bounds.
   3. ✓ Git page type, with bare-repo creation on save hardened never to overwrite an existing repository.
   4. ✓ Model layer for serving: safe file lookup, with info/refs and objects/info/packs built on demand (the reader lists all refs and peels annotated tags).
   5. ✓ Unit tests for the reader, pack reader, and model, built entirely in PHP.
   6. ✓ Read and clone: a git component on the group controller routes `/<group_id>/<page>.git/...` to the model, checks group read access (a repository is only as cloneable as its group is readable, gated exactly as the resource controller gates a file), and serves the bytes. Confirmed end to end: a real `git clone` of a repository in the public group pulls the commit, tree, and blob and checks out; the same repository placed in a non-public group is refused with not-found.
   7. ✓ Push: the WebDAV write methods (OPTIONS, PROPFIND, MKCOL, PUT, MOVE, DELETE, LOCK, UNLOCK) reach the git component through catch-all routes on the WebSite for those verbs, with the router's method table, its request-line parser, and its longest-method length all widened to admit them. A write request must carry Basic auth whose user owns the group, is root, or has full member access; reads stay anonymous. Each verb acts on the repository folder through a confined write path that refuses to climb out with "..". The PROPFIND reply advertises exclusive write locks so the client turns on locking, and request bodies are read from the server's content buffer rather than the input stream, which a long-running server process does not fill. Verified end to end: a real `git push` creates a branch and updates it on a second push, a fresh clone pulls both commits and their files back, an unauthenticated or wrong-password write is refused, and the confined path blocks every traversal. A follow-up made push fast: basic auth resends the credentials with every object, and the deliberately slow password hash was being run on all of a push's hundreds of requests (about a quarter second each, over a minute of hashing for a large push). A successful check is now remembered in the running server's memory for `GIT_AUTH_CACHE_TIMEOUT`, keyed by a hash of the credentials, so a push hashes once instead of hundreds of times; a wrong password still pays the full hash, and nothing is written to disk.
   8. ✓ Read-mode browser: a git repository page now opens in read mode as a file browser instead of reading as a missing page.
      1. ✓ Clone instructions: the read view shows the address a visitor clones the repository from. The clone address is aligned to the end of the line — the right in a left-to-right locale, the left in a right-to-left one — so it sits under the clone button that reveals it.
      2. ✓ Read-view overhaul. The clone panel now shows the full `git clone <address>` command with a copy-to-clipboard button and sits above the path. The path drops its last crumb's link (it is where you already are) and no longer repeats the current file's name below itself; a download control floats to the end of the path line that saves just the resource in view — a file as its raw bytes, a folder as a tar.gz of that subtree. Each commit and tag row's actions are now icons (browse, diff, tar.gz, zip) rather than words, and a new diff action shows a commit's changes: every file it added, removed, or changed with a line-by-line comparison, built from a new `GitRepository::treeDiff` and the existing `diff` utility. The Date, Author, Message (and Tag) headers of the commit and tag list are clickable to sort the whole bounded history server-side, and a magnifying-glass button by the clone toggle reveals a search field: on a folder view it filters the visible tree in the browser, on a commit or tag list it searches message, author, and date on the server.
      3. ✓ Read-view follow-up fixes: the tar.gz row action uses a clamp icon and the commit-diff action a </> glyph; the branch dropdown is normalized so its label matches the ref button's size; the path row is hidden again at the repository root and its text is sized to the file list rather than smaller.
      4. ✓ Read-view public-access fix. Browsing a Git repository page while signed out (or with a stale form token) sent the folder listing back for every file and sub-folder link, because the group controller, when it rebuilds a token-less request from its list of fields that are safe to keep, was dropping the repo_* fields the read view steers by. Those read-only navigation fields now sit in that keep list, so a signed-out visitor browses files, sub folders, commits, tags, diffs, downloads, sorting, and search exactly as a signed-in one does. In the same pass the read view reads every request field it uses once, at the top of each method, instead of reaching into the request here and there, so it is plain what a visitor can steer; the clone-command line no longer re-escapes a value that reaches it already escaped. Separately, formatting the size of a brand-new zero-length resource no longer logs a math warning.
      5. ✓ Git wiki page presentation pass. On the read view the clone control and its text field were removed (clone details now live on the edit and source views), and the download arrow beside a sub-folder path was sized to match the breadcrumb text. On the edit and source views of a Git repository page the wiki editing area is replaced by a Git Controls heading with the repository's clone line; the page keeps only its settings and page-list icons, and the page history link is hidden. Ordinary wiki pages are untouched: every one of these changes is guarded so it applies only when the page is a Git repository.
      6. ✓ Git repository push codes. A person editing a Git repository wiki page is now shown a clone line that carries their own name and a personal application code, which stands in for their account password when pushing. The code is kept in the private database and shown again on each visit; a refresh control lets them mint a fresh one by giving their password and choosing how long it should last (a day, week, month, or year). An expired code is reported rather than shown. The clone line now reads from the normal side for the writing direction rather than being pushed to the right, and the copy control is a clipboard icon rather than the word. A new private table holds the codes, added to fresh installs and to existing ones through a database upgrade.
      7. ✓ Git push code editing polish. The Git repository edit page now always shows a working clone line: before a push code exists it shows the plain clone address together with a note that a code must be made to push, since making one needs the person's password. The refresh, copy, and create controls are now icons drawn through the shared icon helper, using plain characters rather than drawn shapes, and sized to sit level with the expiry menu beside them. The expiry choices are now one month, three months, six months, one year, or never, with one month the shortest.
      8. ✓ Git access panel styling. The heading now reads Git Access. The clone address is shown at the same size as file names in the repository listing, the copy and refresh controls are square, sit level with the address box, and have a small gap between them, and the create control is square and level with the password box. Pressing refresh hides the cloning-only note straight away. A person whose account has an empty password can now make a code by leaving the password box blank.
      9. ✓ Git push over these codes. Pushing to a Git repository wiki page now checks the person's name and the code woven into the clone address against the saved application code instead of against their account password, so the addresses shown on the edit page can actually be pushed to. A valid code is accepted, a wrong one and an expired one are both refused, and a code set never to expire always passes. The brief in-memory record of a good check is never allowed to outlive the code's own expiry. The edit page had left one box open, which pushed the footer up into the middle of the page; that box is now closed. The copy, refresh, and create controls are drawn as fixed square buttons with the character centred, level with the boxes beside them, rather than relying on aspect ratio. New unit tests cover saving and reading application codes.
      10. ✓ Git repository statistics. A Git repository wiki page now shows, in the Git Access panel on its edit page, a short set of statistics: the number of commits and files, the busiest authors, the commits made each month, and the commonest file endings, each drawn as a small bar chart. The numbers are worked out from the repository once for a given newest commit and saved, so later views reuse them until a new commit changes the branch tip; both the history walk and the file walk stop after a generous cap so a very large repository stays quick. New unit tests cover the counting and the saved-statistics file. The statistics show on the edit page and, where the public may read the repository, on the view-source page as well. Two smaller fixes ride along: every icon button is now drawn through the shared icon helper as a fixed square with its symbol centred inside, so the edit-toolbar icons sit level with the access-panel buttons and no symbol spills its box, and the password box is lined up with the menu and button beside it.
      11. ✓ Icon button sizing and the first step towards an issue tracker. Icon buttons are now 32 pixels square by default rather than the smaller size they had become; a button that wants the smaller 28 pixel size adds a "small-icon" class in its icon-helper call, and the symbol inside is drawn a little larger to suit the roomier button. As the groundwork for tracking issues against a Git repository wiki page, a dollar sign is no longer kept in the name of a wiki page a person creates, reserving that character so a repository's issues can later live on hidden companion pages named with it.
      12. ✓ Path and file view: the default branch's tree lists folders first then files, each a link; following a folder walks into it with a breadcrumb trail back up, and following a file shows its contents, with a binary file and an oversized file (past `MAX_GIT_BLOB_VIEW_LEN`) named rather than dumped. The path is resolved only by matching entries that exist, so a crafted path cannot escape the tree.
      13. ✓ Rendered markdown readme: a README at the current folder is shown below the listing, escaped first then run through the markdown parser so its own markup renders but any raw HTML in it stays inert.
      14. ✓ A branch dropdown to switch branches: the branch and path bar shows a dropdown of every branch with the current one selected, and choosing another reloads the page at that branch's root. The read view is now styled as well, so the clone line, bar, listing, file view, and README read as a file browser rather than bare markup.
      15. ✓ Read-view polish toward the GitHub look: the file list now sits in a fixed-height box that scrolls when there are many files rather than stretching the page. The README has a header bar with a book icon and the word README and, when it has more than one section, a contents button that opens an indented menu of its headings; choosing one scrolls to that section (matched by position, so odd heading characters cannot break it). A long README is now kept inside its box: words wrap and wide code or images no longer spill past the edge.
      16. ✓ Per-file last-commit column: alongside each file and folder the listing now shows the summary of the commit that last changed it and how long ago, found by a bounded newest-first walk of the history (capped by GIT_LOG_MAX_COMMITS) comparing each folder listing against its first parent. The middle summary column is hidden on mobile. The README icons are now plain Unicode glyphs rather than inline SVG. The shared markdown renderer was also fixed: fenced and inline code are pulled out and protected before emphasis, headers, links, the math rule, and paragraph wrapping run, then put back, so code is escaped exactly once and things like $_GET or a PHP block are no longer mangled. A commit list and a file filter follow.
      17. ✓ Read-view bar tidy and two fixes: markdown links whose address sat on the line after the opening parenthesis were being pulled apart, so link and image addresses are now held to a single line. The clone address now reads group/<id>/<page>.git to match the wiki page's own address, and the router accepts that leading "group" segment. The bar dropped its "Clone this repository:" and "Branch:" labels, the branch chooser gained a small branch glyph, and the clone address moved onto its own line that a clone button in the bar shows or hides. The README contents menu was made sturdier too: each heading is given a plain id and its menu entry is now an ordinary link to that heading (no script needed to jump), and one small helper shows or hides the contents menu and the clone line by flipping a class.
      18. ✓ Ref chooser and history views: a chooser beside the branch shows HEAD, a short commit name, or a tag, and opens to the recent commits and tags plus ways into the full Commit List and Tag List. Those lists show date, author (or tag name), message, and per-row actions (browse that snapshot, download it), loading twenty rows at a time and fetching more as the reader scrolls. Picking a commit or tag shows that snapshot's files, with the chosen point carried through folder links. A download button offers the shown snapshot as a zip or a gzip-compressed tar, built in pure PHP to a temporary file rather than in memory and capped in size, so archiving even a large repository stays within the server's means. The bar's branch glyph was dropped and the branch and ref choosers now sit side by side, the same height, with the folder path on its own line below (rooted at a "/" link) and a parent link in the file list when in a sub folder. The clone address, the git service's own links, and so cloning and pushing all use the group/ form to match the wiki page's address. The anchor overshoot that sent both these README headings and the wiki parser's own table of contents to just past a heading (the fixed top bar covered it) was fixed by leaving room above a heading it is scrolled to.
   9. ✓ Discussion as an issue tracker. Each issue against a git repository wiki page lives on a hidden companion page named <page_name>$<issue_number>, kept out of the group's page listing the same way personal chat groups are hidden by their reserved name prefix. A companion page carries no page-head settings and no wiki text, only a small record of the one issue: who reported it, who it is assigned to, its priority, and a history of what happened and when (opened and by whom, assigned and by whom, closed as fixed or marked won't-fix). The page's own discussion thread is the issue's discussion. The repository page's discussion button opens a list of its issues; clicking one opens its detail page, where an editor changes its status and how urgent it is, a beetle button starts a new issue, and a search box with a funnel filter finds issues by status and by keyword.
      1. ✓ Reserve the dollar sign: a person can no longer put one in a wiki page name they create, so the character is free to mark issue companion pages.
      2. ✓ The model and helpers that create and read those companion pages and their issue records, and the group page-list query taught to leave companion pages out of the listing.
      3. ✓ The screens: the issue list off the discussion button, the detail page opened by clicking an issue, the new-issue form, and status-and-keyword search.
         1. ✓ The issues view, reached by a git page's discussion button, which for these pages is relabelled the issue tracker. Along the top a search box stretches across the activity and ends in a magnifying-glass button and a funnel button whose drop-down narrows the list by an issue's single status of reported, assigned, marked fixed, or marked won't fix, by the open group that gathers reported and assigned or the closed group that gathers the two marked states, or to the visitor's own issues. For a signed-in visitor a beetle button shows or hides an editor-style new-issue form whose fields are hinted by placeholders rather than labels; the reporter picks the branch and the version the issue was seen on, current code being stored as the current commit, but not its urgency, which starts at the middle. Reporting one stores the issue's record, title, branch, and version on its hidden companion page and starts that issue's own discussion thread. Beneath comes the list, each a bordered row with a narrow number, a title, a status in words, and when it was last touched, shown as how long ago while that is under a day and as a date after. Its rows link into each issue's detail page, and its headers underline as the pointer passes over them to show they sort. It starts newest first, any column header re-sorts it, and once it grows past a set height it scrolls under headers that stay in place.
         2. ✓ The issue detail page, opened by clicking a listed issue. At the top a back link returns to the list, then the issue number. The dated list of status changes it has been through follows, each line naming who made it, so the reporter and the times are read from the list itself rather than a separate line. A group editor then gets a status control set to the current status, without a placeholder: choosing assigned reveals a username box and choosing marked fixed reveals a commit box, each with an arrow to send it, while the other choices send at once. The priority follows the same way with a low, medium, high drop-down, then the title with a button to jump to the latest comment and an icon button that opens the comment box floated onto its line. The issue description shows as the first comment, and below it a scrollable box lists that description and every comment since. Comments are the posts on the issue's own discussion thread and are written with the same editor group threads use, so a comment can carry uploaded resources and wiki or markdown text; each is shown with its writer, time, and rendered body.
         3. ✓ Searching and filtering the issue list. The search box narrows the list as it is typed in, matching an issue's text and the meta terms reporter:name, assignee:name, issue:number, status:word, and a since:date / before:date range, with a hint beneath the box that names them. The funnel's drop-down gains an all-issues choice that turns filtering off and marks the filter in use with a check, and the funnel button fills in while any status filter other than all is on.
   10. ✓ Who may create git pages, and a Server Settings git fieldset.
       1. ✓ Who may turn a wiki page into a git repository is governed by a modifier on the Feed and Wiki activity: a role carrying the no_git_repository modifier loses the ability. The admin role has the activity without the modifier and so may create one; the ordinary user role carries the modifier and so may not. For those users the git repository choice is left out of the page-type menu and refused if posted anyway, reverting to the page's current type; the root account may always create one.
       2. A git fieldset on the Server Settings page.
          1. ✓ A default branch name, default master, that a new git repository page starts its empty repository on. Saved with the other server settings and read when the repository is first initialized.
          2. ✓ Push and blob size caps, and a maximum repository size that defaults to no maximum, each read as a human size like 10M and enforced as a repository is pushed to: an object larger than the blob cap, a pack larger than the push cap, or a push that would grow the repository past its maximum is refused. Each cap is chosen from a dropdown of sizes for project scales from micro to extra large plus an unlimited choice, and the fieldset links to a Git Wiki Pages help page.
   11. ✓ Turn off resource archiving and versioning for git pages, since git carries its own history. A VersionManager is now told whether to version when it is built: the resource operations look up the page's type and, for a git repository page, build the manager with versioning off, so it creates no archive folder and takes no version snapshots and git's history is not duplicated. The decision keys off the page type rather than sniffing the folder. The lock a version operation would take is skipped when versioning is off, so a write into such a folder no longer looks for an archive that is not there.
   12. ✓ Back-port the WebSite WebDAV changes to `github.com/cpollett/atto`, then add a WebDAV example there (example 14, renumbering every later example up by one) and replace the Fiber example (now 27) with one illustrating the use of fibers for concurrency within an existing server. Done in atto: the later WebSite changes were carried over, the WebDAV example serves a folder over the DAV verbs and is gated behind an authenticator, and the replacement example shows a slow handler yielding with Fiber::suspend() so the single-process server stays responsive — the non-blocking machinery this project relied on when it drove WebSite and MailSite.

## Other work

1. ✓ Two wiki-link forms that had regressed were fixed in renderLink. A three-part link `[[relationship|page|shown]]` was linking to its first part, the relationship, rather than to the page; it now links to the middle page part, which is the same part fetchLinks already records the relationship against. A `[[group@page|shown]]` link, meant to reach a page in another group, was treated as a page named `group@page` in the current group; it now emits the `@@group@page@@` marker that WikiElement resolves to that group's read url at display time, since a library parser cannot look a group id up. The mw_links corpus reference was regenerated for the group@page change and the parser test now covers both forms.
2. ✓ A markdown link to another group now works the same way. The markdown reader treated a `[Syntax](Public@Syntax)` destination as a plain relative url, so it never reached the other group. A link destination that names a group as group@page now becomes the same `@@group@page@@` marker the mediawiki path uses, resolved to that group's read url when the page is shown. Only a link destination is affected: an image's destination is left as written, so a filename that happens to contain an at sign is not mistaken for a cross-group reference.
3. ✓ The syntax-guide link shown when a page does not yet exist now points at the right page. Under a group whose render engine is markdown, the "read about the syntax" link beneath the create-page form addressed the page as `Markdown Syntax` with a space, so the space landed in the url and the link went nowhere. Wiki page names store a space as an underscore, and the help page itself is filed as `Markdown_Syntax`, so both places that build the link now use that name. The mediawiki groups' `Syntax` link has no space and was already correct.
4. ✓ The unused additional_substitutions path was removed and the wiki search box was given a working home. Once the wiki parser became cursor-based, nothing read the additional_substitutions list any more, so the `{{search:...}}` box that rode on it had quietly stopped working. The list is gone from the parser's constructor, from setPageName's parameters, and from the tuple SocialComponent threaded it through; the setPageName callers that passed it (and, where that left a trailing empty argument, the arguments after it) were adjusted, and a parser construction argument and an initCommonWikiArrays parameter that only fed it were dropped. The search box now renders where the other display-time tokens do, in WikiElement's dynamicSubstitutions: the `{{search:...}}` tag is carried through the parse untouched and swapped for a real search form — built with the site search address and the icon-link helper's buttons — when the page is shown.
5. ✓ The markdown reader was filled out to more of GitHub's flavor, and its table of contents was put on the same footing as the wiki path's. Four things a readme commonly uses now read correctly: a run of lines indented four spaces is shown as a code block; a reference-style link such as `[text][name]` resolves against a `[name]: url` definition given anywhere on the page, even further down than the link itself; a footnote `[^name]` becomes a small numbered link with the note listed at the foot of the page and a link back up to it; and emphasis is resolved by the full delimiter-stack pass GitHub's markdown uses — each closing run paired with the nearest earlier opening run, the rule of three skipping a forbidden pairing, and two characters taken at a time so a triple run nests emphasis inside strong — so underscores inside a word (a name written in snake case) and stars with a space on each side stay plain text while a star inside a word still emphasizes. The table of contents no longer has a reader of its own: markdown headings are collected during the scan into the same list the wiki path fills, the box is built by the same routine, and every heading is given an id; the old regular-expression table-of-contents reader and its heading-level id cut-off were removed. New tests cover each addition.
6. ✓ The markdown reader used for a code repository's readme was rewritten to read its text with a scanner and a recursive-descent parser in GitHub's flavor of markdown, and no longer leans on regular expressions. A readme is now read a block at a time — headings, fenced code, block quotes, rules, lists, pipe tables, and paragraphs — and the run inside each block a character at a time. Where a readme uses a wiki `{{…}}` template, the reader hands it to the very same template readers the wiki path uses, so a toggle, a citation, a centered block, or a wrapping block behaves the same in a readme as on a wiki page. The old reader's machinery was taken out: the roughly two-hundred line table of brace regular expressions with the two arrays and build loop that fed it, the list-run and provided-regex routines, the table-building callback the old path used, and the now-dead locals and stale note left in the constructor. The output is meant to match what GitHub shows and so differs from the old reader by design; the corpus test's three markdown cases were regenerated to the new output, and that test now feeds a markdown case its raw source, the way a git readme blob reaches the reader, rather than cleaning the text first. A new test set checks headings, emphasis, fenced code, links and images, lists, tables, block quotes and rules, the shared templates, tag passthrough and escaping, and that deeply nested markdown returns bounded escaped output. The extra-substitutions constructor argument, read only by the old reader, is kept for now but no longer applied.
7. ✓ The parser now stops before it can run the call stack out, and link gathering no longer leans on regular expressions. Wiki markup can nest — a block inside a block, a link whose text holds another link — and each level asks the parser to call itself again. Markup nested past twenty levels, whether written that way by hand or crafted to make the parser recurse without end, is now handed back as plain escaped text instead of going deeper, so a single page can never spend the whole call stack. Separately, the routine that lists the pages a wiki page links to used to find those links with four regular expressions; it now scans for the `[[…]]` links and the `{{category…}}` tags directly. The pattern tables that fed the old link search, unused once the scan replaced them, were taken out, along with a fragment-stripping line in the same routine that computed a value it never used. The head-variable reader's one remaining split on a blank line, which never needed a pattern, was changed to a plain string split. A test feeds the parser deeply nested blocks and links and confirms it returns bounded escaped output rather than crashing.
8. ✓ The last regular expressions on the scanner render path are gone. Five spots that still leaned on a regular expression now read their input a character at a time. A table's opening attributes are searched for a trusted class or style by name without regard to case, with spaces allowed around the equals sign, so the safe pair is lifted out and anything else dropped. A link target's scheme — the name and colon that lead an off-site address — is read as a leading letter followed by letters, digits, and a few marks. The plain inline tags the parser passes through, such as a bold open or a line break with or without a trailing slash, are matched by reading the tag name and checking it against the short allowed list. A block or inline-block wrapper's opening line is split on its bars into a keyword, an id, and a style, each checked for the braces and bars it may not hold. And a page's head section is broken into its separate settings by splitting on blank lines. Each reader was checked to give the same result as the expression it replaces, including case, empty values, and the unsafe tokens that must be turned away.
9. ✓ Wiki form runtime fixes: a clean captcha input tag and a nonce call that runs in time. The keyword-captcha text field no longer writes itself with a short self closing tag; like every other form field it now ends with a plain angle bracket. The small script that starts the browser's proof-of-work — the findNonce call — used to sit inline in the form body, where it ran before the hash_captcha.js file that defines findNonce had loaded, so the page reported findNonce as undefined and, with no nonce computed, the server turned a correct keyword captcha away as a failed one. The inline script is gone from the form markup; the call is now added to the page's foot SCRIPT by setupProofOfWorkViewData, alongside the scripts it already lists, so it runs after those have loaded. Both the wiki and the static page paths reach this through the same by reference setup, and both draw their foot SCRIPT from the web layout.
10. ✓ Nowiki inside a pre block, attributes at a table cell's wall, and rules a reader can add to the parser. A nowiki span inside a preformatted block — one made by pre tags or by leading spaces — now has its tags dropped and its characters kept, and a pre close that falls inside such a span no longer ends the block early. A safe run of attributes written at a table cell's wall, before the bar that ends the cell, is kept as that cell's attributes, with anything outside a short safe list dropped so a stray word or an event handler cannot ride in. And the parser now takes rules a caller adds: each is tried before the parser's own handling, both at the start of a block and at each spot in a line, and the html a rule returns reaches the reader as html rather than being escaped. The mediawiki-dump reader uses this to fold its image links, redirects, the other-uses hatnote, and the many templates it drops into the parse by scanning rules of its own, so a cached dump page's html comes through as html; its regex passes before and after the parse are gone. Text that follows the attributes at a cell's wall is kept as that cell's content rather than dropped. A form template whose html is block level — a field, a dropdown, a data block and its close — may be followed on its line by inline content such as a line break and is still emitted as its own block, so a dropdown's select and a data block's option list are no longer split apart by a stray paragraph tag landing inside them.
11. ✓ The template dispatch reads by recursive descent now, not regexes. A small head reader scans the name, notes the separator, and hands the rest to each form, so the alignment, class/id/style, see, hatnote, toggle, and cite templates no longer ride on patterns. A class, id, or style template placed inside a line of text now wraps just its content in a span, so that styling can be used inside a table cell or a sentence.
12. ✓ Table single-bar cells and a notoc region. A table row can divide its cells with single bars, not only double bars, and a bar inside a wiki link or a nowiki span stays part of its cell rather than dividing it. A notoc region — the lines between a notoc tag and its close — renders normally but keeps every heading inside it out of the contents box, so a stretch of example headings is excluded without marking each one.
13. ✓ The wiki form templates are back, as parser rules rather than regex passes. Every form the old parser recognized — the sign-in, ballot, session, alias, and check markers, and the whole csv form-field set (text fields, text areas, drop-downs, sorters, choose-k, check boxes, radios, submit, and the timestamp, date, username, and captcha helpers) — is now a case in the template dispatch, found by the scanner and returning the same marker or form html verbatim so the group controller's side keeps working. A standalone form comes out as its own block rather than wrapped in a paragraph. The mediawiki-dump iterator applies its own dump-only substitutions before handing a page to the parser rather than routing them through it.
14. ✓ Four more wiki reading fixes. A table row can now pair a header cell with the data beside it — a bang cell then a double-bar data cell — and a cell's text can run across several lines. A single top-level heading, the page title, is left out of the contents box while two or more are kept, and a heading can be kept out on its own with a `<notoc>` marker. And a line that opens with a space is shown preformatted, gathered with its neighbors into one pre block.
15. ✓ A spreadsheet resource carries its download link and histogram toggle only when asked for. An inline resource in a wiki page no longer shows those two controls by default; adding `!verbose` after the name and any cell range turns them back on, as in `((resource:name.csv##B2#C3!verbose|Description))`. When Yioop shows a single csv as its own resource page, that view requests `!verbose` itself so it keeps the controls, and a secret-ballot page can ask the same way. The flag uses a symbol other than a colon and is pulled off before the namespace split and the cell-range hashes, so it clashes with neither.
16. ✓ Math and nowiki no longer escape each other's characters twice. Text that already carries an escaped entity, such as one an author wrote by hand to show a tag as an example, keeps its single escaping and shows the tag it stands for rather than the raw entity text, instead of being escaped a second time. And content between backticks is handed to the math renderer whole rather than read for wiki markup, so a matrix written with brackets draws as a matrix instead of becoming a link.
17. ✓ Three wiki behaviors the scanner rewrite had dropped are back. The contents box is placed just before a page's first second-level heading, so a lead paragraph and any top-level heading stay above it rather than being pushed below. A nowiki marker inside a pre block is treated as a directive not to read its contents as wiki markup: since a pre already shows text literally, the marker is dropped and its contents kept, so example markup shows as plain characters instead of the nowiki tags themselves appearing. A nowiki span that runs across several lines is kept whole in the same way, so an example holding markup that would otherwise open its own blocks — a table, say — is read as literal text between the markers rather than being parsed. And a run of definition lines, each a term then a colon then its meaning, becomes one description list instead of a paragraph. Guard tests cover each, and the reused contents-placement helper is the one the markdown path already had.
18. ✓ Restyled the table of contents after the parser switch. The tree engine wraps the contents in a div with the toc and top-color classes; a .toc rule (the old border, width, padding, and margin) was added to search.css, which the web layout already loads, and top-color carries its background from the appearance theme as before.
19. ✓ The MailSite command-loop no-progress guard now only fires when there was input to consume. With an empty buffer it read a command that needs no input as a stuck loop and dropped the connection, which was failing the fiber park and drain test; the guard predates and is unrelated to the search-loop fix.
20. ✓ Fixed two failing unit tests, one a stale expectation and one a test that depended on the machine's profile. The parse2 heading tests now expect the anchor ids the parser emits, which went stale when the table of contents work gave headings ids. The password-policy test depended on whatever policy the running profile happened to set, so it passed on one machine and failed on another; passwordPolicyViolations now takes an explicit policy, defaulting to the configured one but with the require-a-character-class flags coerced to real booleans so a profile that stored them as the strings "true" or "false" can no longer read as always-on, and the test drives every configuration itself rather than reading the ambient one.
21. ✓ Web mail shows every text block and draws inline images where they belong. A message that lays out several text blocks with images between them — the shape Apple Mail sends when a writer drops screenshots into the flow — previously showed only its first text block and pushed every image down into the attachment list. The parser now keeps the parts in the order they were sent as a body-parts list, treats an image the sender left inline as inline rather than as an attachment, and reads every text block instead of only the first; a multipart/alternative group still shows just its richest form, with the plain-text form kept for replies and search. The message view walks that list, drawing each text block and each image — an image as a data URL so it needs no extra request — in the order the sender wrote them.
22. ✓ The mail server no longer wedges on a search, and it sends over IPv4 so its address has matching reverse DNS. An IMAP SEARCH evaluated its terms with `$r = $r && evalOne()`, which let PHP short-circuit once a term was false: the token cursor stopped advancing and the evaluation loop spun at full processor use forever, stalling the whole event loop and with it all delivery. Both the grouped and top-level loops now call the evaluator first and fold the result in afterward, so the cursor always advances and a non-matching message ends the search as it should. Separately, outbound delivery now binds its socket to the IPv4 wildcard so it leaves over IPv4: this host's IPv4 address has a matching reverse-DNS record while its temporary IPv6 autoconfiguration address does not, and receivers such as Gmail reject mail whose sending address lacks forward-confirmed reverse DNS.
23. ✓ Repository table columns can be resized, renderable files are drawn, and statistics scroll rather than crowd. Each column heading of the repository file listing and of the issue tracker, except the last, wraps its label in the same resizable box the resource lists and spreadsheets already use, so a reader can drag a column wider or narrower to suit what they are reading with no added script; the box fills its heading and will not shrink past the column's own width, so the drag corner stays at the column's edge instead of drifting inward, and clicking the heading still sorts the column. A repository file the browser can show directly — a common image, a PDF, or a common audio or video file — is now drawn inline with an image, PDF, audio, or video tag instead of being called a binary file, sized so it never forces sideways scrolling and capped in height. The statistics groups each resolved the page's default folder rather than following its resource path, so a repository kept at a resource path showed no statistics at all; they now follow the redirect the way the rest of the repository views do, and each group draws every row inside a box tall enough for a useful number of them — about ten contributors, twenty recent months, ten file types — that scrolls when there are more, with no control or script involved.
24. ✓ A repository page's resource path becomes a working repository on demand. When a git request reaches a repository page, the folder its resource path points at is now handled by its state rather than assumed to already hold a repository. An empty folder has a bare repository started in it, so the first clone or push to a resource path that was just pointed at a fresh folder works without a separate set-up step, instead of failing with a not-found error. A folder that already holds a repository is served as it is, its history untouched. A folder that already holds other content that is not a repository is left alone and the page's read view warns that the resource path cannot hold the repository, so the misconfiguration is visible rather than silent. A GitRepository folder classifier backs this, with a test covering the three cases.
25. ✓ A page's resource path is honored or reported, never silently ignored. When a wiki page's resource path (the redirect a person sets to keep that page's resources in a folder of their choosing) pointed at a folder that did not exist and could not be made, the resource folder lookup quietly fell back to the page's own default folder, so resources such as a git repository were stored where the operator never asked and the misconfiguration went unseen. The lookup now creates the chosen folder only when it is asked to and the parent folder exists (respecting the create flag its callers pass), and otherwise reports the failure instead of using the default folder. So a person can tell, the read view of any page whose resource path is set but unreachable shows a warning that the path could not be reached or created and asks them to correct it in the page settings; this covers git repository pages and ordinary wiki pages alike.
26. ✓ File listing polish and newest-first commit months. The read repository page's file listing gained an author column showing who last changed each file (carried through the cached last-commit lookup from a new authorName helper), its Age heading is now right-aligned to match its column, its header row shows a border between columns and stays fixed at the top as the listing scrolls, the columns were re-proportioned so the message takes the slack and the rest size to their content, and the message and author columns fold away on a narrow screen (the issue list and file listing now share one git-hide-narrow rule for that). On the edit page, the commits-by-month breakdown now lists the most recent month first rather than last.
27. ✓ Issue tracker opens on an empty repository. A git repository page whose repository had no commits yet showed the empty-repository clone notice for every view, including the issue tracker, so people could not report or discuss issues until code had been pushed. The read-mode set-up returned early on an empty repository before it reached the view dispatch; now, when there are no branches, it still opens the issue tracker if that is the requested view (its new-issue form simply offers no branch or version to pick), and only the file views fall back to the empty-repository notice.
28. ✓ Git repository page columns and file dates. The read issues list gained a user column showing who is answerable for each issue's current status — its reporter while merely reported, its assignee while assigned, and whoever last closed it once fixed or won't-fixed — drawn from a new pure WikiIssue::statusUser with its own unit test, and a priority column that shows low, medium, and high as one, two, or three exclamations in a green circle, an amber triangle, and a red stop sign, so urgency reads at a glance; the user and last-updated columns fold away on a narrow screen. The file browser, which had no header row at all, gained sortable Name, Message, and Age headers (the issue list's sort was generalised to serve any git table, with the age column sorting by its real timestamp), and it no longer leaves older files blank: the walk that finds the commit which last touched each file now runs until every file is found rather than stopping after a fixed number of commits, so a file untouched for years still shows its date and message, and the removed cap constant is gone; the result is cached per folder, keyed to the branch tip, so the fuller walk is only done again when the tip moves. Along the way this fixed a regression that left a fresh install unusable: a crypto-hardening change had put AUTH_KEY_NUM_BYTES below Config.php's unconfigured-instance gate while also using it on the first-run configure path, so `ConfigureTool.php work-dir` died before writing a profile; the define now sits above the gate.
29. ✓ MailServer busy-loop watchdog and guards. On pollett.org the mail server was found pinned at 99% CPU no longer processing mail, having logged nothing for a day though its process was alive; since its heartbeat is a repeating timer fired at the top of every event-loop pass, that silence meant the loop was wedged inside a single pass, and the climbing memory in its last lines fit fibers and contexts piling up. MailSite now records when each pass began and a short note of what it is doing (for example "read key=5"), and a watchdog installed in listen() uses an asynchronous SIGALRM — which interrupts a running PHP loop where an ordinary timer driven by the same stuck loop cannot — to log that note with a backtrace when a pass runs far too long, so a wedge names its stuck code even after it stops logging. Two likely causes were guarded as well: the cooperative file lock, the only fiber suspend point, now gives up after thirty seconds rather than retrying a never-released lock forever, and the per-connection command loop drops a connection if processOne ever claims progress without shrinking its input buffer. The MailServer heartbeat also now reports the parked-fiber count so the build-up shows up early. Verified that the watchdog reports a simulated stall with its activity and backtrace, stays quiet on a healthy pass, and interrupts a real busy loop mid-run. The same changes still need mirroring into atto's own copy of MailSite.
30. ✓ MantisBT import: status, attachments, and tests. An imported issue now reflects its full MantisBT state: an open bug that had a handler is assigned to the Yioop user that handler maps to, so it shows as assigned rather than only reported, while resolved and closed bugs keep showing as fixed or won't-fix. The "Imported from MantisBT #…" footer of source number, category, version, operating system, priority, and severity is no longer added, since that already lives in the issue's own fields; only the original-reporter byline for a bot-folded user remains. Every attachment in `mantis_bug_file_table` is imported too: its bytes, hex-decoded when the dump stored them that way, are written into the resource folder of the comment they belong to (or a closing Attachments comment for files hung off the bug itself), and a wiki resource reference is added so each shows when the comment is read. The pure parts of the import — reading the dump's rows, judging a username, building a body and byline, decoding an attachment, and writing its reference — were pulled into a new `src/library/DiscussionImport` class with unit tests in `tests/DiscussionImportTest.php`.
31. ✓ Generalized the discussion importer. The `import-mantis` command became `import-discussions` with a leading type argument that names the board. `mantis` stays the issue-tracking type (dump file, group, page, locale) and now sits beside the forum and mailing-list feeds Yioop already imports through Manage Groups: `phpbb`, `googlegroup`, and `phorum` each take a feed file saved as RSS or Atom and a group, and hand the work to the social component's `importDiscussions`, the same code the web page uses, so a command-line import matches the web one and the board is read from the feed.
32. ✓ Repository issue tracker link and importer group membership. On a git repository page's issue tracker the page name in the breadcrumb is now a link back to the repository's read view; it stays plain text on the read view itself, where that link would go nowhere. And the MantisBT importer now makes every user it maps to a real Yioop account, whether matched to an existing one or freshly created, a member of the group the repository is on unless they already belong, so imported reporters and commenters can reach the issues they took part in.
33. ✓ Git repository page migration fixes. A group of fixes found while preparing seekquarry for migration, all verified with a live server and a real git client. The Git Access clone command scrolls horizontally inside its box rather than overflowing, which it began doing once `crawlAuthHash` lengthened the embedded app code; the box was also given room so its scrollbar no longer clips the text (`.git-clone-cmd` uses `overflow-x: auto`, `white-space: nowrap`, a `min-height` with padding, and flexes to fill the line). A git page's repository, and its resources generally, now live at the page's Resource Path when one is set: the save and the read both follow the resource-path redirect, and the redirect is written before that folder is resolved so a change of Resource Path moves the repository with it. The bare repository is made on any save of a git page that has a resource folder, and since the maker leaves a folder that already holds a repository untouched, pointing a Resource Path at an existing server repository (say from an earlier Apache deployment) adopts it as is. Setting a Resource Path whose folder does not exist makes it when its parent exists, whatever the page type, and flashes Resource Not Created when it cannot. And the clone read check was widened to match the rest of Yioop: a group whose content is publicly viewable — the "By Request" browse type as well as the open "Anyone" type — can now be cloned without signing in, while an invite-only group stays closed; pushing still needs the account's git app code. The register-type rule was pulled into a small `publiclyReadableGroup` helper with a unit test in `tests/GitComponentTest.php`.
34. ✓ MantisBT issue importer in GroupWikiTool. A new `import-mantis` command reads a MantisBT bug tracker exported with `mysqldump --no-create-info` and turns each bug into an issue on a git repository wiki page's tracker (for example seekquarry's `yioop-repo`). It parses the dump's INSERT rows a character at a time so quoted text with commas, parentheses, doubled quotes, and backslash escapes survives. Each MantisBT user is matched to a Yioop user by username, or, when the name allows, created carrying the MantisBT real name and email with a random password so the person can take the account over through the usual reset; a name Yioop cannot take falls back to the bot-sender account with the original MantisBT name and email kept inline in the issue body and comment so nothing is lost. The bug's reporter owns the issue and its notes' authors own the comments, each dated from MantisBT rather than the moment of import (createGitIssue and setPageName gained optional date and issue number parameters for this). When the tracker starts empty the MantisBT numbers are kept as the Yioop issue numbers, so the last MantisBT number guides the next Yioop one; otherwise issues take the next free numbers. Priorities map onto Yioop's three levels and a resolved bug is closed as fixed or won't-fix, with the source number, category, version, operating system, priority, and severity kept in the issue text.
35. ✓ Pull atto's WebSite changes into Yioop. The atto web server file `src/library/atto_servers/WebSite.php` is kept in step with `github.com/cpollett/atto`. Since this arc's cookie-hardening patch touched that file, atto's newer changes were pulled in at the same time: the CalDAV verbs (Calendaring Extensions to WebDAV, RFC 4791, the calendar-sync protocol) — MKCALENDAR and REPORT added to the route table and both request-line method patterns, with the longest-method length raised from 9 to 10 since MKCALENDAR is ten characters. Yioop registers no CalDAV handlers yet, so those verbs are routable but unanswered; normal requests and the existing git WebDAV verbs are unaffected. The cookie hardening in this same file still needs carrying back the other way to atto.
36. ✓ Undefined MAX_IO_LEN warning during unit tests. The atto web server reads a server setting named MAX_IO_LEN, the largest number of bytes it moves between a socket and memory in one pass, when it sends or receives a response. The full set of server settings is filled in only once the server starts listening, so code that ran before that, as it does under the test set-up, found the setting missing and logged a warning for every request. The default is now also placed on the server object the moment it is created, from a single named constant used in both places, so the setting always exists.
37. ✓ Git bar branch and ref dropdowns showed in different sizes. The branch chooser's own style set its text to a small fixed size, but a broader activity-area rule for every drop-down set a much larger size and, being the more specific of the two, won. The branch chooser's rule is now tied to the activity area as well so it is specific enough to hold its small size, matching the neighbouring ref button.
38. ✓ Issue-page polish. Reporting an issue was sending the reader to the repository's file view instead of back to the issue list, because the address the redirect used had been prepared for a web page, with its ampersands written out, so the view part was lost; the redirect now uses the plain address. The filter drop-down and the read-me table-of-contents drop-down no longer show their entries as blue underlined links but in the quiet grey of the resource menus elsewhere. The report button and the branch and version choosers on the form now read at the same small size as the branch and ref controls on the bar.
39. ✓ BulkEmailJob bot-mailbox scan hangs the media updater on pollett.org. `BulkEmailJob::processBotMailbox` handles `List-Unsubscribe` mailto replies: a recipient who clicks the unsubscribe link sends mail to the bot address with the subject “unsubscribe <token>”, the job reads those, acts on the token, and deletes them. The trouble was the scan. It listed every message in the mailbox and, for each one, fetched and parsed the full header before checking whether the message was even an unsubscribe reply. On pollett.org the mailbox it reads is the root account's INBOX — the bot address is delivered there as an alias — and that INBOX is a personal mailbox with roughly ninety thousand messages, almost none of which are bot mail. Parsing ninety thousand headers on every media-updater pass is what hung it. Done. `processBotMailbox` now narrows the INBOX through the folder's search index (subject, from, to) and parses headers only for those candidates, so the root account's unrelated mail is never opened; the bot reaches root because `MAIL_SENDER` / `MAIL_REPLY_TO` is the bot address delivered as a root alias, and searching root efficiently was chosen over giving the bot its own mailbox. The `FileMailStorage` folder-index cache also gained a bounded least-recently-used limit (`MAX_CACHED_FOLDER_INDEXES`) so the long-running mail server cannot keep every index it ever touches; note this is a robustness fix, not the yioop.com out-of-memory cause, which that server does not exercise.
40. ✓ Web server ran out of memory on yioop.com. The long-running `WebSite` server sits near a high baseline (about 1.7 GB of in-memory index right after a restart, drifting to a roughly 1.95 GB plateau as the caches fill) with little headroom under the 2 GB limit, and a single oversized allocation then tips it over. The root is a corrupt index: a garbage dictionary length made `IndexDocumentBundle::getPostingsString` try to read hundreds of megabytes in one `fread` (which pre-allocates the whole length). The crash diagnostics named both a bot-driven wiki-history request and, later, a spam search query as triggers — any path that reads postings. The work below keeps a corrupt index from taking the live server down; preventing the corruption is the real fix, tracked as a crawling item (todo 73).
    1. ✓ Crash diagnostics: a response-completion log and a fatal shutdown handler that record the request in flight and the peak memory, which named the culprit requests.
    2. ✓ Clamp each posting-string read to the partition file size, and cap both it and the separate positions read at a configurable maximum (`MAX_POSTING_READ_LEN`, default 32 MB): a length past that is logged and skipped, so a corrupt entry cannot over-allocate.
    3. ✓ Scale the three index caches from the memory limit (`cacheEntryLimit`): about half their old size at 2 GB, and proportionally more or less on a roomier or tighter server.
    4. ✓ Cache instrumentation: the periodic memory log now reports each cache's entry count and approximate bytes and the largest posting read seen.
    5. ✓ Idle-session reaper: drop sessions idle past a timeout, closing an unbounded growth path.
    6. ✓ Large-response log: record any response over a threshold, to catch a single giant reply.
    7. ✓ Streamed-response access log: a reply whose body is streamed (a video range, a large download) never passed through the response-size accounting, so it was missing from the access log or recorded as zero bytes. The streaming entry point now writes its own access line, reading the status and length from the headers the route set before it began streaming, with a guard so the same response is not also logged with a zero length. A streamed reply's access line carries its status and byte span; a separate report that some 206s still do not appear is tracked below. This also confirmed resource streaming is memory-bounded (a range serves at most an 8 MB span in 256 KB blocks), ruling a large media file out as the oversized allocation and leaving the corrupt-index posting read as the cause.
    8. ✓ Protocol tag in the access log: each access line — both the arrival line and the companion completion line — now carries a short protocol tag (h1, h2, or h3) right after the request path, read from the protocol the server recorded for the request being served. This makes it visible at a glance how each client connected, and in particular whether a given large-file request arrived over h1, h2, or h3. A first version read a transient streaming field that was sometimes cleared by the time the line was written, so it misreported some h2 requests as h1; it now reads the per-request SERVER_PROTOCOL the dispatcher stamps, which is stable for the whole request. The listening port is now appended as well (protocol:port, e.g. h2:443 or h1:80), so a line shows both how the client connected and which port it reached — the datum needed to tell an h1-over-TLS request on 443 apart from a plain request on 80.
    9. ✓ HTTP/2 never negotiated over the secure launcher: the auto-built server TLS context carried the certificate and key but no `alpn_protocols`, so the listening socket advertised no ALPN and every browser fell back to HTTP/1.1 even with HTTP/2 checked in Server Settings. The context now advertises the configured `ALPN_PROTOCOLS` list (h2 plus http/1.1 by default), so the client can negotiate HTTP/2. The server already detects the chosen protocol from the client's first decrypted bytes, so no other change was needed.
    10. ? HTTP/2 offered inconsistently per connection: with ALPN advertised on the listening socket, a page still fetches the document and images over h2 but the css, scripts, and favicon over h1, deterministically. A first attempt set the protocol list on each accepted socket as well, on the theory that inheritance from the listening socket was unreliable; it made no difference and was backed out, since server-side ALPN is registered on the listening socket's context and a per-accepted set is at best a no-op. Also ruled out: the css/script/image URLs are all root-relative through SHORT_BASE_URL, so same origin and port, not a scheme or port split; and the h1-to-h2 header conversion is correct on all three paths (it strips the connection-specific headers h2 forbids and lowercases names), so a stray h1 header is not resetting streams. The remaining suspect is the browser's connection use during the first load rather than a server misconfiguration; confirming it needs the listening port and a connection identifier per access line to see whether the h1 resources reuse a warm connection or open new ones.
    11. ✓ Wiki save failed when a resource was uploaded with the page: saving a page that also takes a dropped file re-saves the page once more after the upload so the resource is parsed in, and both saves land in the same second. Page history is keyed by page and whole-second timestamp, so the second write hit a duplicate-key error and the save failed. Both history writers now go through one helper that clears any row already at that page and second before inserting, so the later save replaces the earlier one instead of colliding.
    12. ✓ Bounded wiki history diff: the page-history view re-parses a stored version and, when two versions are compared, runs a longest-common-subsequence diff. Measuring the real “Syntax” page (about 1150 lines) showed the re-parse is cheap (about 5 ms, negligible memory), but the diff builds a table with one cell per pair of differing lines, so its memory grows with the product of the two line counts: about 82 MB here, and around 230 MB near 2000 lines, which matches the reported crash allocation. The diff now skips that table and reports the differing region coarsely once the cell count would pass `MAX_DIFF_LCS_CELLS` (about 15 MB), so a whole-page diff of a large page can no longer over-allocate. A localized edit is unchanged, still getting the exact line-by-line diff.
    13. ✓ Outbound-buffer instrumentation: the crashes keep being reported at a `set_error_handler` line in the response drain, which cannot itself allocate the hundreds of megabytes the fatal names, and the existing per-response large-body log stays silent, so no single response is the culprit. Under HTTP/2 many stream responses are appended into one per-connection buffer, so a slow-draining client can accumulate a huge buffer that nothing currently reports. Added `logOutboundBuffer`, called at the enqueue point, which logs once (with the chunk just added and the request) when a connection's outbound buffer first crosses the large-response threshold. This tells us whether the memory goes into an accumulating buffer or somewhere else before deciding the fix. Result: the probe stayed silent, the large-response log stayed silent, and the crash kept landing on the `set_error_handler` line — the second branch above, which pointed away from the send side and into the request-build path, and on a closer look at that line, into `set_error_handler` itself.
    14. ✓ Root cause and fix — a `set_error_handler` stack leak. Each `set_error_handler(null)` … `set_error_handler($custom)` block pushes two handlers onto PHP's internal error-handler stack and pops neither; the restore is meant to be `restore_error_handler()`, which pops. The response drain runs one such block for every writable stream on every event-loop tick, so on a busy long-running process the internal stack grows without bound — this is the steady climb in the memory log — until a push has to grow the stack in one contiguous block, which is the hundreds of megabytes the fatal names, always at the `set_error_handler` line, on whatever URL happened to be draining. Measured directly: the leaky pair grows about 78 MB per two million iterations, the balanced pair is flat. Fixed the eight unbalanced sites in `WebSite.php` (drain loop, streamed write and its stall wait, cookie expiry, GOAWAY and RST_STREAM writes, socket shutdown) to use `restore_error_handler()`; the post-TLS reinstall keeps a single set, since it re-asserts the handler `stream_socket_enable_crypto` clears and so pushes once per connection, far below the drain rate. The same idiom leaks in in-process controllers (`ResourceController`, `SocialComponent`, `CrawlComponent`) and in the `Fetcher` and `QueueServer` daemons; sweeping those is a follow-up.
    15. ✓ Swept the same idiom across the rest of Yioop: converted 82 null-suppress restores in 41 files (controllers, models, media jobs, document processors, index and mail libraries) plus three `try`/`finally` capture-handler blocks to `restore_error_handler()`, so every suppress block is balanced. Left the one-time boot installs (`index.php`, `ClassifierTool`) and two deliberately unpaired asserts (`TestsController`'s test-mode handler and a `SocialComponent` feed re-assert) for a separate pass, now done below. The same fix is still owed upstream in the atto library.
    16. ✓ Removed those two remaining unpaired asserts. `TestsController::processRequest` installed a throw-on-error test handler then immediately reset it to the default, so the install never took effect and both calls leaked; the test runners have no try/catch to survive a throwing handler, so the method now runs under the inherited global handler like any controller and the now-unused handler function was removed. `SocialComponent::importDiscussions` re-asserted the yioop handler after its parse loop although nothing in the function had suppressed it; with every suppress block now balanced the re-assert is redundant and was dropped.
    17. ✓ Large downloads over H2 truncated. A range request for a large PDF (and the no-range large-file path) served only a small first span as a 206 and then stopped; Firefox and Safari both got that stub and never fetched the remainder, so the download hung. The logging half was fine (a direct test confirmed `stream()` fires the response logger with the 206 status and length). The cause was that both resource paths capped the served span to the peer's per-stream flow-control window. That cap could never work: a browser enlarges its stream window with a WINDOW_UPDATE that is a separate frame processed just after the request that opened the stream, so at the moment the route sized the span only the small initial window was visible — the served spans matched each browser's initial window exactly (Firefox 128 KB, Safari 2 MB). And naming a shorter span than the client asked for made the browsers treat the reply as a partial transfer and fail rather than fetch the rest. The window cap and the fixed 8 MB per-response cap were both removed: `serveRangeRequest` now serves the whole requested range and the no-range path streams the whole file as a 200, letting the atto server pace the body to the flow-control credit the client returns as it reads (the Phase 3 fix that reads the WINDOW_UPDATE alongside the request headers). The pacing keeps the transfer memory-bounded and interleaved with other streams, which is what the caps were trying to do by hand. A first attempt to keep a conservative cap but read the current granted window failed for the same timing reason, so `peerStreamWindow()` and `MAX_RANGE_STREAM_LEN` are now removed as unused. The earlier note here that blamed HTTP/3 was wrong; H3 is off on the affected servers, and the split there was a self-signed-cert artifact.
    18. Prevent the corruption at the source: crash-safe index writes and a quick, safe recovery if the queue server or dictionary process is killed mid-crawl (todo 73).
    19. ✓ A helper for saying how long ago. The code that turns a moment into words like five minutes ago had grown up inside the git repository component, yet it needs no git and its wording is translatable, so it now lives in a small Timeformatter helper as a timeAgo method that both the commit list and the issue list call. Its phrases were renamed to belong to that helper, and each is now looked up under a plain name of its own rather than one chosen at run time, so that every translatable phrase can be found by reading the code.
    20. ✓ A multibyte character in wiki inline text is no longer dropped. Where the inline reader met a character it had no special handling for, it escaped one byte at a time; for a multibyte utf-8 character such as an accented letter or an emoji that handed htmlspecialchars a lone byte, which under the utf-8 charset returns the empty string, so the character vanished. The reader now reads the whole character, one to four bytes as its lead byte says, and escapes it as a unit, so the character survives. This also let the javascript help-panel port match the server byte for byte, since the port reads by code point and had kept such characters all along.
    21. ✓ A code block, and text left after a shown-as-typed block, now read the way an author expects. The reader only recognised the pre tag for a block shown exactly as typed, so a code tag used the same way was escaped and its closing tag was left on the page as stray text, and a user-agent example laid out over several lines was torn apart with its indented line read as a block of its own. The reader now treats a code block the same as a pre block, reading from the opening tag through the closing one however many lines it spans, so a pre or code tag inside stays literal and a line break inside no longer breaks the block. A related case is fixed at the same time: an indented run of text is read as a shown-as-typed block only when it starts a line, so text left after a pre or code close on the same line becomes an ordinary paragraph rather than a spurious second block. The change is made in both the server parser and the javascript help-panel port, which stay byte for byte in step, and each gains a test for the reported inputs. The server parser gets a case in tests/WikiParserTest.php; the javascript port gets a JavascriptUnitTest in tests/WikiParserJavascriptTest.php that shows a pass or fail table in a browser and, from the command line, replays the same check under node. To make that command-line path work, JavascriptUnitTest now looks for node and, when it is installed, runs a javascript test case through it, so the existing Sha1 javascript test runs from the command line too rather than being skipped. Each case reports its own sub-results through a shared reporter, scripts/javascript_unit_test.js, built on the basic.js page helpers, so a group of checks shows as a count such as 6/6 instead of a single line both on the command line and in the browser, and the test page lists each javascript test method as its own link the way the php test page already does, so a single method can be opened and shared by url.
41. ✓ CodeTool search shows match context. `CodeTool.php search` printed only the line number and the raw matched text. It now prints the line number and a short snippet — up to thirty characters of file text on each side of the match (newlines flattened to spaces), the match itself in bold, bracketed by ellipses — so a hit reads in context. The bold uses terminal escape codes only when writing to an interactive terminal that supports them (turning the codes on for a modern Windows console), and falls back to plain text when the output is redirected or unsupported, so it works on macOS, Linux, and Windows without leaving stray control characters in piped output.
42. ✓ Wiki page-type setting tweaks. Settings controls that have no effect for a page type are now hidden for it — the table-of-contents checkbox is hidden when the type is Media List — using the existing show/hide handler, with room to add more control/type pairs. A cut-and-paste comment in `HeaderElement` that referred to the footer was corrected to the header. A new Media-List-only control, shown only to the root account, lets root mark a media list to show an index page by default: a checkbox (off by default) that reveals a file field (default `index.html`); when set, viewing the list in read mode serves that file's bytes directly, so the page renders as the file itself with no wiki markup around it, while edit mode still shows the folder so resources can be managed. Only the page's top folder is replaced, so sub-folders still browse normally.
43. ✓ Media-list index page now served directly rather than framed. The first attempt embedded the index file in an inline frame, which showed wiki chrome around a doubly nested page. The read view now emits just the index file from the controller, the way the resource controller serves a file, and stops, so nothing but the file is sent.
44. ✓ Editors get a way back to editing the served index page. When a visitor who is allowed to edit the page views an HTML index page, a fixed edit button is overlaid in the top opposite corner that opens the page's edit view; choosing to serve an index in the first place remains a root-only setting. Every other visitor receives the file exactly as stored with nothing added. Serving the page also clears any pending one-time message, so a leftover notice (such as the sign-in confirmation) no longer surfaces on the edit page reached from the button.
45. ✓ LDAP-not-ready state is now remembered across a restart. Choosing LDAP in the Authentication panel without filling every field used to save the method as plain LDAP, so after the web server was stopped and started the site believed LDAP was active and tried to reach a directory it could not use, logging "Can't contact LDAP server" on each request. A new pending authentication value is now stored when LDAP is chosen but its settings are incomplete; sign-in treats only the plain LDAP value as active, so the site keeps using locally stored passwords until the settings are valid, while the panel still shows LDAP selected and lists what to fix. Saving once the settings are valid promotes the pending value to active LDAP.
46. ✓ Turning LDAP on now checks that the root account can actually sign in to the directory first. The Authentication panel gained a root directory username and password (the password is used only for this check and never stored). When the method is switched into LDAP, the site binds to the directory as root, reads the email the directory holds for that account, and confirms it matches the root account's email here before going live; if the bind fails or the emails differ, it stays pending on local passwords and says which check failed. A re-save while LDAP is already active does not ask again. The required-field asterisks in the panel were also moved to sit after each control rather than beside the label.
47. ✓ Authentication panel polish. The required-field stars now use the larger, padded style used elsewhere in Yioop and appear only after a save that could not turn LDAP on, beside the specific fields that held it up; the servers star also clears as soon as at least one server is in the list. The root sign-in labels were shortened to Root Username and Root Password, leaning on the help page for the detail.
48. ✓ Issues stay out of the group feed. Reporting an issue quietly starts a discussion thread to hold its comments, and that thread had been surfacing as an ordinary group post: it showed as a group's most recent post and was counted among its posts and threads on the account and activity screens. The feed, the post and thread counts, and the most recent post now leave out any thread that belongs to an issue companion page, recognised by the reserved separator in the page title.
49. ✓ Shorter back link on an issue. The link at the top of an issue's detail page now reads simply Back rather than Back to issues.
50. ✓ Even send arrows on issue edits. The arrow that sends a typed assignee or fix commit now stands the same height as the box beside it.
51. ✓ Real editor for issue comments. Issue comments are now the posts on the issue's own discussion thread, written with the same wiki editor group threads use, so a comment can carry uploaded resources and wiki or markdown text and is shown with its rendered body. The comment box, the jump-to-latest button, and the write button moved onto the issue title's line, and the Comments label was dropped.
52. ✓ Resource button group made its own class. An earlier icon-sizing change had pinned the shared media-buttons container to a single icon's width, squashing the view-toggle group on the resource list. That container is left as is and a reusable icon-button-group class now holds such groups at their natural width, matching the height of the Go and sort controls beside them.
53. ✓ Issue detail, comment editor, and editor toolbar fixes. A reader who is not logged in can now open an issue on a public repository and read it: requests with no form token are trimmed to a known set of fields, and the issue number and status-filter fields had been left off that set, so they are now kept alongside the other repository fields. The comment box sets up its upload handler, editing toolbar, and hidden-until-asked state from the page's own start-up script, in the order the group feed uses, rather than through a later step a busier page could skip; the box stays closed until the comment button is pressed, so the comments are what a reader sees first. The same icon-sizing change that squared the media buttons had also pinned every wiki editor button to that square, since the editor toolbar wraps each button in the shared icon-button container; that left the toolbar too wide to sit on one row and drew the alignment buttons wrongly, so editor toolbar buttons are now held at the size and block layout they had before that change, which the media buttons keep. The issue search box reads “Search Issues” and the issue number sits at the far end of its line opposite the back link.
54. ✓ Comment box width and wide uploads. The issue comment box had no width of its own and fell back to a browser's narrow default; it now spans the width of the detail panel like the page editor does. An image uploaded into a comment that is wider than the panel no longer makes the comment scroll sideways, since images in a comment body are held to the panel's width.