<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Phase 4 &mdash; Security Audit and Wiki Regex &mdash; Arc Plan</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 760px;
    margin: 1.5em auto; padding: 0 1em; line-height: 1.5; color: #1a1a1a; }
h1 { font-size: 1.4em; }
h2 { font-size: 1.1em; margin-top: 1.4em; }
ol { margin: 0.3em 0; } li { margin: 0.25em 0; }
ol.items { list-style: none; padding-left: 0; }
.check { color: #1a7f37; font-weight: bold; }
.next { color: #b35900; font-weight: bold; }
.q { color: #8250df; font-weight: bold; }
code { background: #f2f2f2; padding: 0 0.25em; border-radius: 3px; }
em { color: #555; }
</style>
</head>
<body>
<h1>Phase 4 &mdash; Security Audit and Wiki Regex</h1>
<p style="margin:.8rem 2rem;padding:.7rem 1rem;background:#eef2f7">
<strong>Arc Post-Mortem Summary.</strong>
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.</p>
<p><em>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: <span class="check">&#10003;</span> done,
<span class="next">&#9654;</span> next, <span class="q">?</span> decision
to make, unmarked = planned.</em></p>

<p><em>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.</em></p>

<h2>Plan</h2>
<ol>
  <li><strong>Run a full security audit before v10 release (item
    31).</strong> Several strands, some already started in earlier arcs:
    <ol type="a">
      <li><span class="check">&#10003;</span>
        <strong>Crypto.</strong> <code>crawlHash</code>
        (<code>Utility.php</code>) 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
        <code>crawlAuthHash($string)</code> returning
        <code>hash_hmac('sha256', $string, AUTH_KEY)</code> 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 <code>crawlHash</code>. <em>In
        progress:</em> 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 <code>crawlAuthHash</code>
        (real HMAC-SHA256); generate and verify were migrated together so
        pairs stay consistent, and the ballot verify now compares with
        <code>hash_equals</code>. The two user-folder-name hashes are kept on
        <code>crawlHash</code> &mdash; changing them would rename every
        existing user's data folder &mdash; but the duplicated
        folder-and-prefix computation was pulled into a single
        <code>UserModel::userFolderAndPrefix</code> method. AUTH_KEY
        generation now comes from a CSPRNG
        (<code>base64Hash(random_bytes(AUTH_KEY_NUM_BYTES))</code>) instead
         of a hash of the work directory and time. A follow-up removed the
        pointless inner <code>crawlHash</code> from the FORM_HASH tamper
        check: it now HMACs the page directly
        (<code>crawlAuthHash($tmp_page)</code>) 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 <code>[{form-hash}]</code> placeholder
        at save in GroupModel and recomputed at verify in SocialComponent)
        moved from the 8-byte <code>crawlHash</code> fold to a full
        <code>hash("sha256", ...)</code>. Both sides hash the page body
        (<code>page_objects</code> 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.</li>
      <li><span class="check">&#10003;</span>
        <strong>Constant-time comparison.</strong> The CSRF check in
        <code>Controller::checkCSRFToken</code> still compares with
        <code>==</code>; switch it to <code>hash_equals</code> so a token
        cannot be guessed by timing. <em>Found already done</em> in the earlier
        CSRF-to-<code>crawlAuthHash</code> work: the check uses
        <code>hash_equals</code> over <code>crawlAuthHash</code>. The
        ballot-form-hash comparison touched above was switched to
        <code>hash_equals</code> at the same time.</li>
      <li><span class="check">&#10003;</span>
        <strong>Drop dead crypto fallbacks.</strong> Now that the PHP
        floor is well past 8.0, remove the <code>mcrypt_create_iv</code>
        and <code>mt_rand</code> branches from <code>crawlCrypt</code>.</li>
      <li><span class="check">&#10003;</span>
        <strong>Response and cookie hardening.</strong> Confirm the
        session cookie sets HttpOnly, Secure, and SameSite, and add a
        Content-Security-Policy and
        <code>X-Content-Type-Options: nosniff</code>. <em>Done:</em> the
        session cookie now sets <code>HttpOnly</code> and
        <code>SameSite=Lax</code> (new <code>SAME_SITE_COOKIE</code> config,
        threaded through index.php and the atto server's
        <code>setCookie</code>/<code>sessionStart</code>, which also now
        emits <code>Secure</code> over HTTPS); a
        <code>frame-ancestors 'self'</code> CSP and
        <code>nosniff</code> 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.</li>
      <li><span class="check">&#10003;</span>
        <strong>Input and output paths.</strong> Spot-check that request
        values pass through <code>Controller::clean</code>, that view and
        element echoes are escaped, and that file uploads are checked for
        path traversal, MIME type, and executable content. <em>Spot-check
        done:</em> request values go through <code>Controller::clean</code>,
        whose <code>file_name</code> type already neutralizes
        <code>..</code>; no view or element echoes a raw
        <code>$_REQUEST</code> value; upload base names are reduced with
        <code>pathinfo</code> BASENAME. One gap closed:
        <code>getGroupPageResourcesFolders</code> (the upload write path)
        did not strip <code>..</code> from its sub-path the way its URL
        sibling does, so that strip was added. Uploaded files are served by
        <code>ResourceController</code> with
        <code>readfile</code>/<code>fread</code>
        and never <code>include</code>d or <code>eval</code>ed, so an
        uploaded script is downloaded, not run &mdash; executable content is
        mitigated by how resources are served rather than by an extension
        blocklist.</li>
    </ol>
    <em>Decided:</em> 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 &mdash; every cookie- and header-setting path was
    checked, not just one spot.</li>

  <li><strong>Audit and rewrite the wiki-page parsing regexes (item
    16).</strong> 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.
    <span class="q">?</span> <em>Decide, when opening this item:</em>
    whether to harden the existing regexes in place or move to a tokenizer,
    which is the larger but more durable change.
    <em>Decided:</em> 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:
    <ol type="a">
    <li><span class="check">&#10003;</span> Golden corpus regression net
      capturing today's output for both engines.</li>
    <li>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:
      <ol type="i">
      <li><span class="check">&#10003;</span> Paragraphs and blank-line
        breaks.</li>
      <li><span class="check">&#10003;</span> Headings with anchor
        ids.</li>
      <li><span class="check">&#10003;</span> Horizontal rules.</li>
      <li><span class="check">&#10003;</span> Colon indents.</li>
      <li><span class="check">&#10003;</span> Ordered, unordered, and
        mixed lists nesting to any depth.</li>
      <li><span class="check">&#10003;</span> Pre blocks and nowiki
        masking.</li>
      <li><span class="check">&#10003;</span> Bold, italic, and
        bold-italic emphasis.</li>
      <li><span class="check">&#10003;</span> Links with a url-scheme
        allowlist.</li>
      <li><span class="check">&#10003;</span> The allowed inline html
        tags.</li>
      <li><span class="check">&#10003;</span> Tables: caption, headers,
        cells, and class and style only.</li>
      <li><span class="check">&#10003;</span> Styling templates (center,
        left, right, class, id, style) nesting to any depth.</li>
      <li><span class="check">&#10003;</span> Table of contents from the
        tree, fixing the link-in-heading case.</li>
      <li><span class="check">&#10003;</span> Named-transclusion templates
        ({{Main|&hellip;}}, {{See also|&hellip;}}, Hatnote).</li>
      <li><span class="check">&#10003;</span> Toggle blocks
        ({{toggle|&hellip;}}, {{block|&hellip;}}, {{end-block}}).</li>
      <li><span class="check">&#10003;</span> Resources:
        ((resource:&hellip;)) and the chart, CSV, and QR family, passed
        through for the component to substitute as the old parser does.</li>
      <li><span class="check">&#10003;</span> Math wrapped for the math
        renderer.</li>
      <li><span class="check">&#10003;</span> The reference list, cite tags
        to footnotes.</li>
      </ol></li>
    <li><span class="check">&#10003;</span> Switch parse over to parse2
      and delete the old regex engine. <em>Done:</em>
      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 &mdash; 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.</li>
    <li><span class="check">&#10003;</span> 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. <em>Done:</em>
      the sharing turned out real, so rather than a
      separate class the markdown engine runs on the one cursor-based
      WikiParser &mdash; 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 <code>{{search:...}}</code> 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 <code>[{recent_places}]</code> 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.</li>
    <li>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.</li>
    </ol></li>
  <li><span class="check">&#10003;</span> <strong>Self-host the developer
    site: a git-repository wiki page type.</strong> 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.
    <ol type="a">
      <li><span class="check">&#10003;</span> Pure-PHP loose-object reader:
        refs, and commit, tree, and blob parsing, with size-bounded
        inflation and name-verified reads.</li>
      <li><span class="check">&#10003;</span> Pure-PHP packfile reader: index
        lookup, offset- and name-based delta resolution, and malformed-pack
        bounds.</li>
      <li><span class="check">&#10003;</span> Git page type, with bare-repo
        creation on save hardened never to overwrite an existing
        repository.</li>
      <li><span class="check">&#10003;</span> 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).</li>
      <li><span class="check">&#10003;</span> Unit tests for the reader, pack
        reader, and model, built entirely in PHP.</li>
      <li><span class="check">&#10003;</span> Read and clone: a git component
        on the group controller routes
        <code>/&lt;group_id&gt;/&lt;page&gt;.git/...</code> 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 <code>git clone</code> 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.</li>
      <li><span class="check">&#10003;</span> 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
        <code>git push</code> 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 <code>GIT_AUTH_CACHE_TIMEOUT</code>, 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.</li>
      <li><span class="check">&#10003;</span> Read-mode browser: a git
        repository page now opens in read mode as a file browser instead of
        reading as a missing page.
        <ol type="i">
          <li><span class="check">&#10003;</span> 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 &mdash; the right
            in a left-to-right locale, the left in a right-to-left one &mdash;
            so it sits under the clone button that reveals it.</li>
          <li><span class="check">&#10003;</span> Read-view overhaul. The
            clone panel now shows the full <code>git clone
            &lt;address&gt;</code> 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 &mdash; 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
            <code>GitRepository::treeDiff</code> and the existing
            <code>diff</code> 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.</li>
          <li><span class="check">&#10003;</span> Read-view follow-up fixes:
            the tar.gz row action uses a clamp icon and the commit-diff
            action a &lt;/&gt; 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.</li>
          <li><span class="check">&#10003;</span> 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.</li>
          <li><span class="check">&#10003;</span> 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.</li>
          <li><span class="check">&#10003;</span> 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.</li>
          <li><span class="check">&#10003;</span> 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.</li>
          <li><span class="check">&#10003;</span> 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.</li>
          <li><span class="check">&#10003;</span> 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.</li>
          <li><span class="check">&#10003;</span> 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.</li>
          <li><span class="check">&#10003;</span> 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.</li>
          <li><span class="check">&#10003;</span> 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 <code>MAX_GIT_BLOB_VIEW_LEN</code>) named
            rather than dumped. The path is resolved only by matching entries
            that exist, so a crafted path cannot escape the tree.</li>
          <li><span class="check">&#10003;</span> 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.</li>
          <li><span class="check">&#10003;</span> 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.</li>
          <li><span class="check">&#10003;</span> 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.</li>
          <li><span class="check">&#10003;</span> 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.</li>
          <li><span class="check">&#10003;</span> 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/&lt;id&gt;/&lt;page&gt;.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.</li>
          <li><span class="check">&#10003;</span> 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.</li>
        </ol></li>
      <li><span class="check">&#10003;</span> Discussion as an issue
        tracker. Each issue against a git
        repository wiki page lives on a hidden companion page named
        &lt;page_name&gt;$&lt;issue_number&gt;, 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.
        <ol type="i">
          <li><span class="check">&#10003;</span> 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.</li>
          <li><span class="check">&#10003;</span> 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.</li>
          <li><span class="check">&#10003;</span> 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.
            <ol type="a">
              <li><span class="check">&#10003;</span> 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.</li>
              <li><span class="check">&#10003;</span> 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.</li>
              <li><span class="check">&#10003;</span> 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.</li>
            </ol></li>
        </ol></li>
      <li><span class="check">&#10003;</span>
        Who may create git pages, and a Server Settings git fieldset.
        <ol type="a">
          <li><span class="check">&#10003;</span> 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.</li>
          <li>A git fieldset on the Server Settings page.
            <ol type="i">
              <li><span class="check">&#10003;</span> 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.</li>
              <li><span class="check">&#10003;</span> 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.</li>
            </ol></li>
        </ol></li>
      <li><span class="check">&#10003;</span> 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.</li>
      <li><span class="check">&#10003;</span> Back-port the WebSite WebDAV
        changes to <code>github.com/cpollett/atto</code>, 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 &mdash;
        the non-blocking machinery this project relied on when it drove
        WebSite and MailSite.</li>
    </ol></li>
</ol>

<h2>Other work</h2>
<ol class="items">
  <li><span class="check">&#10003;</span> <strong>Two wiki-link forms that
    had regressed were fixed in renderLink.</strong> A three-part link
    <code>[[relationship|page|shown]]</code> 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 <code>[[group@page|shown]]</code> link, meant to
    reach a page in another group, was treated as a page named
    <code>group@page</code> in the current group; it now emits the
    <code>@@group@page@@</code> 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.</li>
  <li><span class="check">&#10003;</span> <strong>A markdown link to another
    group now works the same way.</strong> The markdown reader treated a
    <code>[Syntax](Public@Syntax)</code> 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 <code>@@group@page@@</code>
    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.</li>
  <li><span class="check">&#10003;</span> <strong>The syntax-guide link shown
    when a page does not yet exist now points at the right page.</strong>
    Under a group whose render engine is markdown, the "read about the
    syntax" link beneath the create-page form addressed the page as
    <code>Markdown Syntax</code> 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
    <code>Markdown_Syntax</code>, so both places that build the link now use
    that name. The mediawiki groups' <code>Syntax</code> link has no space
    and was already correct.</li>
  <li><span class="check">&#10003;</span> <strong>The unused
    additional_substitutions path was removed and the wiki search box was
    given a working home.</strong> Once the wiki parser became
    cursor-based, nothing read the additional_substitutions list any more,
    so the <code>{{search:...}}</code> 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
    <code>{{search:...}}</code> tag is carried through the parse untouched
    and swapped for a real search form &mdash; built with the site search
    address and the icon-link helper's buttons &mdash; when the page is
    shown.</li>
  <li><span class="check">&#10003;</span> <strong>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.</strong> 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
    <code>[text][name]</code> resolves against a <code>[name]: url</code>
    definition given anywhere on the page, even further down than the link
    itself; a footnote <code>[^name]</code> 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 &mdash; 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
    &mdash; 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.</li>
  <li><span class="check">&#10003;</span> <strong>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.</strong> A readme is now
    read a block at a time &mdash; headings, fenced code, block quotes,
    rules, lists, pipe tables, and paragraphs &mdash; and the run inside
    each block a character at a time. Where a readme uses a wiki
    <code>{{&hellip;}}</code> 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.</li>
  <li><span class="check">&#10003;</span> <strong>The parser now stops
    before it can run the call stack out, and link gathering no longer
    leans on regular expressions.</strong> Wiki markup can nest &mdash; a
    block inside a block, a link whose text holds another link &mdash; 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 <code>[[&hellip;]]</code> links and the
    <code>{{category&hellip;}}</code> 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.</li>
  <li><span class="check">&#10003;</span> <strong>The last regular
    expressions on the scanner render path are gone.</strong> 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 &mdash; the name and
    colon that lead an off-site address &mdash; 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.</li>
  <li><span class="check">&#10003;</span> <strong>Wiki form runtime fixes: a
    clean captcha input tag and a nonce call that runs in time.</strong> 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
    &mdash; the findNonce call &mdash; 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.</li>
  <li><span class="check">&#10003;</span> <strong>Nowiki inside a pre block,
    attributes at a table cell's wall, and rules a reader can add to the
    parser.</strong> A nowiki span inside a preformatted block &mdash; one
    made by pre tags or by leading spaces &mdash; 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 &mdash; a
    field, a dropdown, a data block and its close &mdash; 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.</li>
  <li><span class="check">&#10003;</span> <strong>The template dispatch reads
    by recursive descent now, not regexes.</strong> 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.</li>
  <li><span class="check">&#10003;</span> <strong>Table single-bar cells and
    a notoc region.</strong> 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
    &mdash; the lines between a notoc tag and its close &mdash; 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.</li>
  <li><span class="check">&#10003;</span> <strong>The wiki form templates are
    back, as parser rules rather than regex passes.</strong> Every form the
    old parser recognized &mdash; 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) &mdash; 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.</li>
  <li><span class="check">&#10003;</span> <strong>Four more wiki reading
    fixes.</strong> A table row can now pair a header cell with the data
    beside it &mdash; a bang cell then a double-bar data cell &mdash; 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
    <code>&lt;notoc&gt;</code> marker. And a line that opens with a space is
    shown preformatted, gathered with its neighbors into one pre block.</li>
  <li><span class="check">&#10003;</span> <strong>A spreadsheet resource
    carries its download link and histogram toggle only when asked
    for.</strong> An inline resource in a wiki page no longer shows those
    two controls by default; adding <code>!verbose</code> after the name
    and any cell range turns them back on, as in
    <code>((resource:name.csv##B2#C3!verbose|Description))</code>. When
    Yioop shows a single csv as its own resource page, that view requests
    <code>!verbose</code> 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.</li>
  <li><span class="check">&#10003;</span> <strong>Math and nowiki no longer
    escape each other's characters twice.</strong> 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.</li>
  <li><span class="check">&#10003;</span> <strong>Three wiki behaviors the
    scanner rewrite had dropped are back.</strong> 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 &mdash; a table, say &mdash; 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.</li>
  <li><span class="check">&#10003;</span> <strong>Restyled the table of
    contents after the parser switch.</strong> 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.</li>
  <li><span class="check">&#10003;</span> <strong>The MailSite command-loop
    no-progress guard now only fires when there was input to consume.</strong>
    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.</li>
  <li><span class="check">&#10003;</span> <strong>Fixed two failing unit
    tests, one a stale expectation and one a test that depended on the
    machine's profile.</strong> 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.</li>
  <li><span class="check">&#10003;</span> <strong>Web mail shows every text
    block and draws inline images where they belong.</strong> A message that
    lays out several text blocks with images between them &mdash; the shape
    Apple Mail sends when a writer drops screenshots into the flow &mdash;
    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 &mdash; an image as a data URL so it needs no extra
    request &mdash; in the order the sender wrote them.</li>
  <li><span class="check">&#10003;</span> <strong>The mail server no longer
    wedges on a search, and it sends over IPv4 so its address has matching
    reverse DNS.</strong> An IMAP SEARCH evaluated its terms with
    <code>$r = $r && evalOne()</code>, 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.</li>
  <li><span class="check">&#10003;</span> <strong>Repository table columns
    can be resized, renderable files are drawn, and statistics scroll rather
    than crowd.</strong> 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 &mdash; a common image, a PDF, or a common audio or video file
    &mdash; 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 &mdash; about ten contributors, twenty recent months, ten file
    types &mdash; that scrolls when there are more, with no control or script
    involved.</li>
  <li><span class="check">&#10003;</span> <strong>A repository page's
    resource path becomes a working repository on demand.</strong> 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.</li>
  <li><span class="check">&#10003;</span> <strong>A page's resource path is
    honored or reported, never silently ignored.</strong> 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.</li>
  <li><span class="check">&#10003;</span> <strong>File listing polish and
    newest-first commit months.</strong> 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.</li>
  <li><span class="check">&#10003;</span> <strong>Issue tracker opens on an
    empty repository.</strong> 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.</li>
  <li><span class="check">&#10003;</span> <strong>Git repository page
    columns and file dates.</strong> The read issues list gained a user
    column showing who is answerable for each issue's current status &mdash;
    its reporter while merely reported, its assignee while assigned, and
    whoever last closed it once fixed or won't-fixed &mdash; 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
    <code>ConfigureTool.php work-dir</code> died before writing a profile;
    the define now sits above the gate.</li>
  <li><span class="check">&#10003;</span> <strong>MailServer busy-loop
    watchdog and guards.</strong> 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 &mdash; which interrupts a running PHP loop where an ordinary
    timer driven by the same stuck loop cannot &mdash; 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.</li>
  <li><span class="check">&#10003;</span> <strong>MantisBT import:
    status, attachments, and tests.</strong> 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 #&hellip;" 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 <code>mantis_bug_file_table</code> 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 &mdash; reading the dump's rows, judging a
    username, building a body and byline, decoding an attachment, and
    writing its reference &mdash; were pulled into a new
    <code>src/library/DiscussionImport</code> class with unit tests in
    <code>tests/DiscussionImportTest.php</code>.</li>
  <li><span class="check">&#10003;</span> <strong>Generalized the discussion
    importer.</strong> The <code>import-mantis</code> command became
    <code>import-discussions</code> with a leading type argument that names
    the board. <code>mantis</code> 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: <code>phpbb</code>,
    <code>googlegroup</code>, and <code>phorum</code> each take a feed file
    saved as RSS or Atom and a group, and hand the work to the social
    component's <code>importDiscussions</code>, the same code the web page
    uses, so a command-line import matches the web one and the board is read
    from the feed.</li>
  <li><span class="check">&#10003;</span> <strong>Repository issue tracker
    link and importer group membership.</strong> 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.</li>
  <li><span class="check">&#10003;</span> <strong>Git repository page
    migration fixes.</strong> 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
    <code>crawlAuthHash</code> lengthened the embedded app code; the box was
    also given room so its scrollbar no longer clips the text
    (<code>.git-clone-cmd</code> uses <code>overflow-x: auto</code>,
    <code>white-space: nowrap</code>, a <code>min-height</code> 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 &mdash; the "By Request" browse type as well as the
    open "Anyone" type &mdash; 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
    <code>publiclyReadableGroup</code> helper with a unit test in
    <code>tests/GitComponentTest.php</code>.</li>
  <li><span class="check">&#10003;</span> <strong>MantisBT issue importer
    in GroupWikiTool.</strong> A new <code>import-mantis</code> command reads
    a MantisBT bug tracker exported with
    <code>mysqldump --no-create-info</code> and turns each bug into an issue
    on a git repository wiki page's tracker (for example seekquarry's
    <code>yioop-repo</code>). 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.</li>
  <li><span class="check">&#10003;</span> <strong>Pull atto's WebSite
    changes into Yioop.</strong> The atto web server file
    <code>src/library/atto_servers/WebSite.php</code> is kept in step with
    <code>github.com/cpollett/atto</code>. 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) &mdash; 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.</li>
  <li><span class="check">&#10003;</span> <strong>Undefined MAX_IO_LEN
    warning during unit tests.</strong> 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.</li>
  <li><span class="check">&#10003;</span> <strong>Git bar branch and ref
    dropdowns showed in different sizes.</strong> 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.</li>
  <li><span class="check">&#10003;</span> <strong>Issue-page polish.</strong>
    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.</li>
  <li><span class="check">&#10003;</span> <strong>BulkEmailJob bot-mailbox
    scan hangs the media updater on pollett.org.</strong>
    <code>BulkEmailJob::processBotMailbox</code> handles
    <code>List-Unsubscribe</code> mailto replies: a recipient who clicks
    the unsubscribe link sends mail to the bot address with the subject
    &ldquo;unsubscribe &lt;token&gt;&rdquo;, the job reads those, acts on
    the token, and deletes them. The trouble was the scan. It listed
    <em>every</em> 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 &mdash; the bot address is delivered there as an alias
    &mdash; 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.
    <em>Done.</em> <code>processBotMailbox</code> 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
    <code>MAIL_SENDER</code> / <code>MAIL_REPLY_TO</code> is the bot
    address delivered as a root alias, and searching root efficiently was
    chosen over giving the bot its own mailbox. The
    <code>FileMailStorage</code> folder-index cache also gained a bounded
    least-recently-used limit (<code>MAX_CACHED_FOLDER_INDEXES</code>) 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.</li>

  <li><span class="check">&#10003;</span> <strong>Web server ran out of
    memory on yioop.com.</strong> The long-running <code>WebSite</code>
    server sits near a high baseline (about 1.7&nbsp;GB of in-memory index
    right after a restart, drifting to a roughly 1.95&nbsp;GB plateau as the
    caches fill) with little headroom under the 2&nbsp;GB limit, and a single
    oversized allocation then tips it over. The root is a corrupt index: a
    garbage dictionary length made
    <code>IndexDocumentBundle::getPostingsString</code> try to read hundreds
    of megabytes in one <code>fread</code> (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 &mdash; 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).
    <ol type="a">
      <li><span class="check">&#10003;</span> 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.</li>
      <li><span class="check">&#10003;</span>
        Clamp each posting-string read to the
        partition file size, and cap both it and the separate positions
        read at a configurable maximum (<code>MAX_POSTING_READ_LEN</code>,
        default 32&nbsp;MB): a length past that is logged and skipped, so a
        corrupt entry cannot over-allocate.</li>
      <li><span class="check">&#10003;</span> Scale the three index caches
        from the memory limit (<code>cacheEntryLimit</code>): about half their
        old size at 2&nbsp;GB, and proportionally more or less on a roomier or
        tighter server.</li>
      <li><span class="check">&#10003;</span> Cache instrumentation: the
        periodic memory log now reports each cache's entry count and
        approximate bytes and the largest posting read seen.</li>
      <li><span class="check">&#10003;</span> Idle-session reaper: drop
        sessions idle past a timeout, closing an unbounded growth path.</li>
      <li><span class="check">&#10003;</span> Large-response log: record any
        response over a threshold, to catch a single giant reply.</li>
      <li><span class="check">&#10003;</span> 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&nbsp;MB span in 256&nbsp;KB blocks), ruling a large
        media file out as the oversized allocation and leaving the
        corrupt-index posting read as the cause.</li>
      <li><span class="check">&#10003;</span> Protocol tag in the access
        log: each access line &mdash; both the arrival line and the
        companion completion line &mdash; 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 &mdash; the datum
        needed to tell an h1-over-TLS request on 443 apart from a plain
        request on 80.</li>
      <li><span class="check">&#10003;</span> HTTP/2 never negotiated over
        the secure launcher: the auto-built server TLS context carried the
        certificate and key but no <code>alpn_protocols</code>, 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 <code>ALPN_PROTOCOLS</code> 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.</li>
      <li><span class="q">?</span> 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.</li>
      <li><span class="check">&#10003;</span> 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.</li>
      <li><span class="check">&#10003;</span> 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 &ldquo;Syntax&rdquo; page (about 1150 lines) showed the re-parse
        is cheap (about 5&nbsp;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&nbsp;MB here, and
        around 230&nbsp;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
        <code>MAX_DIFF_LCS_CELLS</code> (about 15&nbsp;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.</li>
      <li><span class="check">&#10003;</span> Outbound-buffer instrumentation:
        the crashes keep being reported at a <code>set_error_handler</code>
        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
        <code>logOutboundBuffer</code>, 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
        <code>set_error_handler</code> line &mdash; 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
        <code>set_error_handler</code> itself.</li>
      <li><span class="check">&#10003;</span> Root cause and fix &mdash; a
        <code>set_error_handler</code> stack leak. Each
        <code>set_error_handler(null)</code> &hellip;
        <code>set_error_handler($custom)</code>
        block pushes two handlers onto PHP's internal error-handler stack
        and pops neither; the restore is meant to be
        <code>restore_error_handler()</code>, 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 &mdash; this is the steady climb in the memory log
        &mdash; until a push has to grow the stack in one contiguous block,
        which is the hundreds of megabytes the fatal names, always at the
        <code>set_error_handler</code> line, on whatever URL happened to be
        draining. Measured directly: the leaky pair grows about 78&nbsp;MB
        per two million iterations, the balanced pair is flat. Fixed the
        eight unbalanced sites in <code>WebSite.php</code> (drain loop,
        streamed write and its stall wait, cookie expiry, GOAWAY and
        RST_STREAM writes, socket shutdown) to use
        <code>restore_error_handler()</code>; the post-TLS reinstall keeps a
        single set, since it re-asserts the handler
        <code>stream_socket_enable_crypto</code> clears and so pushes once
        per connection, far below the drain rate. The same idiom leaks in
        in-process controllers (<code>ResourceController</code>,
        <code>SocialComponent</code>, <code>CrawlComponent</code>) and in the
        <code>Fetcher</code> and <code>QueueServer</code> daemons; sweeping
        those is a follow-up.</li>
      <li><span class="check">&#10003;</span> 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 <code>try</code>/<code>finally</code>
        capture-handler blocks to <code>restore_error_handler()</code>, so
        every suppress block is balanced. Left the one-time boot installs
        (<code>index.php</code>, <code>ClassifierTool</code>) and two
        deliberately unpaired asserts (<code>TestsController</code>'s
        test-mode handler and a <code>SocialComponent</code> feed
        re-assert) for a separate pass, now done below. The same fix is
        still owed upstream in the atto library.</li>
      <li><span class="check">&#10003;</span> Removed those two remaining
        unpaired asserts. <code>TestsController::processRequest</code>
        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. <code>SocialComponent::importDiscussions</code>
        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.</li>
      <li><span class="check">&#10003;</span> 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 <code>stream()</code> 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 &mdash; the served spans matched each browser's initial
        window exactly (Firefox 128&nbsp;KB, Safari 2&nbsp;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&nbsp;MB per-response cap were
        both removed: <code>serveRangeRequest</code> 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&nbsp;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
        <code>peerStreamWindow()</code> and
        <code>MAX_RANGE_STREAM_LEN</code> 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.</li>
      <li>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).</li>
      <li><span class="check">&#10003;</span> <strong>A helper for saying how
    long ago.</strong> 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.</li>
  <li><span class="check">&#10003;</span> <strong>A multibyte character in
    wiki inline text is no longer dropped.</strong> 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.</li>
  <li><span class="check">&#10003;</span> <strong>A code block, and text
    left after a shown-as-typed block, now read the way an author
    expects.</strong> 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.</li>
</ol></li>

  <li><span class="check">&#10003;</span> <strong>CodeTool search shows
    match context.</strong> <code>CodeTool.php search</code> printed only
    the line number and the raw matched text. It now prints the line
    number and a short snippet &mdash; 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 &mdash; 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.</li>

  <li><span class="check">&#10003;</span> <strong>Wiki page-type setting
    tweaks.</strong> Settings controls that have no effect for a page type
    are now hidden for it &mdash; the table-of-contents checkbox is hidden
    when the type is Media List &mdash; using the existing show/hide
    handler, with room to add more control/type pairs. A cut-and-paste
    comment in <code>HeaderElement</code> 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 <code>index.html</code>); 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.</li>

  <li><span class="check">&#10003;</span> <strong>Media-list index page now
    served directly rather than framed.</strong> 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.</li>

  <li><span class="check">&#10003;</span> <strong>Editors get a way back to
    editing the served index page.</strong> 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.</li>

  <li><span class="check">&#10003;</span> <strong>LDAP-not-ready state is
    now remembered across a restart.</strong> 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 &quot;Can't contact LDAP server&quot; 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.</li>

  <li><span class="check">&#10003;</span> <strong>Turning LDAP on now checks
    that the root account can actually sign in to the directory first.</strong>
    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.</li>

  <li><span class="check">&#10003;</span> <strong>Authentication panel
    polish.</strong> 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.</li>
  <li><span class="check">&#10003;</span> <strong>Issues stay out of the
    group feed.</strong> 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.</li>
  <li><span class="check">&#10003;</span> <strong>Shorter back link on an
    issue.</strong> The link at the top of an issue's detail page now reads
    simply Back rather than Back to issues.</li>
  <li><span class="check">&#10003;</span> <strong>Even send arrows on issue
    edits.</strong> The arrow that sends a typed assignee or fix commit now
    stands the same height as the box beside it.</li>
  <li><span class="check">&#10003;</span> <strong>Real editor for issue
    comments.</strong> 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.</li>
  <li><span class="check">&#10003;</span> <strong>Resource button group made
    its own class.</strong> 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.</li>
  <li><span class="check">&#10003;</span> <strong>Issue detail, comment
    editor, and editor toolbar fixes.</strong> 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 &ldquo;Search Issues&rdquo; and the issue
    number sits at the far end of its line opposite the back link.</li>
  <li><span class="check">&#10003;</span> <strong>Comment box width and wide
    uploads.</strong> 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.</li>
</ol>

</body>
</html>
X