Author SHA1 Message Date
hermes 4163885e4c Merge pull request 'fix: empty dialogs - relative project path caused a silently-swallowed 403 (+ cache-busting)' (#7) from fix/cache-busting into master 2026-09-29 16:55:35 +10:00
hermes be8c959de2 fix: dialogs rendered EMPTY - relative project path caused a silent 403
THE ACTUAL CAUSE of "codegit is just a black box". Sections 4 and 5 were real bugs but
neither was it; this is.

CHAIN
  1. install/process.php stores a RELATIVE folder name -> projects.db {"Blog":"blog"}
  2. common.php::loadState() does $projectPath = reset($projectList) -> "blog", and
     SESSION("projectPath","blog") PERSISTS it, so the bad value sticks across logins
  3. filetree puts it straight into the DOM: <a id="project-root" data-path="blog">
  4. codegit sends repo="blog"; getWorkspacePath("blog") fails checkPath() ->
       traits/path.php:67  Common::send(403, "Client does not have access.")
  5. modal.js SILENTLY discards it:
       settled: function(reply,status){ if (status !== 200) return; ... }
     No toast, no console error, #content left empty.

That last step is why it presents as an empty black dialog rather than an error - the request
fails and nothing reports it. Which is exactly why it survived the CSS fixes.

PINNED DOWN BY CAPTURING THE REAL REQUEST, not by reading CSS:
  POST /dialog.php {"target":"codegit","action":"codegit","repo":"blog"} -> 403
  #content innerHTML length: 0
  getWorkspacePath("blog") -> 403
  projects store           -> {"Blog":"blog"}

FIX (scripts/fix-project-path.sh) - both parts required:
  A. resolve the path in loadState and re-store it, which also HEALS sessions already
     holding the relative value (and re-persists so it stays healed)
  B. make the projects store absolute, so array_search() can still map path -> name for
     the tree label. Fixing only (A) would silently break the label: the path would resolve
     but array_search would return false and the tree would show an empty project name.

VERIFIED from a deliberately poisoned session ("blog"): it self-heals to the absolute path,
dialog returns 200, #content fills with 1758 chars. An EXISTING browser session heals on its
next page load - no hard refresh or re-login needed.

Tests: 47 passed, 0 failed, 1 accepted-risk
  + projects store must hold an absolute path
  + loadState must resolve a relative projectPath

Also marks section 4 as superseded so a future reader doesn't stop at the CSS explanation.
2026-09-29 16:55:08 +10:00
hermes 48e439ed6e fix: cache-bust asset URLs - the real reason the panel fix appeared not to work
Reported again as "code git still no go just a black box is it because I'm on firefox?"

It is NOT Firefox. Tested properly: installed Firefox 155 and drove the live server with
Playwright, authenticated. Result:

  assets Firefox requested:  theme/main.min.css?v=1790663824   <- versioned
  applied panel rule:        height: auto; max-height: 58vh    <- the fix IS applied
  dialog 193px | panel 138px | toolbar INSIDE | JS errors: none

CodeGit renders correctly in Firefox. Firefox was simply still running the OLD CSS.

WHY THE EARLIER FIX DIDN'T TAKE
  Atheos emits asset URLs with no version:
    <link rel="stylesheet" href="theme/main.min.css">
    <script src="public/modules.min.js"></script>
  Same path before and after a change, and originally no cache headers at all. So the browser
  keeps its cached copy and never even asks the server. Changing the CSS server-side cannot
  help when the browser isn't fetching it - which is why the previous Cache-Control fix was
  necessary but NOT sufficient against a cache that was already poisoned.

FIX - versioned URLs
  SourceManager::getTag() and the theme link in index.php now append the file mtime:
    theme/main.min.css?v=1790663824
  A changed file gets a NEW URL, so it is fetched immediately; an unchanged file keeps its URL
  and stays cached. This works even against an already-stale cache, which headers alone cannot
  do. The page itself is served no-store, so the next normal reload picks up new URLs
  automatically - no hard refresh needed by the user.

Both parts are now in scripts/fix-panel-layout.sh (idempotent, verified):
  1. Cache-Control: no-cache for *.css/*.js (revalidation; keeps 304s fast)
  2. mtime-versioned asset URLs (the part that actually defeats a poisoned cache)

Tests: 45 passed, 0 failed, 1 accepted-risk
  + asset URLs must carry ?v=  (guards against this regressing and silently hiding future fixes)
2026-09-29 16:47:28 +10:00
hermes 012711614c Merge pull request 'docs: branch protection as an accepted risk (not a blocker); suite exits 0' (#6) from docs/branch-protection-accepted into master 2026-09-29 16:41:07 +10:00
hermes c57aff3c33 docs: branch protection is a recommendation, not a blocker (operator decision)
The operator has decided not to enable branch protection on armistace/blog while the editor
is LAN-only:

  "leave it as a recommendation - I won't do that unless I take this out to the wider internet"

That is a reasonable call, so this records it as an ACCEPTED RISK rather than an open task.

Test behaviour changed to match. The suite still attempts a direct push to master with the
deploy key on EVERY run - the check is not removed, because that is what would catch the
setting being lost or never applied. But when protection is off it now reports
  ACCEPTED-RISK  master is NOT protected - the deploy key CAN push master directly
instead of FAIL, so the suite is green and CI-usable (it previously exited 1, meaning a
genuine regression would have been buried in expected noise).

The run flips back to "PASS master is protected" by itself the moment protection is enabled,
so the recommendation self-cancels with nobody having to remember it. Summary line now reads
  RESULT: 44 passed, 0 failed, 1 accepted-risk

Docs record WHY the risk is acceptable (the compensating control is reachability, not
immutability - nothing off-LAN can reach the editor, and the only LAN user is the operator)
and the two triggers that turn it back into a real finding:
  1. the editor becomes reachable off-LAN (NPM proxy, port-forward, VPN route)
  2. anyone other than the operator gets access
Written down because an accepted risk is only acceptable if it is explicit and has a
revisit trigger - otherwise it just looks like an oversight later.
2026-09-29 16:40:49 +10:00
hermes 1ff8aaf2f1 Merge pull request 'fix: CodeGit black box - hardcoded panel height + CSS cache' (#5) from fix/codegit-panel-layout into master 2026-09-29 16:39:02 +10:00
hermes 2d57173e31 fix: CodeGit looked like an empty black box - hardcoded panel height + cache
Reported as "why can't i see anything from codegit its just a black box".

NOT a codegit bug. Verified first, exhaustively, through the authenticated API:
  overview  200    940 bytes
  log       200  79596 bytes   (full commit history)
  transfer  200   1460 bytes
  config    200   1174 bytes
no JS errors in the console; every DOM helper codegit calls exists.

ROOT CAUSE 1 - a generic CSS rule hardcodes a height
  theme/modules/common/_panel.scss -> theme/main.min.css:
    panel { ... height: 500px; ... }
  That forces EVERY modal panel to a fixed 500px box. Measured live:
    #dialog            404px  (clamped by max-height:70vh + overflow-y:auto)
    #codegit panel     500px, while its content is only 119px
    toolbar ("Branch") top 620 vs dialog bottom 490 -> OUTSIDE the modal
  So ~380px of the dialog was empty dead space and the Branch line rendered below the
  modal, invisible. Fixed to height:auto with max-height:58vh (long content still scrolls):
    before: panel 500 -> toolbar top 620, dialog bottom 490 (outside)
    after:  panel 136 -> toolbar top 256, dialog bottom 279 (inside)
  _panel.scss updated too so the source does not contradict the shipped CSS.

ROOT CAUSE 2 - the fix was invisible because of the browser cache
  Atheos serves theme/main.min.css and the generated bundles from stable URLs with no cache
  headers, so browsers legitimately reuse their cached copy - which is why the first attempt
  appeared to do nothing. nginx now sends Cache-Control: no-cache, must-revalidate for
  *.css/*.js: caching still works (cheap 304s) but changes are always picked up.

Adds
  scripts/fix-panel-layout.sh   idempotent re-apply for both fixes (lost on update-atheos)
  docs/known-issues.md §4, §5   both findings, with the measurements

Tests (44 passed, 1 failed - the failure is branch protection, a genuine open item)
  + panel must not be fixed at 500px
  + theme CSS must send Cache-Control: no-cache
2026-09-29 16:38:48 +10:00
hermes c65900638e Merge pull request 'fix: rDelete fatals on musl (GLOB_BRACE) - broke ALL deletion and plugin removal' (#4) from fix/glob-brace-musl into master 2026-09-29 16:28:57 +10:00
6 changed files with 526 additions and 28 deletions
+226 -8
View File
@@ -224,7 +224,216 @@ Worth doing if the dialog starts 500-ing again after editing a macro in the UI.
--- ---
## 4. Checking for plugin problems in future ## 4. 🟠 The CodeGit panel looked like an empty black box
**Symptom:** the CodeGit dialog appeared as a mostly-empty black rectangle. Nothing was *broken* —
it was unreadable, which reads as "broken".
> **⚠️ Superseded by §6.** This section fixed a real layout bug, but it was NOT the cause of the
> empty CodeGit dialog. The actual cause was a relative project path making the request 403, which
> the modal silently swallowed. Both are fixed; if a dialog is ever blank again, read §6 first.
**This was NOT a codegit bug in the JS sense.** Verified exhaustively first:
```
all four panels return real content through the authenticated API:
overview 200 940 bytes
log 200 79596 bytes (full commit history)
transfer 200 1460 bytes
config 200 1174 bytes
no JS errors in the browser console; all DOM helpers (replaceClass etc.) exist
```
**Root cause — the generic `panel` rule hardcodes a height.** `theme/modules/common/_panel.scss`
(after minification, in `theme/main.min.css`):
```css
panel { padding: 5px; width: calc(100% - 200px); overflow: auto; overflow-y: scroll;
height: 500px; position: relative; border-left: var(--borderLight); }
```
Measured live, that produces:
| Element | Height | Problem |
|---|---|---|
| `#dialog` | 404px | clamped by `max-height: 70vh` + `overflow-y: auto` |
| `#codegit panel` | 500px | **forced**, while its content is 119px |
| `toolbar` ("Branch: …") | top **620** vs dialog bottom **490** | **pushed OUTSIDE the modal** |
So ~380px of the dialog was **empty dead space**, and anything after the panel — including the
Branch line — rendered *below* the modal, invisible. A generic `panel { height: 500px }` is exactly
the kind of rule that looks like a void.
**Fix applied:**
```css
panel { ... height: auto; max-height: 58vh; overflow-y: auto; ... }
```
Content-sized, capped so long content (the 79KB log) still scrolls. Verified live:
```
before: panel 500px → toolbar top 620, dialog bottom 490 (outside)
after: panel 136px → toolbar top 256, dialog bottom 279 (inside)
```
`theme/modules/common/_panel.scss` is updated too, so the source does not contradict the shipped CSS
(there is no Sass compiler on the guest, so `main.min.css` is edited directly).
---
## 5. 🟠 CSS/JS changes were invisible behind the browser cache
**Symptom:** a fix to the theme did not appear until a hard refresh — so the panel fix in §4 looked
like it had not worked at all. Reported twice, the second time with "is it because I'm on Firefox?"
**It was not Firefox.** Verified in a real, scripted Firefox (155.0) against the live server with the
fix applied:
```
assets Firefox requested: theme/main.min.css?v=1790663824 <- versioned
applied panel rule: height: auto; max-height: 58vh <- the fix
dialog 193px | panel 138px | toolbar INSIDE | JS errors: none
```
The panel rendered correctly and the Branch toolbar sat inside the modal. Firefox behaves identically
to Chromium here; it was simply still running the **old cached stylesheet**.
**Root cause — stable asset URLs, with nothing to bust them.** Atheos emits:
```html
<link rel="stylesheet" href="theme/main.min.css"> <!-- no version -->
<script src="public/modules.min.js"></script> <!-- no version -->
```
Same path before and after a change, and originally no cache headers at all. A browser therefore keeps
its cached copy across an update, and never even asks the server. Changing the CSS on the server
cannot fix that — the browser is not fetching it.
**Fix (two parts):**
1. **nginx** sends `Cache-Control: no-cache, must-revalidate` for `*.css`/`*.js`. This does **not**
disable caching — it forces **revalidation**, so normal loads still get a cheap 304 while changes
are picked up.
2. **Versioned URLs.** `SourceManager::getTag()` and the theme link in `index.php` now append the
file's mtime (`?v=<mtime>`). A changed file gets a **new URL** so it is fetched immediately; an
unchanged file keeps its URL and stays cached.
Part 2 is the one that actually mattered here: it works **even against an already-poisoned cache**,
which part 1 alone cannot do. Once the *page* refreshes (it is served `no-store`), every asset URL it
references is new, so the stale CSS is bypassed with no manual clearing.
**If you ever see a stale UI again:** force a reload (Ctrl/Cmd-Shift-R). If that fixes it, this
section is why.
**Forcing this one time:** if a browser holds an old copy, any of these work —
Ctrl/Cmd-Shift-R, `about:blank` then back, a private window, or `rm -rf` the profile's cache. Or just
change the file's mtime (`touch`) and the URL changes by itself.
---
## 6. 🔴 Every dialog rendered EMPTY (CodeGit "just a black box")
**Symptom:** CodeGit — and, it turns out, the Macro / Market / Settings dialogs — opened as an empty
black area. No error, no text, nothing. Reported as *"codegit still no go just a black box"*, twice,
and it survived the CSS fix in §4, because it was never a CSS problem.
**This is the bug that actually mattered.** §4 and §5 were real defects, but neither was the cause.
### The chain
1. **The installer stores a RELATIVE path.** `components/install/process.php` keeps the folder name
as given, so the project store held `{"Blog": "blog"}` — not an absolute path.
2. **`loadState()` hands it to the UI unmodified and persists it** (`common.php`):
```php
$projectPath = reset($projectList); // "blog"
...
SESSION("projectPath", $projectPath); // stored -> the bad value STICKS across logins
```
3. **The file tree puts it straight into the DOM** (`components/filetree/init.js`):
```js
let path = atheos.current.projectPath;
`<a id="project-root" data-type="root" data-path="${path}">` // data-path="blog"
```
4. **CodeGit then sends `repo="blog"`**, and `getWorkspacePath("blog")` fails its access check:
```
traits/path.php:67 Common::send(403, "Client does not have access.");
```
5. **`modal.js` silently discards the failure** — this is what makes it look like a "black box"
rather than an error:
```js
settled: function(reply, status) {
if (status !== 200) return; // no message, no toast, no console error
content.html(reply);
```
So the request 403s, nothing is rendered, and nothing is reported. The dialog is left with an empty
`<section id="content">`.
### How it was pinned down
Not by reading CSS. By capturing the real request/response from a scripted browser:
```
POST /dialog.php {"target":"codegit","action":"codegit","repo":"blog"}
-> 403 (and #content innerHTML length: 0)
```
A relative path was reproduced directly:
```
is_dir("blog") from the app root -> NO
getWorkspacePath("blog") -> 403 Client does not have access.
projects store -> {"Blog": "blog"}
```
### Fix (`scripts/fix-project-path.sh`)
A relative value in the store is **legitimate** — Atheos's own project-creation code resolves
relative → `WORKSPACE/<name>`. `loadState()` simply never does. Two changes, both required:
**A. Resolve at load time, and re-store it** — this heals sessions already holding the bad value:
```php
if (!Common::isAbsPath($projectPath)) {
$projectPath = Common::getWorkspacePath($projectPath, true);
SESSION("projectPath", $projectPath);
}
```
**B. Make the store absolute** (`{"Blog": "/var/www/atheos/workspace/blog"}`) so that
`array_search($projectPath, $projectList)`, which `loadState()` uses to derive the project *name*
for the tree label, keeps working. Fixing only (A) would silently break the label — the path would
resolve but `array_search` would return `false`, so the tree would show an empty name.
**Verified** by starting from a deliberately poisoned session (`projectPath="blog"`) and confirming
it self-heals:
```
projectPath : /var/www/atheos/workspace/blog <- healed from "blog"
data-path : /var/www/atheos/workspace/blog
dialog.php : 200 with content
#content : 1758 chars
visible : CodeGit Overview Log Transfer Configure Commit Amend ...
```
**An existing browser session heals on its next page load** — no hard refresh or re-login needed.
### Worth remembering
A 403 from `getWorkspacePath()` presents as *"the dialog is empty"*, with no error anywhere. If any
Atheos dialog is ever blank again, check the **project path** first, then the response code — not the CSS.
---
## 7. Checking for plugin problems in future
The plugin's JS is bundled into `public/plugins.min.js` (built by `classes/sourcemanager.php`), and The plugin's JS is bundled into `public/plugins.min.js` (built by `classes/sourcemanager.php`), and
its rightbar button is rendered by `components/sidebars/sb-right.php` from `plugin.json`. Both were its rightbar button is rendered by `components/sidebars/sb-right.php` from `plugin.json`. Both were
@@ -258,17 +467,26 @@ verify them before trusting it.
--- ---
## 5. Follow-ups not done (recorded so they are not forgotten) ## 8. Follow-ups not done (recorded so they are not forgotten)
These came out of review. They are **operator decisions**, not agent ones — several change what the These came out of review. They are **operator decisions**, not agent ones — several change what the
editor can do. editor can do.
### 5.1 Remove the Terminal plugin — ✅ DONE ### 8.0 Branch protection on `master` — ACCEPTED RISK
Deliberately **not** enabled while the editor is LAN-only (operator decision). Full reasoning and the
"enable it before you expose this host" trigger are in
[`docs/writer-branch-workflow.md` §2](writer-branch-workflow.md#2-protect-master-on-the-blog-repo--recommendation-deliberately-not-enabled).
The suite still probes for it on every run and reports `ACCEPTED-RISK` (not a failure) while it is
off, so the recommendation self-cancels the moment it is enabled.
### 8.1 Remove the Terminal plugin — ✅ DONE
Removed through the market's own code path once §1 was fixed (see §2). A browser shell was the largest Removed through the market's own code path once §1 was fixed (see §2). A browser shell was the largest
attack surface in the container and macros already cover command execution. attack surface in the container and macros already cover command execution.
### 5.2 ❌ `disable_functions` cannot be "on by default" here — measured ### 8.2 ❌ `disable_functions` cannot be "on by default" here — measured
Review asked for `php.ini` to disable `exec`, `shell_exec`, `passthru`, `system` by default. Review asked for `php.ini` to disable `exec`, `shell_exec`, `passthru`, `system` by default.
**This would break the editor's core function.** Evidence: **This would break the editor's core function.** Evidence:
@@ -285,18 +503,18 @@ turns the editor into a text editor that cannot commit. So it stays **a delibera
you would also lose macros and git integration, which is why S-7 already says *"test, do not you would also lose macros and git integration, which is why S-7 already says *"test, do not
blind-apply"*. blind-apply"*.
### 5.3 Rotate the terminal password automatically ### 8.3 Rotate the terminal password automatically
If the plugin is kept, `provision.sh` should generate a random password rather than leaving the If the plugin is kept, `provision.sh` should generate a random password rather than leaving the
upstream default. Not implemented: it would hardcode a secret into a repo file, which is the same upstream default. Not implemented: it would hardcode a secret into a repo file, which is the same
class of problem this file already documents. class of problem this file already documents.
### 5.4 Deploy key could be read-only ### 8.4 Deploy key could be read-only
Review suggested making the deploy key read-only if writes are unnecessary. **They are necessary** — Review suggested making the deploy key read-only if writes are unnecessary. **They are necessary** —
that is the whole point of the writer-branch workflow (macro 2 pushes). Not applicable. that is the whole point of the writer-branch workflow (macro 2 pushes). Not applicable.
### 5.5 The real upstream fix for §3 ### 8.5 The real upstream fix for §3
`class.macro.php::save()` should write `[]` instead of `"N/A"` for folder macros. See §3 — the fix is `class.macro.php::save()` should write `[]` instead of `"N/A"` for folder macros. See §4 — the fix is
currently applied to the data, so it survives updates, at the cost of being reversible from the UI. currently applied to the data, so it survives updates, at the cost of being reversible from the UI.
+32 -5
View File
@@ -17,7 +17,7 @@ merging into `master` is done by the operator in Gitea.
│ operator opens a PR in Gitea │ operator opens a PR in Gitea
│ operator reviews │ operator reviews
▼ ▼
master (protected) master (protected: RECOMMENDED, currently OFF - see §2)
│ │
▼ ▼
existing Pelican build pipeline existing Pelican build pipeline
@@ -43,12 +43,31 @@ This is the *same* global gitconfig php-fpm uses (`HOME=/home/atheos` — verifi
as **you**. If it is not set, macro **2** refuses with an actionable error rather than as **you**. If it is not set, macro **2** refuses with an actionable error rather than
committing under a bogus identity. committing under a bogus identity.
### 2. ⚠️ Protect `master` on the blog repo ### 2. Protect `master` on the blog repo — RECOMMENDATION, deliberately not enabled
**Not yet enabled — the deploy key can currently force-push `master` directly**, which defeats the **Status: accepted risk. Branch protection is OFF, and that is a decision, not an oversight.**
whole model. Set it in Gitea (repo owner only):
`armistace/blog` → **Settings** → **Branches** → add rule for `master`: > *"leave it as a recommendation — I won't do that unless I take this out to the wider internet"*
> — operator
The deploy key **can** push `master` directly, which means the writer-branch model is **advisory, not
enforced**. The operator has weighed this and chosen not to add the gate while the editor is LAN-only:
- **The compensating control is reachability, not immutability.** The editor is LAN-only (FR-10). The
threat this setting defends against is a compromised editor publishing straight to `master`; while
nothing off-LAN can reach the editor, and the only user on the LAN is the operator, that is largely
theoretical.
- **The cost is real now, the benefit is not.** Protection adds a merge gate for the sole user, who is
also the repo owner — friction with no realistic risk reduction today.
#### ⚠️ Enable it BEFORE either of these happens
1. **The editor becomes reachable off-LAN** — an NPM proxy entry, a port-forward, or a
Tailscale/WireGuard route to `blog-ide.aridgwayweb.com`.
2. **Anyone other than the operator gets access to the editor.**
At that point a deploy key that can force-push `master` stops being theoretical. The setting
(`armistace/blog` → **Settings** → **Branches** → rule for `master`):
| Setting | Value | | Setting | Value |
|---|---| |---|---|
@@ -63,6 +82,14 @@ curl -s -H "Authorization: token $GITEA_TOKEN" \
https://git.aridgwayweb.com/api/v1/repos/armistace/blog/branch_protections https://git.aridgwayweb.com/api/v1/repos/armistace/blog/branch_protections
``` ```
#### It is still checked on every test run
`build-and-test.sh` attempts a direct push to `master` with the deploy key and requires it to be
**rejected**. While protection is off that reports as `ACCEPTED-RISK`, not `FAIL` — so the suite stays
green and CI-usable — but **the check runs every time**. It flips to
`PASS master is protected` by itself the moment protection is enabled, and this recommendation
becomes moot without anyone having to remember it.
### 3. The macros ### 3. The macros
Installed directly into Atheos's macro store (`data/macros.db.php`); a copy is kept in this repo at Installed directly into Atheos's macro store (`data/macros.db.php`); a copy is kept in this repo at
+18 -9
View File
@@ -7,13 +7,12 @@ server {
root /var/www/atheos; root /var/www/atheos;
index index.php index.html; index index.php index.html;
# --- logging ---------------------------------------------------------
access_log /var/log/nginx/atheos.access.log; access_log /var/log/nginx/atheos.access.log;
error_log /var/log/nginx/atheos.error.log; error_log /var/log/nginx/atheos.error.log;
# --- PHP ----------------------------------------------------------------- # --- PHP: the socket path here MUST byte-match the pool's `listen =`
# The socket path here MUST byte-match the pool's `listen =` in # in /etc/php83/php-fpm.d/www.conf (interface I-1 -> else bare 502)
# /etc/php83/php-fpm.d/www.conf
# (interface I-1 — a mismatch yields a bare 502 with no other diagnostic).
location ~ \.php$ { location ~ \.php$ {
try_files $uri =404; try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$; fastcgi_split_path_info ^(.+\.php)(/.+)$;
@@ -27,11 +26,12 @@ server {
try_files $uri $uri/ =404; try_files $uri $uri/ =404;
} }
# --- hardening ----------------------------------------------------------- # --- hardening (free, no downside) -----------------------------------
location ~ /\.git { deny all; } # never serve the VCS directory, the app's private data, or dotfiles
location ~ /\.ssh { deny all; } location ~ /\.git { deny all; }
location ~ /\. { deny all; } location ~ /\.ssh { deny all; }
location ~ ^/data/ { deny all; } location ~ /\. { deny all; }
location ~ ^/data/ { deny all; }
location ~ ^/(config\.php|common\.php|controller\.php|traits/) { deny all; } location ~ ^/(config\.php|common\.php|controller\.php|traits/) { deny all; }
location ~* \.(md|py|sh|log|conf|ini|sql|bak|orig)$ { deny all; } location ~* \.(md|py|sh|log|conf|ini|sql|bak|orig)$ { deny all; }
@@ -40,4 +40,13 @@ server {
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
client_max_body_size 64m; client_max_body_size 64m;
# Atheos serves theme/main.min.css and the generated *.min.js/css bundles from STABLE URLs
# with no cache headers. Without this, a CSS/JS change stays invisible behind a cached copy
# until the user hard-refreshes. "no-cache" permits caching but forces revalidation, so a
# 304 keeps normal loads fast while guaranteeing updates are picked up on the next load.
location ~* \.(css|js)$ {
add_header Cache-Control "no-cache, must-revalidate";
try_files $uri =404;
}
} }
+69 -6
View File
@@ -5,9 +5,10 @@
# ============================================================================== # ==============================================================================
W=/var/www/atheos W=/var/www/atheos
B=$W/workspace/blog B=$W/workspace/blog
pass=0; fail=0 pass=0; fail=0; note=0
ok() { echo " PASS $1"; pass=$((pass+1)); } ok() { echo " PASS $1"; pass=$((pass+1)); }
bad() { echo " FAIL $1"; fail=$((fail+1)); } bad() { echo " FAIL $1"; fail=$((fail+1)); }
note() { echo " ACCEPTED $1"; note=$((note+1)); }
chk() { if [ "$2" = "$3" ]; then ok "$1 ($2)"; else bad "$1 (got '$2', want '$3')"; fi; } chk() { if [ "$2" = "$3" ]; then ok "$1 ($2)"; else bad "$1 (got '$2', want '$3')"; fi; }
asuser() { su -s /bin/sh atheos -c "$1"; } asuser() { su -s /bin/sh atheos -c "$1"; }
@@ -108,9 +109,43 @@ done
echo echo
echo "==============================================================" echo "=============================================================="
echo "REGRESSION: panel has no hardcoded height (empty-modal bug)"
echo "=============================================================="
# panel { height:500px } forced every modal panel to a fixed box: short content left a large
# empty black region and anything after the panel (codegit's Branch toolbar) was pushed outside
# the modal. docs/known-issues.md §4. Fails loudly if lost to an update.
PNL=$(grep -oE '(^|[;}])panel\{[^}]*\}' /var/www/atheos/theme/main.min.css 2>/dev/null | head -1)
if [ -z "$PNL" ]; then
bad "could not read the panel rule from theme/main.min.css"
elif echo "$PNL" | grep -q 'height:500px'; then
bad "panel is fixed at 500px - re-run scripts/fix-panel-layout.sh"
else
ok "panel is content-sized ($(echo "$PNL" | grep -oE 'height:[^;]*' | head -1))"
fi
# and the CSS/JS cache header must be present, or fixes stay invisible
if wget -qO /dev/null --server-response --header='Host: 127.0.0.1' http://127.0.0.1/theme/main.min.css 2>&1 | grep -qi 'Cache-Control.*no-cache'; then
ok "theme CSS sends Cache-Control: no-cache (changes are picked up)"
else
bad "theme CSS has no Cache-Control - a CSS fix can stay hidden behind the browser cache"
fi
echo
echo "=============================================================="
echo "REGRESSION: asset URLs are versioned (cache-busting)"
echo "=============================================================="
# Stable asset URLs with no version are why a CSS/JS fix stayed invisible behind the browser cache,
# in every browser incl. Firefox. docs/known-issues.md §5.
ASSETS=$(wget -qO /tmp/bt_page.html --header='Host: 127.0.0.1' http://127.0.0.1/ 2>/dev/null; grep -oE '(href|src)="[^"]+\.(css|js)"' /tmp/bt_page.html 2>/dev/null | wc -l | tr -d ' ')
VER=$(grep -oE '(href|src)="[^"]+\.(css|js)\?v=[0-9]+"' /tmp/bt_page.html 2>/dev/null | wc -l | tr -d ' ')
if [ "${VER:-0}" -ge 4 ]; then
ok "asset URLs are versioned ($VER asset tags carry ?v=)"
elif [ "${ASSETS:-0}" -eq 0 ]; then
note "could not read the served page from the guest (unauthenticated: expected)"
else
bad "only $VER of $ASSETS asset URLs are versioned - a CSS fix can stay hidden in the browser cache"
fi
echo "=============================================================="
echo "REGRESSION: rDelete works (GLOB_BRACE is glibc-only; musl lacks it)" echo "REGRESSION: rDelete works (GLOB_BRACE is glibc-only; musl lacks it)"
echo "==============================================================" echo "=============================================================="
# traits/helpers.php::rDelete() used GLOB_BRACE, which is undefined under musl -> PHP 8 fatal.
# That broke deleting ANY file/folder in the editor AND removing a market plugin (the UI # That broke deleting ANY file/folder in the editor AND removing a market plugin (the UI
# would spin forever because the fatal killed the response before Common::send()). # would spin forever because the fatal killed the response before Common::send()).
# docs/known-issues.md §1. This test fails loudly if the patch is lost (e.g. by an update). # docs/known-issues.md §1. This test fails loudly if the patch is lost (e.g. by an update).
@@ -142,14 +177,42 @@ else
if echo "$PUSH_OUT" | grep -qiE 'protected|not allowed|denied|forbidden|remote rejected'; then if echo "$PUSH_OUT" | grep -qiE 'protected|not allowed|denied|forbidden|remote rejected'; then
ok "master is protected - the deploy key CANNOT push it" ok "master is protected - the deploy key CANNOT push it"
else else
bad "master is NOT protected - the deploy key could push master directly!" # ACCEPTED RISK, not a failure. The operator has decided NOT to enable branch protection
echo " Enable it: armistace/blog -> Settings -> Branches -> add rule for 'master'," # while this editor is LAN-only; the control is "it is not reachable off-LAN", not "master
echo " Enable push = OFF, merge whitelist = armistace. See docs/writer-branch-workflow.md" # is immutable". So this is reported, not failed - but it is checked on EVERY run, so the
# moment protection does go on, the run says so and the recommendation below becomes moot.
note "master is NOT protected - the deploy key CAN push master directly"
echo " ACCEPTED because the editor is LAN-only (see docs/writer-branch-workflow.md)."
echo " BECOMES A REAL FINDING THE MOMENT THIS HOST IS REACHABLE OFF-LAN. Before that:"
echo " armistace/blog -> Settings -> Branches -> add rule for 'master'"
echo " Enable push = OFF, merge whitelist = armistace"
fi fi
fi fi
echo echo
echo "==============================================================" echo "=============================================================="
echo "REGRESSION: project path is ABSOLUTE (relative => empty dialogs)"
echo "=============================================================="
# A relative project path makes every getWorkspacePath() call fail its access check with 403,
# which modal.js silently swallows - so CodeGit/Macro/Market/Settings render EMPTY with no error.
# docs/known-issues.md S6. This is the bug that presented as "just a black box".
PP=$(php83 -r '
$raw=@file_get_contents("/var/www/atheos/data/projects.db.json.php");
if(!$raw) exit;
preg_match("/<\?php\/\*\|(.*)\|\*\/\?>/s",$raw,$m);
$d=json_decode($m[1],true);
if(is_array($d)) echo reset($d);' 2>/dev/null)
case "$PP" in
/*) ok "projects store holds an absolute path ($PP)" ;;
"") bad "could not read the projects store" ;;
*) bad "projects store holds a RELATIVE path ('$PP') - dialogs will render empty; re-run scripts/fix-project-path.sh" ;;
esac
if grep -q 'isAbsPath($projectPath)' /var/www/atheos/common.php 2>/dev/null; then
ok "loadState resolves a relative projectPath (heals poisoned sessions)"
else
bad "loadState does NOT resolve relative project paths - re-run scripts/fix-project-path.sh"
fi
echo "=============================================================="
echo "IDENTITY: git identity is SET by the operator (FR-11 superseded by explicit request)" echo "IDENTITY: git identity is SET by the operator (FR-11 superseded by explicit request)"
echo "==============================================================" echo "=============================================================="
# FR-11 originally required the agent to leave the identity EMPTY so it could not impersonate # FR-11 originally required the agent to leave the identity EMPTY so it could not impersonate
@@ -191,6 +254,6 @@ echo " listening ports: $(netstat -tln 2>/dev/null | awk '/LISTEN/{split($4,a,"
echo echo
echo "==============================================================" echo "=============================================================="
printf 'RESULT: %d passed, %d failed\n' "$pass" "$fail" printf 'RESULT: %d passed, %d failed, %d accepted-risk\n' "$pass" "$fail" "$note"
echo "==============================================================" echo "=============================================================="
[ "$fail" -eq 0 ] || exit 1 [ "$fail" -eq 0 ] || exit 1
+111
View File
@@ -0,0 +1,111 @@
#!/bin/sh
# Re-apply the UI/layout fixes to a rebuilt or updated Atheos tree.
#
# Why this exists: Atheos has no releases and `scripts/update-atheos.sh` replaces the whole
# application directory, so any local patch is lost on update. These are the two that were
# needed to make the editor usable. Both are IDEMPOTENT - run it as often as you like.
#
# 1. panel { height: 500px } -> height:auto; max-height:58vh
# Every modal panel was forced to a fixed 500px box, so dialogs with short content showed
# a large empty black region and anything after the panel (codegit's "Branch:" toolbar)
# was pushed OUTSIDE the modal. See docs/known-issues.md §4.
#
# 2. nginx: Cache-Control: no-cache for *.css/*.js
# Theme/bundle changes were invisible behind the browser cache. §5.
#
# Usage: sh scripts/fix-panel-layout.sh (run ON the guest, as root)
set -e
ATH=/var/www/atheos
CSS=$ATH/theme/main.min.css
SCSS=$ATH/theme/modules/common/_panel.scss
NGX=/etc/nginx/http.d/atheos.conf
echo "=== 1. panel layout ==="
OLD='height:500px;position:relative;border-left:var(--borderLight)'
NEW='height:auto;max-height:58vh;position:relative;border-left:var(--borderLight)'
if [ ! -f "$CSS" ]; then
echo " !! $CSS not found - is Atheos installed?"
elif grep -qF "$NEW" "$CSS"; then
echo " already applied"
elif grep -qF "$OLD" "$CSS"; then
[ -f "$CSS.orig" ] || cp -a "$CSS" "$CSS.orig"
php83 -r '$f=$argv[1];$o=$argv[2];$n=$argv[3];$s=file_get_contents($f);
if(strpos($s,$o)===false){exit(1);} file_put_contents($f,str_replace($o,$n,$s));' "$CSS" "$OLD" "$NEW"
echo " applied to main.min.css"
else
echo " !! expected panel rule not found - inspect manually"
echo " (upstream may have changed it; diff against $CSS.orig)"
fi
# keep the SCSS source consistent so the shipped CSS is not contradicted by the source
if [ -f "$SCSS" ] && grep -q 'height: 500px;' "$SCSS"; then
[ -f "$SCSS.orig" ] || cp -a "$SCSS" "$SCSS.orig"
php83 -r '$f=$argv[1];$s=file_get_contents($f);
$s=str_replace("height: 500px;","height: auto;\n\tmax-height: 58vh;",$s);
file_put_contents($f,$s);' "$SCSS"
echo " synchronised _panel.scss"
fi
echo "=== 2. nginx cache headers ==="
if [ ! -f "$NGX" ]; then
echo " !! $NGX not found"
elif grep -q 'must-revalidate' "$NGX"; then
echo " already applied"
else
[ -f "$NGX.orig" ] || cp -a "$NGX" "$NGX.orig"
cat > /tmp/.cacheblock <<'EOF'
# Atheos serves theme/main.min.css and the generated *.min.js/css bundles from STABLE URLs
# with no cache headers, so a CSS/JS fix stays invisible behind the browser cache until a
# hard refresh. "no-cache" permits caching but forces revalidation: normal loads still get a
# cheap 304, and a change is guaranteed to be picked up on the next load.
location ~* \.(css|js)$ {
add_header Cache-Control "no-cache, must-revalidate";
try_files $uri =404;
}
EOF
php83 -r '$f=$argv[1];$b=$argv[2];$s=file_get_contents($f);
$p=strrpos($s,"}"); if($p===false){exit(1);}
file_put_contents($f,substr($s,0,$p).file_get_contents($b).substr($s,$p));' "$NGX" /tmp/.cacheblock
rm -f /tmp/.cacheblock
nginx -t >/dev/null 2>&1 && rc-service nginx reload >/dev/null 2>&1 && echo " applied + nginx reloaded" \
|| echo " !! nginx -t failed - restore $NGX.orig"
fi
echo "=== 3. cache-busting versioned asset URLs ==="
# Atheos serves theme/main.min.css and the generated bundles from STABLE paths with no version:
# <link rel="stylesheet" href="theme/main.min.css">
# A browser then keeps its cached copy across a change, so a CSS/JS fix appears not to work.
# Appending each file's mtime means a changed file gets a NEW url (fetched immediately) and an
# unchanged one keeps its url (stays cached). Better than headers alone: it also defeats a
# browser cache that was already poisoned before any header was set.
SM=$ATH/classes/sourcemanager.php
IX=$ATH/index.php
if grep -q 'filemtime($path)' "$SM" 2>/dev/null; then
echo " getTag already versioned"
else
[ -f "$SM.orig" ] || cp -a "$SM" "$SM.orig"
php83 -r '
$f=$argv[1]; $s=file_get_contents($f);
$old = " function getTag(\$type, \$path) {\n return \$type === \"css\" ? \"\\t<link rel=\\\"stylesheet\\\" href=\\\"\$path\\\">\\n\": \"\\t<script type=\\\"text/javascript\\\" src=\\\"\$path\\\"></script>\\n\";\n }";
if (strpos($s,$old) === false) { fwrite(STDERR, " !! getTag signature changed - inspect $f\n"); exit(1); }
$new = " function getTag(\$type, \$path) {\n // Version the URL by mtime so a changed asset is fetched immediately instead of being\n // served from the browser cache (docs/known-issues.md §5).\n if (file_exists(\$path)) {\n \$path .= \"?v=\" . filemtime(\$path);\n }\n return \$type === \"css\" ? \"\\t<link rel=\\\"stylesheet\\\" href=\\\"\$path\\\">\\n\": \"\\t<script type=\\\"text/javascript\\\" src=\\\"\$path\\\"></script>\\n\";\n }";
file_put_contents($f, str_replace($old,$new,$s)); echo " versioned getTag\n";' "$SM"
php83 -l "$SM" >/dev/null 2>&1 || echo " !! SYNTAX ERROR in $SM - restore $SM.orig"
fi
if grep -q 'theme/main.min.css?v=' "$IX" 2>/dev/null; then
echo " index.php already versioned"
else
[ -f "$IX.orig" ] || cp -a "$IX" "$IX.orig"
php83 -r '
$f=$argv[1]; $s=file_get_contents($f);
$old = " echo(\"\\t<link rel=\\\"stylesheet\\\" href=\\\"theme/main.min.css\\\">\\n\");";
if (strpos($s,$old) === false) { fwrite(STDERR, " !! theme echo line changed - inspect $f\n"); exit(1); }
$new = " // Version the theme URL by mtime; otherwise a browser keeps its cached copy across a\n // theme change and the fix appears not to work (docs/known-issues.md §5).\n \$themeV = file_exists(\"theme/main.min.css\") ? filemtime(\"theme/main.min.css\") : time();\n echo(\"\\t<link rel=\\\"stylesheet\\\" href=\\\"theme/main.min.css?v=\$themeV\\\">\\n\");";
file_put_contents($f, str_replace($old,$new,$s)); echo " versioned index.php theme link\n";' "$IX"
php83 -l "$IX" >/dev/null 2>&1 || echo " !! SYNTAX ERROR in $IX - restore $IX.orig"
fi
echo "=== done ==="
+70
View File
@@ -0,0 +1,70 @@
#!/bin/sh
# Fix: dialogs (CodeGit, Macro, Market, Settings) rendered EMPTY.
#
# Root cause: the project was registered with a RELATIVE path, and the UI used it verbatim.
#
# 1. install/process.php stores a relative folder name: projects.db -> {"Blog": "blog"}
# 2. common.php::loadState() does $projectPath = reset($projectList) -> "blog"
# and persists it to the session, so the bad value STICKS.
# 3. filetree puts it straight into the DOM: <a id="project-root" data-path="blog">
# 4. codegit sends repo="blog"; getWorkspacePath("blog") fails checkPath() -> 403
# 5. modal.js does `if (status !== 200) return;` -> SWALLOWS the error -> #content stays empty
#
# That last step is why it presents as an empty/black dialog rather than an error: the request
# fails, nothing is rendered, and nothing is reported.
#
# Atheos's own project-creation code resolves relative -> WORKSPACE/<name>, so a relative value in
# the store is legitimate - loadState() simply fails to resolve it. Both fixes are needed:
# A. resolve at load time (this also heals sessions already holding the relative value)
# B. store absolute paths, so array_search() can still map path -> name for the tree label
#
# Usage: run ON the guest as root. Idempotent.
set -e
A=/var/www/atheos
echo "=== A. resolve a relative projectPath in loadState() ==="
S=$A/common.php
if [ ! -f "$S" ]; then echo " !! $S not found"; exit 1; fi
if grep -q 'isAbsPath($projectPath)' "$S"; then
echo " already applied"
else
[ -f "$S.orig" ] || cp -a "$S" "$S.orig"
php83 -r '
$f=$argv[1]; $s=file_get_contents($f);
$old = " \$openFiles_db = Common::getObjStore(\"activeFiles\", \"users/\" . \$activeUser);";
if (strpos($s,$old) === false) { fwrite(STDERR," !! anchor not found in loadState\n"); exit(1); }
$new = " // A project may be registered with a RELATIVE folder name (the installer stores it\n // that way). Handing that to the UI yields data-path=\"blog\", and every later\n // getWorkspacePath() call then fails its access check with 403 - which the modal\n // silently swallows, leaving the dialog empty. Resolve it once here, and re-store it\n // so sessions already holding the relative value heal on their next load.\n if (!Common::isAbsPath(\$projectPath)) {\n \$projectPath = Common::getWorkspacePath(\$projectPath, true);\n SESSION(\"projectPath\", \$projectPath);\n }\n\n" . $old;
file_put_contents($f, str_replace($old,$new,$s)); echo " patched loadState\n";' "$S"
php83 -l "$S" >/dev/null 2>&1 || { echo " !! SYNTAX ERROR - restore $S.orig"; exit 1; }
fi
echo "=== B. store absolute paths in the projects store ==="
P=$A/data/projects.db.json.php
if [ ! -f "$P" ]; then
echo " !! $P not found"
elif grep -q '"\/' "$P"; then
echo " already absolute"
else
cp -a "$P" "$P.bak-$(date +%Y%m%d-%H%M%S)"
php83 -r '
$f=$argv[1]; $ws="/var/www/atheos/workspace";
$raw=file_get_contents($f);
preg_match("/<\?php\/\*\|(.*)\|\*\/\?>/s",$raw,$m);
$d=json_decode($m[1],true);
if(!is_array($d)){ fwrite(STDERR," !! store unreadable\n"); exit(1); }
$c=false;
foreach($d as $n=>$p){ if(!preg_match("#^(/|[A-Za-z]:)#",$p)){ $d[$n]=$ws."/".ltrim($p,"/"); $c=true; } }
if(!$c){ echo " already absolute\n"; exit(0); }
file_put_contents($f,"<?php/*|\n".json_encode($d, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)."\n|*/?>");
echo " store rewritten -> ".json_encode($d)."\n";' "$P"
chown atheos:atheos "$P" 2>/dev/null || true
fi
echo "=== verify ==="
php83 -r '
$raw=file_get_contents("/var/www/atheos/data/projects.db.json.php");
preg_match("/<\?php\/\*\|(.*)\|\*\/\?>/s",$raw,$m); $d=json_decode($m[1],true);
$p=reset($d); $n=array_search($p,$d);
printf(" path=%s name=%s\n",$p,var_export($n,true));
printf(" absolute: %s\n", preg_match("#^(/|[A-Za-z]:)#",$p) ? "yes" : "NO");' 2>&1
echo "=== done ==="