From 7717efd19a0ef5886f647e91397b23d18445afa7 Mon Sep 17 00:00:00 2001 From: x01dc Date: Wed, 22 Jul 2026 15:49:33 +0200 Subject: [PATCH] feat(webpage): mirror status-page uploads to a second URL HttpUploader now accepts a single URL or a list, fanning every upload (dir/changed/single-file/cleanup) out to each configured server with an independent per-URL mtime cache, so a newly added mirror backfills on its own and a failing one doesn't block the others. Session-auth endpoints (session_query/set_password) still derive from the primary URL only. flomni.py/lamni.py now mirror to https://omny-test.psi.ch/upload.php alongside production, marked TEMP for easy removal once that server is evaluated. Adds OMNY_shared/AI_docs/WEBPAGE_UPLOAD_MECHANISM.md consolidating the upload mechanism (the prior doc predates this file and still called LamNI a stub). Co-Authored-By: Claude Sonnet 5 --- .../bec_ipython_client/plugins/LamNI/lamni.py | 7 +- .../AI_docs/WEBPAGE_UPLOAD_MECHANISM.md | 110 ++++++++++++++++++ .../plugins/OMNY_shared/web_common.py | 88 ++++++++------ .../OMNY_shared/webpage_generator_base.py | 19 ++- .../plugins/flomni/flomni.py | 7 +- 5 files changed, 189 insertions(+), 42 deletions(-) create mode 100644 csaxs_bec/bec_ipython_client/plugins/OMNY_shared/AI_docs/WEBPAGE_UPLOAD_MECHANISM.md diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index c1c7f18..bc7105a 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -233,7 +233,12 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools self._webpage_gen = LamniWebpageGenerator( bec_client=client, output_dir="~/data/raw/webpage/", - upload_url="https://v1p0zyg2w9n2k9c1.myfritz.net/upload.php", + upload_url=[ + "https://v1p0zyg2w9n2k9c1.myfritz.net/upload.php", + # TEMP: mirroring to the test server while it's evaluated -- + # drop this line once omny-test.psi.ch is confirmed or the trial ends. + "https://omny-test.psi.ch/upload.php", + ], local_port=8080, ) self._webpage_gen.start() diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/AI_docs/WEBPAGE_UPLOAD_MECHANISM.md b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/AI_docs/WEBPAGE_UPLOAD_MECHANISM.md new file mode 100644 index 0000000..268a6cc --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/AI_docs/WEBPAGE_UPLOAD_MECHANISM.md @@ -0,0 +1,110 @@ +# Webpage generator upload mechanism + +Consolidated reference for how the flOMNI/LamNI/OMNY status pages get uploaded to the +remote web server(s). Supersedes the upload-related parts of +`flomni/AI_docs/Webpage.md`, which predates `OMNY_shared/web_common.py` and still +describes LamNI as an unimplemented stub. + +## Live files vs. dead duplicate + +The live implementation is: + +- `OMNY_shared/web_common.py` -- `HttpUploader`, `LocalHttpServer`. +- `OMNY_shared/webpage_generator_base.py` -- `WebpageGeneratorBase`, `make_webpage_generator()`. +- Per-setup subclasses: `flomni/flomni_webpage_generator.py` (`FlomniWebpageGenerator`), + `LamNI/LamNI_webpage_generator.py` (`LamniWebpageGenerator`). These are thin subclasses + that forward constructor kwargs straight to `WebpageGeneratorBase.__init__` -- neither + overrides upload handling. +- `OMNY_shared/eps/eps_status_generator.py` -- `EpsStatusGenerator`, the separate + EPS/machine-status daemon. It has its own `HttpUploader` construction for standalone + use, but when launched from `WebpageGeneratorBase._launch()` it's always given + `upload_url=None` -- its files live in the same `output_dir` and ride along on the + parent generator's own `upload_changed_async()` scan every cycle. Giving it its own + uploader too would re-POST the same directory redundantly. + +**`omny/omny_webpage_generator.py` is a stale, unwired duplicate.** It defines its own +copies of `HttpUploader`/`LocalHttpServer`/`WebpageGeneratorBase`, is never imported by +anything, and its own docstring header still reads `"""flomni/webpage_generator.py"""` +(a leftover from an earlier version of the flomni file). `make_webpage_generator()` +explicitly falls back to the plain base class for the `"omny"` session rather than using +it. **Do not edit this file** -- a `grep -rn "HttpUploader"` across `plugins/` will +surface it as a false positive; always check the import path before editing. + +## `HttpUploader` contract (`web_common.py`) + +Uploads files from a local directory to one or more remote servers via HTTP POST, in a +background daemon thread so uploads never block the generator's cycle. + +- `_UPLOAD_SUFFIXES = {".html", ".json", ".jpg", ".png", ".pdf", ".txt", ".svg"}` -- + files with any other extension are silently skipped by the directory-scanning methods + (`upload_dir_async`/`upload_changed_async`). `upload_file_async` bypasses this whitelist + entirely (used only for `session.htpasswd`, which has no matching extension). +- **Fire-and-forget, drop-if-busy**: `_dispatch()` guards a single `_busy` flag under a + lock. If a previous upload from this `HttpUploader` is still running when the next + cycle fires, the new request is dropped (logged at DEBUG) -- there is no queueing or + retry. One dispatched thread does all configured URLs sequentially. +- **Per-URL mtime cache**: `self._uploaded` is `dict[url, dict[path_str, mtime]]`. A file + is only re-uploaded to a given URL once its mtime changes since the last *successful* + upload to that specific URL. This means each configured server tracks "changed" fully + independently -- a URL that's been failing doesn't block another URL from proceeding, + and a brand-new URL (empty cache) naturally gets backfilled the next time a full + (`force=True`) upload runs. +- **Rate-limited warning logging**: `_warn(key, msg)` logs at most once per 600s per key. + Keys fold in the URL (e.g. `f"{url}|upload_{filename}"`, `f"{url}|cleanup"`) so a + persistent failure against one server doesn't suppress the warning for another. +- **SSRF hardening**: every POST uses `allow_redirects=False` -- a compromised public + server must not be able to redirect the internal process to an arbitrary internal + address. `verify=False` accepts self-signed certs (the fallback Raspberry Pi target + historically used one). +- **Ptycho cleanup**: `cleanup_ptycho_images_async()` POSTs `{"action": "cleanup"}` to + each URL, asking the server to delete stale `S*_*.png`/`S*_*.jpg` files, then forgets + the local mtime records for any cached path containing `/S` or `\S` so those files get + re-uploaded on the next cycle. + +### Multi-URL mirroring + +`HttpUploader(url)` accepts either a single URL string or a list of URLs. When a list is +given, **every** method (`upload_dir_async`, `upload_changed_async`, `upload_file_async`, +`cleanup_ptycho_images_async`) mirrors uniformly to all of them, including +`session.htpasswd` pushes -- there is no special-casing of any file or action per URL. + +`WebpageGeneratorBase.__init__`'s `_session_query_url`/`_set_password_url` (used for +account-match checking and `set_web_password()`) are **not** part of `HttpUploader` and +are derived only from the *first/primary* URL, even when `upload_url` is a list -- they +govern access control for the production page and have no meaning for a secondary/test +mirror. + +As of 2026-07, both `flomni.py` and `lamni.py` pass a two-element list -- production +(`https://v1p0zyg2w9n2k9c1.myfritz.net/upload.php`) plus a temporary evaluation target +(`https://omny-test.psi.ch/upload.php`) -- marked with a `# TEMP` comment at the call +site. Drop the second entry once the trial ends. + +## Call-site map (`webpage_generator_base.py`) + +- `_launch()`: on startup (unless `local_only`), `self._uploader.upload_dir_async(...)` + uploads every eligible file once. Also prints `self._uploader._urls` (joined) in the + startup log line. +- `_cycle()` (every ~15s): if the ptycho scan id changed since the last cycle, triggers + `cleanup_ptycho_images_async()` then a delayed (2s) `upload_dir_async()` full re-upload; + otherwise just `upload_changed_async()`. Skipped entirely when `self._uploader is None` + or `self._local_only` is True. +- `_clear_session_htpasswd()`: writes an emptied `session.htpasswd` locally and pushes it + via `upload_file_async()` whenever the active BEC account changes. +- `status()`: prints `self._uploader._urls` (joined) as the "Upload URL" line. + +`local_only` (the account-mismatch fallback, entered automatically by `start()` unless +`force=True`) suppresses **all** uploads regardless of how many URLs are configured -- +it's a binary "upload at all vs. not" switch, orthogonal to how many mirrors exist. + +## Known issue: `.svg` upload failing on `omny-test.psi.ch` + +Both the live `HttpUploader._UPLOAD_SUFFIXES` and the in-repo reference +`OMNY_shared/server_side/webserver/upload.php` already allow `.svg`. Manual testing +against `omny-test.psi.ch` showed `.json`/`.html` status updates succeeding but `.svg` +(the EPS synoptic image, generated by `OMNY_shared/eps/eps_synoptic_svg.py`) failing. +Since nothing in this repo's client or reference server code excludes `.svg`, the most +likely cause is whatever `upload.php`/WAF is actually deployed on the test box differing +from the reference copy here (a stricter filename regex, or a WAF rule flagging SVG's +potential for embedded `