<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Phase 4.5 &mdash; Settings Refactor and Live-Site Fixes &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; }
h3 { font-size: 1em; margin-top: 1em; }
ol { margin: 0.3em 0; } li { margin: 0.35em 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.5 &mdash; Settings Refactor and Live-Site Fixes</h1>
<p><em>Arc started 2026-07-10, after Phase 4. Sections are Roman numerals and
steps within them are lettered; each step's title says what it does, followed
by a sentence on what was proposed and, once worked, a few on what was done
and anything learned. Marker legend: <span class="check">&#10003;</span> done,
<span class="next">&#9654;</span> next, <span class="q">?</span> open
decision, unmarked = planned.</em></p>

<h2>I. Fix live-site crashes and load</h2>
<ol class="items">
  <li><span class="check">&#10003;</span> <strong>A. Stop the yioop.com
    out-of-memory crashes and CPU pinning.</strong> A crawl flood on one
    page's wiki history plus spam searches drove repeated out-of-memory
    crashes and a pinned core. Bounded the <code>PackedTableTools</code>
    dictionary cache to a byte budget with least-recently-used eviction, took
    the markdown and mediawiki parsers linear with
    <code>MarkUpScanner::peekLine</code>, clamped each index entry's
    <code>NUM_DOCS</code>, stopped a spaces-only list marker spinning
    <code>skipBlankLines</code>, and moved history-revision rendering to the
    browser. Every fix has tests, holds byte-for-byte against the confirmed
    head, and is live.</li>
  <li><span class="check">&#10003;</span> <strong>B. Stop the pollett.org mail
    server wedging on slow DNS.</strong> The single event loop stalled up to
    sixty seconds inside a blocking <code>dns_get_record</code> on inbound
    DKIM/SPF/DMARC and outbound mail-exchanger lookups, so one slow sender
    froze the daemon. Routed every lookup through a new
    <code>AsyncDnsResolver</code> that queries over a non-blocking socket,
    yields the loop, and is bounded by <code>MAIL_DNS_TIMEOUT</code>; a unit
    test covers name encode and decode, compression pointers, and hand-built
    replies.</li>
</ol>

<h2><span class="check">&#10003;</span> II. Split Server Settings into two
activities</h2>
<p>The one Server Settings screen mixed machine-level configuration with
per-account policy; these steps split it and build out the new
account-policy controls.</p>
<ol class="items">
  <li><span class="check">&#10003;</span> <strong>A. Move the settings field
    sets onto a Servers screen and a User, Roles, Groups screen.</strong>
    Partition the existing field sets between the two activities with no new
    fields, as a pure move that keeps every control and locale key working.
    Landed as a pure move.</li>
  <li><span class="check">&#10003;</span> <strong>B. Add a Role Group Limits
    field set with per-role storage and a read path.</strong> Give each role
    caps on how many groups it may own and how large those groups may grow, an
    account's cap being the most generous across its roles. A
    <code>ROLE_LIMITS</code> table holds one row per role (-1 means unlimited)
    with a version 117 migration, the screen edits every role's caps, and
    <code>getUserGroupLimits</code> resolves the maximum across an account's
    roles. Enforcement is step E.</li>
  <li><span class="check">&#10003;</span> <strong>C. Add a Moderation field
    set with an on/off toggle.</strong> Gather the moderation constants into
    one group and add a switch for whether moderation runs at all. The field
    set carries <code>MODERATION_FLAG_THRESHOLD</code> plus a new
    <code>USE_MODERATION</code>, and <code>flagGroupItem</code> only routes a
    flagged item to the moderation group when <code>USE_MODERATION</code> is
    on.</li>
  <li><span class="check">&#10003;</span> <strong>D. Retire GIT_MAX_REPO_SIZE
    for a resource-memory cap and add a Developer role.</strong> Fold the
    standalone git repository-size limit into the per-page resource-memory
    limit, and add a role that holds only the Feeds and Wikis activity. The
    git push check reads <code>MAX_PAGE_RESOURCE_MEMORY</code>, and a Developer
    role (version 118 migration and Createdb seed) holds Feeds and Wikis with
    no modifier, unlike the User role.</li>
  <li><span class="check">&#10003;</span> <strong>E. Enforce the role group
    limits at the points that create or
    grow a group.</strong> Check <code>getUserGroupLimits</code> at group
    create, member add, wiki-page add, resource upload, and post, and refuse
    past the cap.
    <br><em>Done:</em>
    <code>GroupModel</code> gains pure count methods
    (<code>countGroupsOwnedByUser</code>, <code>countGroupWikiPages</code>,
    <code>countGroupThreads</code>, <code>countThreadPosts</code>, joining the
    existing <code>countGroupUsers</code>); all enforcement lives in
    <code>SocialComponent</code> so the models stay independent of
    <code>RoleModel</code>. Two helpers, <code>overUserGroupLimit</code> and
    <code>overGroupLimit</code>, resolve the acting user's or the group
    owner's caps through <code>getUserGroupLimits</code> and refuse past a cap
    with <code>redirectWithMessage</code>. Group create is held to the acting
    user's <code>MAX_GROUPS_OWNED</code>; the wrappers
    <code>addGroupItemWithinLimits</code> and
    <code>addUserGroupWithinLimits</code> guard the feed and membership adds
    for <code>MAX_GROUP_THREADS</code>, <code>MAX_THREAD_POSTS</code>, and
    <code>MAX_GROUP_MEMBERS</code>; the new-wiki-page path is held to
    <code>MAX_GROUP_WIKI_PAGES</code>; and <code>handleResourceUploads</code>
    enforces <code>MAX_THREAD_RESOURCES</code> by file count for thread posts
    and <code>MAX_PAGE_RESOURCE_MEMORY</code> by folder bytes for pages. Seven
    refusal strings and a <code>GroupModelCountsTest</code> were added.</li>
  <li><span class="check">&#10003;</span> <strong>F. Rework the Profile.php
    constants onto the live accessor so settings changes skip a
    restart.</strong> A Profile.php constant is fixed at boot, so a setting
    read as <code>C\NAME</code> needs a restart to change; a
    <code>p($label, $value, $set)</code> in Config.php backs each setting with
    a per-process cache seeded from the boot constant and overridden whenever
    <code>updateProfile</code> rewrites Profile.php, so a read through
    <code>C\p('NAME')</code> sees the new value at once. Landed the accessor
    (keyed on <code>array_key_exists</code> so a stored zero holds, resolved
    against the configs namespace), the <code>updateProfile</code> write hook,
    and the <code>updateProfileFields</code> fallback, with a unit test. The
    settings reads, including the moderation flags, were then moved to
    <code>C\p</code> group by group and verified live.</li>
  <li><span class="check">&#10003;</span> <strong>G. Add per-role cost and
    charge-frequency billing.</strong>
    Charge a per-role cost in credits on a frequency (Never, Once, Monthly,
    Semi-Annual, Yearly). Done with the sellable-roles work: a role's
    <code>ROLE_LIMITS</code> row carries <code>ROLE_COST</code> and
    <code>CHARGE_FREQUENCY</code>, both editable in the roles admin panel and
    read by the account-page purchase and renewal paths.</li>
</ol>
<p>Open decisions: <span class="q">?</span> relabel the current settings
activity display name to &ldquo;Servers&rdquo;; <span class="q">?</span>
whether Git Wiki Pages moves fully to the new activity or keeps a foot on the
Servers screen.</p>

<h2>III. Multi-domain hosting and small interface fixes</h2>
<ol class="items">
  <li><span class="next">&#9654;</span> <strong>A. Serve seekquarry.com and
    frise.com from one Yioop
    instance.</strong> Get multi-domain handling solid enough that one code
    base and index host answers for both domains, each with its own landing
    group and branding, rather than separate installs. Planned.
    <br><em>Landing routing unified onto the static controller:</em> a
    domain routed to a landing group in the Web Server field set used to
    serve that group's Main page through the group controller, while the
    Public-group landing and the &ldquo;Use Wiki Public Main Page&rdquo;
    option both went through the static controller. Both routed cases now
    take the static path. The router in <code>src/index.php</code> sends
    any positive routed group id to the static controller and passes the
    id on a <code>$_SERVER['ROUTE_GROUP_ID']</code> variable a browser
    cannot set, since it is not an <code>HTTP_</code> header, so only an
    admin-set <code>DOMAIN_ROUTE</code> chooses the group.
    <code>StaticController</code> gained a <code>staticGroupId()</code>
    resolver (the routed id when present and positive, else the
    world-readable Public group) and reads every wiki page, header,
    footer, alias, and resource from it in place of a hardcoded
    <code>PUBLIC_GROUP_ID</code>. Only the bare landing request is
    re-homed; other <code>c=static</code> requests still resolve to the
    Public group. A <code>StaticControllerTest</code> covers the
    resolver, and an end-to-end read confirmed a routed group's Main page
    is served while the default falls back to Public.
    <br><em>Routed sub-pages and their self links:</em> serving only the
    landing left the group's own links broken, since a link like
    <code>[[Bob]]</code> in a routed group's page is baked at save time
    as that group's read url on the group controller and, drawn under the
    static controller, became a <code>static/&lt;id&gt;?a=wiki&hellip;</code>
    url with no clean route (an error page). Two changes fix this. The
    router now resolves the request host to its landing group for every
    static request, not just the bare landing, so a <code>p/Bob</code>
    clean url on a routed domain reads that group's Bob page; the host
    comes from the admin-set <code>DOMAIN_ROUTE</code> table, so a request
    parameter cannot choose the group. And the static controller rewrites
    a routed group's baked self links to their <code>p/</code> form as it
    serves the page, body, header, and footer alike. The rewrite keys on
    the <code>page_name</code> query the read url ends with rather than on
    the exact base url, so it holds whether the page was saved with clean
    urls on or off. The rewrite covers a page's own links; cross-group
    <code>@@group@page@@</code> links and <code>[[pages]]</code> index
    links are left as they are, and a page missing in the routed group is
    not looked up in the Public group. End-to-end on the built-in server:
    the routed landing shows its Main with the Bob link drawn as
    <code>p/Bob</code>, a static request for that page on the routed host
    serves the routed group's copy, and the same request on an unrouted
    host still serves the Public group.</li>
  <li><span class="check">&#10003;</span> <strong>B. Give the Manage Crawl
    start-crawl [+] control a single-line accessible name.</strong> Its
    accessible name renders as the letters of &ldquo;Add&rdquo; stacked on
    three lines. Landed a <code>white-space:nowrap</code> guard on
    <code>.icon-glyph</code>, confirmed on the live browser.</li>
  <li><span class="check">&#10003;</span> <strong>C. Fix the Add Role form's
    vertically-stacked Name
    label.</strong> The &ldquo;Name:&rdquo; label rendered one letter per
    line. Removed a stray <code>min-width:100%</code> on the input cell, the
    only one in the views, which was collapsing the label column in Chrome;
    verified the fixed form renders on one line.</li>
</ol>

<h2>IV. Other work</h2>
<p>Tasks that surfaced while doing the above and stand on their own.</p>
<ol class="items">
  <li><span class="check">&#10003;</span> <strong>A. Restore wiki source,
    history, and page-list access.</strong> Several related regressions in
    wiki viewing. Historical revisions render their resources server-side, a
    <code>{{require-signin}}</code> left literal inside
    <code>&lt;nowiki&gt;</code> no longer fires, the whole-page sign-in warning
    is width-bounded, the Tools &ldquo;Wiki Pages&rdquo; link is hidden where
    the public group forbids its page list, and an empty
    <code>public_source</code> falls back to the group's page-source
    setting.</li>
  <li><span class="check">&#10003;</span> <strong>B. Move wiki-revision diff
    rendering to the browser.</strong> A diff built its
    longest-common-subsequence table server-side on every crawler visit. The
    two revisions now ride to the browser and <code>renderWikiDiff</code> in
    <code>help.js</code> computes the diff there, checked line for line against
    the server <code>diff()</code>; this also fixed a latent
    <code>array_slice(..., 0)</code> that collapsed any no-common-tail diff to
    coarse.</li>
  <li><span class="check">&#10003;</span> <strong>C. Instrument mail server
    down-events.</strong> A silent mail outage left no trace, since the
    heartbeat closure never captured the server object and threw every
    interval. Fixed that capture and added a shutdown handler that records a
    fatal with loop position, memory, and fiber and connection counts;
    catchable stop signals are logged, and the resolver logs a lookup no name
    server answers in time.</li>
  <li><span class="check">&#10003;</span> <strong>D. Build shared test tables
    from ProfileModel definitions.</strong> Seven model tests stood up shared
    tables with hand-copied CREATE TABLE statements that could drift. Each now
    calls <code>ProfileModel::initializeSql</code> and executes the matching
    <code>create_statements</code> entry, so the tests track schema changes;
    MAIL_SECRET and GIT_APP_CODE, which ProfileModel does not define, keep
    their local definitions.</li>
  <li><span class="check">&#10003;</span> <strong>E. Keep the admin signed in
    when the session cookie name
    or CSRF token field name changes.</strong> Renaming either on the
    Session Parameters panel signed the admin out: the browser kept a
    cookie under the old name while the server began expecting the new
    one, and a token-field rename dropped the token from the follow-up
    redirect. The <code>renameSessionCookie</code> migration and a
    token carry-forward now run on the panel's real save path, the
    <code>updatetypes</code> case of <code>security()</code>, just before
    <code>updateProfile</code>. An earlier copy of the migration sat in
    <code>updateProfileFields</code>, which no SESSION_NAME form reaches,
    so it never ran and is removed. Verified live against the secure
    server that both renames keep the session.</li>
  <li><span class="check">&#10003;</span> <strong>F. Let users buy roles and
    subscribe to sellable ones.</strong>
    A role is put on sale by giving its <code>ROLE_LIMITS</code> row a
    <code>ROLE_COST</code> above zero and a <code>CHARGE_FREQUENCY</code>
    other than "never" (both already editable in the roles admin panel).
    A new <code>USER_ROLE.EXPIRES</code> column (database version 120,
    default <code>FOREVER</code>) records when a bought grant lapses; the
    permission queries in <code>UserModel</code> now ignore a grant past
    its expiry, so a lapsed role stops conferring its activities. On the
    account page, when any role is for sale, a Roles row under the
    language setting lists the user's roles, gives each subscribed role an
    unsubscribe link, and offers a change-password-style Purchase form that
    debits the role's cost from the credit ledger and grants it with an
    expiry one billing period out (a "once" purchase never lapses). Viewing
    the account also runs a credits-forward pass that spends the balance to
    push each lapsed subscription's expiry forward, one period at a time,
    for as many periods as the balance covers. Reworking the roles admin
    limits screen to fetch only the selected role's row, with an
    unsaved-changes confirm on dropdown change, is the immediate
    follow-on.</li>
</ol>

<h2>Other work accomplished</h2>
<ul>
  <li><strong>Purchase New Roles now opens the purchase form.</strong> The
    link carried <code>edit_purchase=true</code> but not
    <code>edit</code>, and the account page draws the purchase form only
    in its edit view, so the click landed on the read-only account page
    with nothing to buy. <code>edit_purchase=true</code> now opens the
    edit view the way <code>edit_pass</code> does, and buying or
    unsubscribing returns to that view (the purchase form carries a hidden
    <code>edit_purchase</code>, the unsubscribe link carries
    <code>edit</code>, and both redirects copy the field forward).
    Verified by rendering the account page with a role put up for sale:
    the form and the Roles row appear.</li>
  <li><strong>Role Group Limits keeps the role you were editing.</strong>
    The role drop-down was client-side only, so a save reloaded the panel
    on the first role, Admin. The drop-down now submits as
    <code>selected_role</code> and marks the chosen role selected, the
    save redirect copies <code>selected_role</code> forward, and the
    controller hands the view a <code>SELECTED_ROLE</code> it validates
    against the loaded roles. Verified by rendering the panel with
    Developer chosen: the drop-down comes back on Developer, not
    Admin.</li>
  <li><strong>Unsubscribe is now a confirm on the role
    name.</strong> A subscribed role used to print its name followed by a
    separate "unsubscribe" link; the name itself is now the link, and
    clicking it raises a browser confirm dialog ("Unsubscribe from this
    role?") before following through to the unsubscribe action.
    Non-sellable roles stay plain text. Verified by rendering the account
    page for a user holding a role put up for sale: the name carries the
    confirm and the standalone link is gone.</li>
  <li><strong>Roles label reads "Roles:" and tops the list.</strong> The
    label gained the colon its sibling rows carry, and the label cell
    takes the existing <code>align-top</code> class so it lines up with
    the first line of the roles list rather than floating to the vertical
    middle of the two-line cell. Confirmed the label cell has no competing
    vertical-align rule, so <code>align-top</code> applies.</li>
  <li><strong>Unsubscribing a role now cancels at period
    end.</strong> A cancelled sellable role is kept until its paid period
    ends but stops renewing (a new WILL_RENEW flag on USER_ROLE, schema
    version 121 with its migration), and the account page shows the
    expiry date with a resubscribe link that only turns renewing back on
    and charges nothing. The account-page settle step removes a cancelled
    grant once it lapses, while a grant already lapsed, or a one-time
    permanent buy, goes at once. Confirmed by running the component
    methods: cancel keeps the role with the flag off, resubscribe
    restores it with no charge, a lapsed cancelled grant is removed while
    a lapsed renewing grant is pushed forward and charged, and the page
    renders the expiry and link; the same model is meant to carry to
    group subscriptions, which do not yet exist in the tree.</li>
  <li><strong>Resubscribe is the expiry link, and the cell wraps.</strong>
    For a cancelled role the whole "expires DATE" text inside the
    parentheses is now the link, and clicking it asks "Would you like to
    resubscribe?" before resubscribing; the role name and the commas
    between roles stay plain text outside any link. The roles cell now
    sits in a container capped at the form field width so a long roles
    list wraps instead of widening the column and pushing the save button
    out of place. Confirmed by rendering both states: the link wraps only
    the expiry text with the resubscribe prompt, no comma falls inside a
    link, and the cell content sits in the width-capped container.</li>
  <li><strong>pollett.org mail server no longer runs out of
    memory on an unterminated SMTP message.</strong> The daemon died with
    Allowed memory size exhausted during a socket read, memory having
    jumped from about 145&nbsp;MB to a gigabyte in under a minute: each
    connection's received bytes accumulate in one buffer until a complete
    command or, in the DATA phase, the &lt;CRLF&gt;.&lt;CRLF&gt; marker is
    seen, and while the command phase already dropped a line passing
    64&nbsp;KiB, the DATA phase checked the message size only after the
    terminator arrived, so a body streamed and never terminated grew
    without bound and took every other connection down with it.
    consumeSmtpDataPhase now drops the connection once the buffered body
    passes MAX_MESSAGE_LEN with no terminator in sight, the way an
    over-long command line is already handled; a message that ends within
    the limit, or whose terminator lands in the same read that carries it
    past the limit, still gets the existing 552 reply. A MailSiteTest
    group covers the three outcomes: an unterminated over-limit body
    drops the connection, a short partial body keeps waiting, and a
    terminated over-limit body still receives 552.</li>
  <li><strong>The wiki parser renders a page's block-class
    boxes, resource links, and stacked headings the way the old parser
    did.</strong> The recursive-descent WikiParser that replaced the old
    regex parser had dropped three constructs the frise.org front page is
    built from, so that page came out as literal markup, and three fixes
    restore the intent without the raw-html passthrough the old nowiki
    route allowed. A block-class directive now always emits a
    &lt;div class="..."&gt;, in a block or inline, where plain class stays
    a span; a link splits on its first top-level bar only, so a bar nested
    inside a resource, a template, or an inner link is skipped and
    [[url| ((resource-nolink:file|alt))]] keeps its href while the
    three-field relationship|page|shown form still points at its middle
    field; and a heading may run over several lines, an opening = with no
    close gathering following lines until one ends in a =, with a blank
    line or the end of input leaving the block to ordinary parsing. A
    WikiMarkdownTest group covers all three, including the frise.org title
    stack, and the existing corpus and markdown suites still pass
    unchanged.</li>
  <li><strong>A nowiki example that spans several lines now
    stays whole when it starts on a space-indented line.</strong> Writing
    sample wiki markup in the Wiki Syntax help page surfaced this: a
    single leading space marks a line as preformatted, the run was
    gathered one indented line at a time, and when a &lt;nowiki&gt; opened
    on an indented line but its &lt;/nowiki&gt; sat past an unindented
    line, the run ended too soon, so the opener was shown as literal
    &lt;nowiki&gt; and the rest of the example parsed as ordinary
    text. The space-preformatted reader now does what the paragraph reader
    already did: while a nowiki span opened inside the run is still open
    it keeps taking lines as typed, including unindented ones, until the
    span closes, then returns to the leading-space rule. A WikiParserTest
    case covers the multi-line case, a single-line nowiki on an indented
    line is unchanged, and the parser, corpus, and markdown suites still
    pass.</li>
  <li><strong>The Appearance activity now follows the domain
    in the address bar, so on a multi-domain instance the admin edits the
    look of the domain they are actually viewing.</strong> The Customize
    Domain picker used to default to the default scope and reload the same
    host when changed; it now defaults to the current host when that host
    is one of the configured route domains, and changing it moves the
    browser to the host serving the chosen scope. Because the two domains
    are different registrable names that cannot share a session cookie,
    the switch carries a single-use handoff token: the source host mints
    it with setSessionUser tied to the signed-in user, and the target
    host, in AdminController, redeems it the way a dropped session is
    rehydrated from a cookie, then deletes it so it cannot be replayed.
    The picker's script, delivered through the controller's script data so
    it runs after the page and its base helpers have loaded, marks the
    form dirty on any field change and confirms before switching;
    SystemComponentTest covers the scope-selection rule, while the live
    cross-host handoff needs a multi-domain instance to exercise end to
    end.</li>
  <li><strong>The Appearance domain switch now builds its
    links the way the rest of the site does, and follows the site's own
    url scheme.</strong> The picker's switch link and the redirects behind
    it were assembled by hand as a query on the site root rather than
    through the shared controller-url builder every other link uses, so
    they pointed at a different path from the activity's own form; they
    now come from that builder. The switch also derived its url scheme
    from the site's base-url constant, which in a request is a
    host-relative path carrying no scheme at all, so the check always fell
    through to https and a plain-http site would have been sent to an
    https address that may not answer; the scheme now comes from the
    resolver that reports what the request actually arrived over. Checked
    against a running server with a routed domain configured: the switch
    link renders in the site's clean path form, the switch emits an
    absolute http address with its single-use handoff token on a
    plain-http site, and saving a colour for a routed domain stores it and
    returns to the Appearance form for that domain.</li>
<li><strong>Each domain a site serves now has its own themes,
    and the theme a domain is set to is the one it actually
    shows.</strong> Themes used to sit loose in the app css folder and be
    shared by every host, and a domain's chosen theme was never worn: the
    page layout read the theme name from the site-wide setting while only
    the domain overlay knew the domain's choice, so a routed domain
    dressed itself in the site's theme; each theme now belongs to a scope,
    is worn by the domain that chose it, and is kept in a folder of its
    own, app/css/&lt;domain&gt; for a
    routed domain and app/css/default otherwise, so two domains may each
    have a theme of the same name and each domain is offered only its own,
    and a version 122 step moves themes already in the css folder into the
    default folder while leaving a search.css override where it is.
    Adding or editing a theme while a domain was picked saved nothing at
    all, as the domain branch returned before the theme was written, and
    the editor showed the domain's theme name against the site theme's
    rules, so an edit would have saved them over the wrong theme; both are
    fixed, and the Appearance form now keeps its values apart from the
    fields dressing the page, so it shows the scope the admin picked
    rather than the look of the domain they are browsing. Checked against a running server with 127.0.0.1
    configured as a route domain: adding a theme there writes it to that
    domain's folder, the domain's pages link and serve it, the form shows
    the domain's theme on the domain scope and the site's on the default
    scope, and rolling a work directory back a version moves its loose
    themes into the default folder.</li>
<li><strong>Deleting a theme works again, and the Appearance
    activity stops offering a theme that is not there.</strong> Moving
    themes into a folder per scope left the delete step looking for them
    where they used to be, in the css folder itself, so deleting any
    theme reported an error and removed nothing; delete now works in the
    scope's own folder, and deleting a domain's theme clears only that
    domain's record of it, leaving the domain's other settings and the
    site's own theme alone. The activity also offered an edit pencil for a
    theme the scope does not have: the theme named by the domain was laid
    over the form after the check that the named theme is one of those on
    offer, so a name left in a domain's record by the earlier
    save-does-nothing bug, when a theme was named but its file never
    written, still reached the form and opened an editor with no rules
    that could not delete; the check now comes after the domain has had
    its say, so a name with no theme behind it leaves the form offering no
    theme at all. The Adjust Search Page Components link is gone from the
    activity, as search page components are the same for every domain and
    the link sitting under a domain picker suggested otherwise; the
    setting is still on Page Options, where it reads as the site-wide
    choice it is.</li>
<li><strong>Switching the Appearance domain to another host no
    longer signs the admin out.</strong> The switch hands the target host
    a single-use token naming the signed-in user, and the target restored
    that user's session by reading the saved session blob a user carries;
    nothing writes that blob at sign-in, so it was always empty, the
    restore was skipped, and the token was spent anyway, leaving the admin
    at the sign-in page on the domain they had just switched to. The token
    is now redeemed by signing that user in under the target host's own
    session, the way signing in on that host does, which is all the rest
    of the request needed to treat them as signed in, and it is still
    spent the moment it is presented so it cannot be offered twice.
    Checked against a running server with a name server host and a routed
    domain: switching from the default scope to the domain lands signed
    in, adding a theme there then works rather than dropping to the
    sign-in page, and presenting the same token again from another
    browser gets the sign-in page.</li>
<li><strong>Signed-in sessions survive being dropped, so the
    Appearance domain switch no longer ends at the sign-in page.</strong>
    A session lives in the serving process's memory, and when it is not
    there, because the request reached a process without it or the server
    had restarted, the code meant to put it back read a saved copy of the
    session that is only written when a user changes their settings, so it
    was almost always empty and the visitor was sent to sign in; the same
    empty read is why the domain switch appeared to work and then dropped
    the admin on the next click. Restoring now uses the record of which
    user a session belongs to, which every sign-in writes, and puts the
    sign-in back under the session id the host is handing the browser
    rather than the one the browser arrived with and will never send
    again, along with the record of a recent token that a request without
    one needs to be given a fresh one; a domain switch also counts as
    consent to a session, so a domain the browser holds no cookie for yet
    has a session to sign the admin into. Checked against a running server
    with a name server host and a routed domain: after dropping the
    session, both a request carrying a token and a plain visit to the
    admin address stay signed in where both previously went to the
    sign-in page, and switching to the domain then adding a theme survives
    a dropped session and works from a browser with no cookie for that
    domain.</li>
<li><strong>The Search Toolbar upload moved from Appearance to the
    Page Options Search Time tab.</strong> A search toolbar is the XML
    file a browser reads to offer this site as one of the choices in its
    own search box, so it names the site once for a whole browser and has
    nothing to do with how a page looks; it was worse than misplaced,
    being one of the fields kept per domain, so each domain could hold its
    own and the last one saved is what a browser would have been handed.
    It is now a setting of the site alongside the other search page
    choices, and Appearance uploads images only, which took the file type
    test back to a plain check rather than one carrying an exception for
    the one file that was not an image; a domain row saved before this
    still holds a toolbar, so that field is now passed over when a
    domain's look is laid over a page, the way the landing page setting
    already was. Checked against a running server: the control is gone
    from Appearance and draws on Search Time, uploading an XML file puts
    it in the resources folder and names it in the profile, a file that is
    not XML is turned away, and a domain still holding an old toolbar gets
    the site's toolbar while keeping its own colors.</li>
<li><strong>The Search Toolbar control reads as part of the Search
    Time tab.</strong> It had been dropped in among the search page
    element checkboxes in a table of its own, so its label was set in a
    lighter face than everything around it and it sat under a heading it
    had nothing to do with. It now has its own Browser-based Search
    heading at the foot of the tab, after Search Results Grouping, with
    the control in the same kind of table and the label in the same cell
    as Minimum Results to Group directly above it, so the two labels come
    out at one size and weight. Rendered the tab headless to check it: the
    heading falls between Search Results Grouping and the end of the tab,
    and the Search Toolbar label matches the label above it.</li>
<li><strong>The Browser-based Search heading has a help button of
    its own.</strong> Every other heading on the Search Time tab carries
    one, so the new heading was the only place on the tab offering no
    explanation of what its control does. The button points at a
    Browser-based Search help page, whose source goes to Chris as a text
    file to paste through the wiki UI, since the file holding the help
    pages is generated rather than written by hand.</li>
<li><strong>The server names the request that makes it claim more
    memory.</strong> The memory sample says how much the server holds but
    not what asked for it, and since PHP hands memory back to the system
    rarely the figure only ever climbs, so reading it later says nothing
    about which request did the climbing. Reading the figure either side of
    each request turns that into a line naming the request the moment it
    happens, written only when the claim actually grows, which on a server
    that has been up a while is never, because by then requests are served
    out of memory already claimed. Checked on a running server: the first
    request wrote one line naming itself and reporting four megabytes, and
    the two requests after it wrote nothing.</li>
<li><strong>A video's width, height and running time are read once and
    kept, rather than by starting ffprobe on every view.</strong> These
    figures go into a video's page as the tags a link preview reads, and
    they only change when the video does, but each view of the page started
    an ffprobe process to work them out again; a crawler walking a site's
    videos made that a process per hit, which is what the Unable to fork
    warnings in the live log are. They now come from a note kept beside the
    video's thumbnails, holding the time the video was last changed so a
    replaced video is measured again; the test for whether the site has
    ffmpeg at all was also asking the wrong question, being true even when
    the setting is empty, which had every video view on an install without
    ffmpeg starting a shell to run an empty command. Measured against a
    real video: reading the figures took 80.7ms the first time and 0.1ms
    after that, and changing the video had them read again.</li>
<li><strong>Themes are one list the whole site shares, and a domain
    picks which of them it wears.</strong> Giving each domain its own
    folder of themes, as the earlier per-domain themes work did, meant a
    domain could not wear a theme another domain had made, and a wiki
    page's own choice of theme was worse off still: the page settings
    offered the themes of the default folder while the page was dressed
    from the folder named for the host it was served on, so on a routed
    domain a page named a theme that was not where it was looked for and
    went undressed. Every theme now lives in the one folder, and all that
    a domain, or a wiki page, holds is the name of the theme it wants; a
    new database version folds each domain's folder into the shared one,
    dropping a copy identical to one already there, and where a name is
    taken by different rules it brings the domain's theme over with the
    domain's name added to its own and points that domain's record at the
    new name, so a domain goes on looking as it did. Checked on a running
    server: a theme made for the site is offered while customizing a
    domain, where before that list was that domain's own and empty, and
    the domain's pages load it; the three cases of the fold were each
    exercised against a real database.</li>
<li><strong>The site has a name, and each domain may go by one of its
    own.</strong> A page's title and the label on its logo said Yioop
    because the words were written into the locale strings themselves, so
    a site serving several domains announced itself as Yioop on all of
    them and a page came out titled for two sites at once. Site Name is
    now an Appearance setting a domain may override like any colour, the
    title string takes the name rather than containing it, and a page
    that gives itself no title is titled for the site alone instead of
    for the site and a separator with nothing after it. Checked on a
    running server: the default host titles and labels itself Yioop while
    a domain named Frise does so as Frise, and the two locale strings
    that had held the names are deleted.</li>
<li><strong>The site also has a motto, and the two together title a page
    that has no title of its own.</strong> The words This Search Engine
    and This Site had been written into a locale string, which is a
    placeholder rather than a translation and named neither the site nor
    the domain being served; they are now the Site Motto and Site Name
    settings and the string takes them both. Because the profile writes
    an empty value for a setting it has never been given, and an empty
    value beats the default named in Config, a new site would have come
    out with no name at all: the defaults are put in where the profile is
    first written, and the Appearance form falls back to them the way it
    already did for the logos. Checked on a running server: the default
    host titles itself This Search Engine - Yioop while a domain given
    its own name and motto uses those.</li>
<li><strong>The theme dropdown lines up with the buttons beside
    it.</strong> The dropdown asked for a height of a fixed fraction of
    an inch while the buttons take theirs from the glyph they show, so
    the two boxes were sized by unrelated rules and only ever matched by
    luck; worse, the dropdown had no box-sizing, so the height written
    for it was silently not the height it got, the padding and border
    being added on top. The row is now laid out as a row and the dropdown
    takes whatever height the buttons come out as, which holds wherever
    the page is read rather than only where the glyph happens to measure
    what it does here.</li>
<li><strong>A page's resources can be downloaded as one zip.</strong>
    Anyone wanting a copy of what a page holds had to fetch the files one
    at a time and the folders beside them a level at a time, so the
    Actions menu over a page's resources now offers Resources ZIP, which
    packs the folder and everything below it. A folder larger than the
    limit named in Config is refused outright rather than sent short,
    since an archive quietly missing files is worse than none, and the
    limit is counted in powers of ten so that the size a reader is told
    is the size being enforced. The archive is built on disk and read
    back a block at a time through the streaming path rather than held
    whole the way the mail attachment zip does it, which at this size
    would have cost the always-on server a gigabyte it never gives
    back.</li>
<li><strong>The resources download opens as the page keeps
    them.</strong> It had packed only the resource folder, and packed it
    loose under a fixed name, so it unpacked as a heap into whatever
    folder it was opened in, carried the folder of earlier versions along
    with it, and gave every page the same file. The archive now opens as
    one folder holding a resources and a thumbs folder, named after the
    page with spaces written as underscores and anything else outside
    what a file name may hold url encoded, and the version record is left
    out wherever it appears and not counted against the size limit; the
    menu item reads Page Resources ZIP and follows Upload File. Checked
    by building real archives over a folder with a sub-folder and a
    version record: the layout and the names are as expected and none of
    the record is in them.</li>
<li><strong>Deleting a resource returns you to the folder you
    deleted it from.</strong> Where a reader is sent afterwards is worked
    out from the fields the request carried, and the delete link named
    the page and the folder but never said it was being used from the
    resource listing, so the reader was returned to the page's edit form
    instead and had to find their way back. The link now says so, as does
    the extract link beside it, which was built the same way and went the
    same place.</li>
<li><strong>A folder dropped onto a page's resources keeps its
    folders under a web server too.</strong> The folders a dropped file
    came from were read off the name the browser gave it, but PHP takes
    the folders off that name before a handler ever sees it, so under
    Apache every file landed in one heap while under the command line
    server, which does no such thinning, the folders survived. The path
    is now read from full_path, the field both leave whole and which the
    upload already required, so no name has to survive a parser that is
    entitled to rewrite it. The sub-path built from it has parent steps
    taken out where it is turned into a folder, as before.</li>
<li><strong>The resources download really is named after its
    page.</strong> It was meant to be, but the name was being read from a
    field the page record does not have, so it was always empty and every
    download fell back to the fixed name the fallback exists for, whatever
    page it came from. The name of the page being edited was in scope the
    whole time and is now what is used. Checked against a real page
    record: the field used before is absent from it, and the one used now
    gives an archive named for the page.</li>
<li><strong>The block- forms of id and style work again.</strong>
    The parser that replaced the old one knew block-class but not
    block-id or block-style, so pages written for the old parser had
    those directives show as text where a block was meant; they had been
    a way of giving a whole region an id to link to or a style to lay it
    out by. The three now sit in one list saying which attribute each
    sets, so writing class, id or style on its own still wraps text in a
    span that keeps flowing, while any of the block- forms wraps it in a
    div wherever it is written. Tested for each form, in the middle of a
    line as well as alone.</li>
<li><strong>A media list page can be served as a static HTML
    folder.</strong> Showing an index page only ever replaced the top
    folder, so a folder of generated documentation came out unstyled and
    its links dead: everything the index asked for by a path relative to
    itself was read as a folder to browse and answered with the wiki page
    instead of the file. The setting is now Static HTML Folder, with
    Directory Indexes and Index File beside it, and a path beneath such a
    page is answered with the file it names, out of that page's resources
    and nowhere else, a block at a time; a folder is answered with its
    index file, or with a 404 when directory indexes are off. Both the
    group url and the static p/ url do this through the same code, so a
    domain serves its docs the same way, and a page set up under the old
    setting names goes on working.</li>
<li><strong>Static HTML folders serve the way a web server
    serves.</strong> The first cut read the two settings as one, so
    directory indexes had to be on for a folder to reach its index file
    at all, unchecking Static HTML Folder still served as one, a folder
    asked for without its closing slash was not found rather than sent to
    the slash, and a step back up a path was dropped rather than taken.
    Index File now says what stands for a folder and accepts several
    names tried in order, while Directory Indexes only says whether a
    folder holding none of them shows the page's resource listing or is
    not found. The two rows are shown only when the page is a static HTML
    folder, since they say nothing otherwise.</li>
<li><strong>A signed-in reader is no longer sent away from a
    static HTML folder.</strong> A signed-in reader whose address carried
    no token was sent once to the same request with one put on it, which
    is harmless for a page but not for a folder of files: the address
    landed on is no longer the folder, so every stylesheet, script and
    link a file names relative to itself was looked for one level up, and
    the file arrived unstyled with dead links. That is why a docs folder
    read while signed out was fine and the same folder read while signed
    in was not. A read of a static HTML folder is now answered where it
    was asked for, while every other page is sent as before.</li>
<li><strong>Several index file names save as well as serve.</strong>
    Index File takes a comma separated list tried in order, and serving
    already read it that way, but saving cut the whole line back to a
    plain file name in one go: any list in which a name carried a slash
    kept only what came after the last one and quietly lost every name
    before it. Each name is now cut back on its own, as serving does, so
    what is saved is what was meant. Checked by serving a folder whose
    only index file is the last name in the list and one whose only index
    file is the middle name.</li>
<li><strong>A git repository page can be read at its static url
    too.</strong> Reading one was only ever set up on the group
    controller, so a repository in the Public group could be browsed at
    its group url but not at its p/ one, which showed the page's text
    rather than the repository. The static controller now sets a
    repository page up the same way the group controller does, through the
    same code, and the static view shows it with the same element the
    group view uses, so a routed domain reaches its repositories this way
    as well. The Index File setting is also now labelled Index Files and
    says that a comma separated list may be given.</li>
<li><strong>The commit and tag lists fill in as they are scrolled
    again.</strong> The call that sets that up was written into the list
    itself, but the file it lives in is loaded at the end of the layout,
    after the page's own content, so the call ran before the function
    existed and the reader saw only the first page of rows. It is now put
    where the layout writes it out after that file is loaded, which is
    the same place the page's other scripts go. This had been so on the
    group url as well as the static one; the static page is only where it
    was noticed.</li>
<li><strong>A repository carrying a ref that names another ref can
    be listed again.</strong> Every ref file was read as though it held an
    object's name, but one can instead hold the name of another ref, as
    the file recording which branch a remote's HEAD follows does; its
    contents were then handed on as an object name, nothing could be read
    under that name, and the listing a client is given died outright,
    which reaches the client as an empty reply and stops a clone or a
    push before it starts. Such a ref is now passed over, as it is absent
    from that listing anyway. Checked against a repository carrying one:
    the listing died before and lists the branch after.</li>
<li><strong>Adding a user gives them their role again.</strong>
    The row granting a new user the ordinary user role was written by
    giving two values in order, but a role grant has since gained two
    columns saying when it lapses and whether it renews, and a row given
    fewer values than the table has columns is refused outright, so every
    user added other than through the account pages ended up holding no
    role. The columns are now named, as they already are where the first
    accounts are seeded, which lets the two later ones take the defaults
    they were added with. This surfaced while importing an issue tracker,
    which adds a user per reporter.</li>
<li><strong>A file carrying an image's name but holding no image
    no longer warns.</strong> Reading such a file hands back false, but
    that was passed on to the turning and thumbnail steps below, which
    need an image to work on, and the site's own error handler reported
    the read rather than letting it answer, so importing an issue
    tracker's attachments filled the run with warnings and each such file
    was reported as having been given a thumbnail it never got. The read
    now answers for itself, as the exif read beside it already did, and a
    file that holds no image is left without a thumbnail and said to be.
    Checked against a file of plain text named .png, a truncated one, and
    a real image.</li>
<li><strong>No stray ampersand after the question mark.</strong>
    The routines that build a url leave it ready for the first thing in
    its query, and the bar put the signed-in reader's token there and
    wrote a separator before whatever came next; with nobody signed in
    there is no token, so what came next was itself the first thing and
    the separator it was given had nothing to separate, giving addresses
    reading question mark ampersand. The separator now belongs to the
    token, which is what needs one, so an address is well formed whether
    or not anyone is signed in. Checked signed out, where such addresses
    were two and are now none, and signed in, which is unchanged.</li>
<li><strong>An issue can be thrown away.</strong> There was no way
    to be rid of an issue once reported, which an import that brings in
    hundreds at once makes felt. A wastebin now sits on the line saying
    when an issue was reported and by whom, offered to whoever owns the
    group or may edit its pages and to nobody else, and it asks before it
    acts because there is no undoing it; an issue is kept as a page of its
    own, so throwing it away takes its record, history, discussion and
    uploads with it. Checked by deleting through the link as an editor,
    and by trying the same link signed out and with a stale token, neither
    of which removed anything.</li>
<li><strong>A routed domain lists its own wiki pages.</strong> A
    domain pointed at a group serves that group's pages, but the list of
    them is read through the group controller rather than the static one,
    and it alone was never told which group the domain serves; it asked
    the site's own public group instead, so every routed domain answered
    with that group's pages and that group's say over who may see them,
    however its own was set. The list is now asked of the group the
    domain serves, from the routing an admin set rather than from
    anything a request can name. Checked both ways: a routed domain whose
    group keeps its list to itself now refuses, and one whose group
    shares it shows its own pages and none of the site's.</li>
<li><strong>A group keeping its page list to itself says so
    properly.</strong> Refusing a stranger the list showed the page for
    saying a crawled copy of something could not be found, which has
    nothing to do with the ask and told the reader nothing; the reader is
    now sent where the site says a page is not there, so a routed domain
    says it in its own words on its own address. A domain may never have
    been given a page of its own for saying that, and answering with
    nothing at all would be worse than answering with the site's, so that
    one page falls back to the site's copy while a domain's ordinary
    pages stay its own. Checked with and without a page of its own, and
    the site's own list and pages are unchanged.</li>
</ul>

</body>
</html>
X