24 Commits
Author SHA1 Message Date
jared e988465906 Add MIT license
Lint / Shell (shellcheck) (push) Successful in 14s
Lint / JS (eslint) (push) Successful in 14s
Lint / No secrets in webhook configs (push) Successful in 11s
Lint / Landing page is rendered (matrix (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 30s
Lint / Secret scan (gitleaks) (push) Successful in 4s
2026-10-03 00:16:58 -04:00
jared effa27da3d Merge pull request 'cinny config: status banner from the Kuma page (cinny #124)' (#17) from config-status-pages into main
Lint / Shell (shellcheck) (push) Successful in 11s
Lint / JS (eslint) (push) Successful in 7s
Lint / No secrets in webhook configs (push) Successful in 4s
Lint / Landing page is rendered (matrix (push) Successful in 3s
Lint / Python (ruff) (push) Successful in 4s
Lint / Python deps (pip-audit) (push) Successful in 33s
Lint / Secret scan (gitleaks) (push) Successful in 7s
Merge pull request #17: cinny config status banner (cinny #124)
2026-09-29 13:24:51 -04:00
Lotus CIandClaude Opus 5.5 8d80ebf4c2 cinny config: status banner from the Kuma page (cinny #124)
Lint / Shell (shellcheck) (push) Successful in 15s
Lint / JS (eslint) (push) Successful in 11s
Lint / No secrets in webhook configs (push) Successful in 5s
Lint / Landing page is rendered (matrix (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 9s
Lint / Python deps (pip-audit) (push) Successful in 1m1s
Lint / Secret scan (gitleaks) (push) Successful in 8s
Lint / Shell (shellcheck) (pull_request) Successful in 14s
Lint / JS (eslint) (pull_request) Successful in 11s
Lint / No secrets in webhook configs (pull_request) Successful in 6s
Lint / Landing page is rendered (matrix (pull_request) Successful in 6s
Lint / Python (ruff) (pull_request) Successful in 6s
Lint / Python deps (pip-audit) (pull_request) Successful in 44s
Lint / Secret scan (gitleaks) (pull_request) Successful in 7s
Adds `statusPages` for matrix.lotusguild.org → the Kuma status page
https://isitup.lotusguild.org/status/matrix (groups Homeserver, Voice
calls, Login). The banner code (cinny #255) and the CSP (#16) are already
live; production's config.json comes from this file, so without this entry
the client never asks Kuma. The LXC 106 deploy merges it into the live file
and keeps server-only values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-29 12:35:55 -04:00
jared 0914339180 Merge pull request 'cinny CSP: allow the Kuma status page (cinny #124)' (#16) from csp-kuma-status into main
Lint / Shell (shellcheck) (push) Successful in 17s
Lint / JS (eslint) (push) Successful in 14s
Lint / No secrets in webhook configs (push) Successful in 8s
Lint / Landing page is rendered (matrix (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 7s
Lint / Python deps (pip-audit) (push) Successful in 43s
Lint / Secret scan (gitleaks) (push) Successful in 8s
Merge pull request #16: cinny CSP allows the Kuma status page (cinny #124)
2026-09-29 12:28:30 -04:00
Lotus CIandClaude Opus 5.5 9fcbd410f2 cinny CSP: allow the app to read the Kuma status page (cinny #124)
Lint / Shell (shellcheck) (push) Successful in 17s
Lint / JS (eslint) (push) Successful in 12s
Lint / No secrets in webhook configs (push) Successful in 7s
Lint / Landing page is rendered (matrix (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 7s
Lint / Python deps (pip-audit) (push) Successful in 45s
Lint / Secret scan (gitleaks) (push) Successful in 7s
Lint / Shell (shellcheck) (pull_request) Successful in 12s
Lint / JS (eslint) (pull_request) Successful in 11s
Lint / No secrets in webhook configs (pull_request) Successful in 7s
Lint / Landing page is rendered (matrix (pull_request) Successful in 10s
Lint / Python (ruff) (pull_request) Successful in 8s
Lint / Python deps (pip-audit) (pull_request) Successful in 57s
Lint / Secret scan (gitleaks) (pull_request) Successful in 8s
Add https://isitup.lotusguild.org to connect-src so the client can fetch
the public status JSON (GET /api/status-page/matrix and
/api/status-page/heartbeat/matrix) for the homeserver status banner.
Read-only public JSON, no credentials; nothing else changes.

The live snippet (/etc/nginx/snippets/cinny-security-headers.conf on
LXC 106) was identical to this file before the change; install it by hand
with a backup and `nginx -t` (the deploy script only handles
cinny/nginx.conf).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-29 12:08:45 -04:00
jared d76d276da4 Merge pull request #15: LXC 106 deploy: merge config.json, self-update (#13)
Lint / Shell (shellcheck) (push) Successful in 7s
Lint / JS (eslint) (push) Successful in 5s
Lint / No secrets in webhook configs (push) Successful in 3s
Lint / Landing page is rendered (matrix (push) Successful in 4s
Lint / Python (ruff) (push) Successful in 4s
Lint / Python deps (pip-audit) (push) Successful in 36s
Lint / Secret scan (gitleaks) (push) Successful in 4s
2026-09-28 18:52:30 -04:00
Lotus CIandClaude Opus 5.5 8d47df711d fix(lxc106): deploy script merges config.json and updates itself (#13)
Lint / Shell (shellcheck) (push) Successful in 31s
Lint / JS (eslint) (push) Successful in 5s
Lint / No secrets in webhook configs (push) Successful in 4s
Lint / Landing page is rendered (matrix (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 8s
Lint / Python deps (pip-audit) (push) Successful in 1m2s
Lint / Secret scan (gitleaks) (push) Successful in 10s
Lint / Shell (shellcheck) (pull_request) Successful in 12s
Lint / JS (eslint) (pull_request) Successful in 12s
Lint / No secrets in webhook configs (pull_request) Successful in 8s
Lint / Landing page is rendered (matrix (pull_request) Successful in 8s
Lint / Python (ruff) (pull_request) Successful in 8s
Lint / Python deps (pip-audit) (pull_request) Successful in 1m15s
Lint / Secret scan (gitleaks) (pull_request) Successful in 12s
LXC 106's matrix-deploy hook has not fired since May: Gitea posts to
10.10.10.6:9000, which webhook.service binds to 127.0.0.1. The same hooks
(same secret) are served by webhook-lotus on :9001; the Gitea hook is being
re-pointed there. Before it starts firing again:

- cinny/config.json is merged into the live file instead of copied over it.
  The live file carries gifApiKey (injected by lotus_deploy.sh), empty in
  git, which a copy would blank. Repo keys win except where the repo value is
  empty and the live one isn't. Backup goes to /root/config-backups; the live
  file is left untouched on error.
- The script installs its own updates (deploy/lxc106-cinny.sh →
  /usr/local/bin/matrix-deploy.sh, after bash -n). Until now repo edits to
  it never reached the server.
- A hooks.json change restarts webhook-lotus 15 s after the script exits,
  since that listener is the one running this script.
- README: port 9001, the file list, and the history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-28 18:47:49 -04:00
Lotus CIandClaude Opus 5.5 9baafd4928 cinny: load Element Call from call.chat.lotusguild.org (cinny #43)
Lint / Shell (shellcheck) (push) Successful in 50s
Lint / JS (eslint) (push) Successful in 10s
Lint / No secrets in webhook configs (push) Successful in 8s
Lint / Landing page is rendered (matrix (push) Successful in 11s
Lint / Python (ruff) (push) Successful in 10s
Lint / Python deps (pip-audit) (push) Successful in 48s
Lint / Secret scan (gitleaks) (push) Successful in 8s
Sets elementCallUrl so the web app frames the call page on its own origin
(desktop keeps its bundled copy). Applied on LXC 106 in place (the live file
also carries the injected gifApiKey; backup in /root/config-backups/), since
the matrix-deploy webhook there has not fired since May.

Rollback: remove this key (live: restore the backup) and new calls use the
bundled same-origin page again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-27 11:52:52 -04:00
jared 97d9416b01 Merge pull request #12: nginx for Element Call on call.chat.lotusguild.org (cinny #43)
Lint / Shell (shellcheck) (push) Successful in 22s
Lint / JS (eslint) (push) Successful in 15s
Lint / No secrets in webhook configs (push) Successful in 20s
Lint / Landing page is rendered (matrix (push) Successful in 13s
Lint / Python (ruff) (push) Successful in 11s
Lint / Python deps (pip-audit) (push) Successful in 1m13s
Lint / Secret scan (gitleaks) (push) Successful in 9s
2026-09-27 02:32:07 -04:00
Lotus CIandClaude Opus 5.5 9c5a183025 feat(cinny): nginx for Element Call on call.chat.lotusguild.org (cinny #43)
Lint / Shell (shellcheck) (push) Successful in 21s
Lint / JS (eslint) (push) Successful in 14s
Lint / No secrets in webhook configs (push) Successful in 6s
Lint / Landing page is rendered (matrix (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 7s
Lint / Python deps (pip-audit) (push) Successful in 52s
Lint / Secret scan (gitleaks) (push) Successful in 8s
Lint / Shell (shellcheck) (pull_request) Successful in 17s
Lint / JS (eslint) (pull_request) Successful in 13s
Lint / No secrets in webhook configs (pull_request) Successful in 6s
Lint / Landing page is rendered (matrix (pull_request) Successful in 6s
Lint / Python (ruff) (pull_request) Successful in 8s
Lint / Python deps (pip-audit) (pull_request) Successful in 53s
Lint / Secret scan (gitleaks) (pull_request) Successful in 8s
- cinny/nginx.conf: a call.chat.lotusguild.org server block that serves ONLY
  /public/element-call/ (the same files chat.lotusguild.org already serves
  there) and 404s everything else, including source maps and dotfiles.
- cinny/nginx-security-headers-call.conf (new snippet): frame-ancestors
  https://chat.lotusguild.org instead of X-Frame-Options SAMEORIGIN, which
  would block the now cross-origin parent.
- cinny/nginx-security-headers.conf: the app's Permissions-Policy delegates
  autoplay/camera/display-capture/microphone to the call origin (without it
  the cross-origin frame's getUserMedia is refused). Outer quotes switched to
  single: the inner "origin" quotes broke nginx parsing.

Nothing changes for users until config.json sets elementCallUrl (separate
step). Snippets are installed by hand on LXC 106; nginx.conf deploys on merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-26 23:24:32 -04:00
Lotus CIandClaude Opus 5.5 1dccca914c landing: re-verify every client claim (reviewed 2026-09-26)
Lint / Shell (shellcheck) (push) Successful in 21s
Lint / JS (eslint) (push) Successful in 11s
Lint / No secrets in webhook configs (push) Successful in 9s
Lint / Landing page is rendered (matrix (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 7s
Lint / Python deps (pip-audit) (push) Successful in 47s
Lint / Secret scan (gitleaks) (push) Successful in 7s
Each client's card and all 64 table cells were checked against its latest
release (source at the release tag, changelogs, official docs):
Cinny v4.12.7, Element Web v1.12.29, Element X 26.09.2, FluffyChat
v2.9.5, Commet v0.5.0, Nheko v0.12.1. ~95 cells corrected, notes
tightened, chips made consistent (platforms / SDK / one calling chip).

Notable:
- Cinny: calls, voice rooms and screenshare via Element Call are stable
  (card gained the calling chip); ring, who-reacted, read receipts: yes;
  polls: no.
- Element X: cannot START a screenshare (mobile web views have no
  getDisplayMedia) — chip is now "Voice & Video".
- FluffyChat: threads, pinning, media gallery, custom status, QR invites:
  yes; calls still experimental (DM-only, legacy 1:1).
- Commet: web app + Windows/Linux, no macOS; screenshare, widgets
  (v0.5.0), favourites: yes; "Beta" chip dropped (not self-described).
- Nheko: calls are 1:1 only and only in Linux builds; threads: yes; no
  release since Aug 2025.
- Element: no bookmarks or scheduling; custom status is Labs; QR invites
  and sort order: yes.
- Lotus Chat: Vodozemac yes (Rust crypto), widgets yes, push = only while
  open (no background push yet); desktop app is Windows + Linux (the
  feature list said macOS).
- Vodozemac is yes for every js-sdk client now (Rust crypto).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-26 16:53:07 -04:00
Lotus CIandClaude Opus 5.5 f0017f4d5a landing: structured feature groups, single-source comparison table (#11)
Lint / Shell (shellcheck) (push) Successful in 17s
Lint / JS (eslint) (push) Successful in 11s
Lint / No secrets in webhook configs (push) Successful in 10s
Lint / Landing page is rendered (matrix (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 11s
Lint / Python deps (pip-audit) (push) Successful in 54s
Lint / Secret scan (gitleaks) (push) Successful in 13s
- The ~1,800-word fork paragraph becomes 7 titled groups, one short line
  per feature, plus a link to LOTUS_FEATURES.md.
- Single source: landing/data/features.json + comparison.json, rendered
  into index.html by landing/build.py between BEGIN/END markers (all 71
  table rows re-render with identical cell text).
- CSS moved to landing/style.css; build.py inlines it because the LXC 139
  deploy copies index.html only.
- "Client comparison last reviewed" stamp driven by comparison.json.
- CI job `landing-fresh` fails if index.html drifts from its sources.
- Feature list brought up to date: 629 avatar decorations in 28
  categories, mic level meter + remembered per-person volume, search
  operators, paste-as-code, thread "Mark all read", pinch-zoom/swipe media
  viewer, room widgets with a permission prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-26 15:11:50 -04:00
Lotus CIandClaude Opus 5.5 e28256ab3a docs: note the Element Call headers exception
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 7s
Lint / No secrets in webhook configs (push) Successful in 4s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 38s
Lint / Secret scan (gitleaks) (push) Successful in 6s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 19:26:52 -04:00
Lotus CIandClaude Opus 5.5 d4fd1d0b8e fix(cinny/nginx): let the app frame its bundled Element Call again
Lint / Shell (shellcheck) (push) Successful in 20s
Lint / JS (eslint) (push) Successful in 13s
Lint / No secrets in webhook configs (push) Successful in 4s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 44s
Lint / Secret scan (gitleaks) (push) Successful in 8s
Web calls broke ("nobody can join calls from the web version"): 23ad133
(cinny #210) put the security-headers snippet, including the app CSP, on
every .html response. /public/element-call/index.html then carried
frame-ancestors 'none', so the browser refused to load it inside the app
("Content-Security-Policy ... frame-ancestors 'none'"). Desktop was fine (it
loads Element Call from its own bundle). Before #210 that page had no CSP.

A `location ^~ /public/element-call/` now serves it with
cinny-security-headers-framed.conf: the same headers minus the CSP
(X-Frame-Options SAMEORIGIN still limits framing to chat.lotusguild.org),
and the same caching (HTML no-cache, hashed assets 1 year). Applied live on
LXC 106 (backup sites-available/cinny.bak-ecframe-*), nginx -t ok; verified
from outside: no CSP on the call page, and the live site frames it and it
loads ("Element Call").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 19:26:44 -04:00
Lotus CIandClaude Opus 5.5 23ad133dc3 cinny(nginx): security headers on every location; sync repo with live (cinny #210, #214, #155)
Lint / Shell (shellcheck) (push) Successful in 13s
Lint / JS (eslint) (push) Successful in 12s
Lint / No secrets in webhook configs (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 9s
Lint / Python deps (pip-audit) (push) Successful in 1m9s
Lint / Secret scan (gitleaks) (push) Successful in 10s
Applied on LXC 106 (backed up, `nginx -t`, reloaded) and recorded here:

- Security headers (incl. CSP) moved to snippets/cinny-security-headers.conf
  and included at server level plus in the /sw.js, static-asset and
  json/html locations. nginx drops inherited add_header in any block that
  sets its own, so static assets were served without nosniff and /sw.js
  without a CSP (a service worker takes its CSP from its own script).
  Now every path carries all seven headers. Verified: the SW installs and
  controls the page under the CSP with no violations.
- #214: CSP no longer allows fonts.googleapis.com / fonts.gstatic.com
  (VT323 is self-hosted since cinny 6f250353).
- #155: the /share-target → /share 303 is now live (it was only in the
  repo; the 106 matrix-deploy hook has been dead since May, so repo edits
  never reached it). absolute_redirect off makes it relative.
- README: the snippet, and that 106 needs these applied by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 23:17:53 -04:00
Lotus CIandClaude Opus 5.5 1dacf29d53 security(deploy): webhook secrets out of git; all exposed ones rotated
Lint / Shell (shellcheck) (push) Successful in 14s
Lint / JS (eslint) (push) Successful in 11s
Lint / No secrets in webhook configs (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 13s
Lint / Python deps (pip-audit) (push) Successful in 1m46s
Lint / Secret scan (gitleaks) (push) Successful in 11s
deploy/hooks-lxc*.json in this public repo held the HMAC secrets for
every auto-deploy webhook (and 106's cinny-build token), readable
anonymously. Six values had been committed: the four matrix-deploy
secrets, 106's lotus-deploy secret (publicly reachable through
chat.lotusguild.org/hooks/lotus-deploy) and the cinny-build token.

- All six rotated on the servers and in Gitea (matrix hooks 6-9,
  cinny hook 69). The old values are rejected.
- Secrets now live in /etc/webhook/secrets.env (0600) on each LXC, loaded
  by a systemd drop-in that also enables webhook's -template mode; the
  hooks files use {{ getenv "NAME" | js }} placeholders.
- The repo hooks files are now exact copies of the live ones, so they
  also document the *-config-deploy hooks they were missing. Those
  three secrets were never committed and are unchanged.
- README: where the secrets live and how to rotate one.
- CI `hooks-no-secrets` fails if a literal secret lands in a hooks
  file again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 22:29:52 -04:00
jaredandClaude Opus 5 a16cc85420 cinny: 303 /share-target → /share for the PWA share target (#155)
Lint / Shell (shellcheck) (push) Successful in 12s
Lint / JS (eslint) (push) Successful in 8s
Lint / Python (ruff) (push) Successful in 9s
Lint / Python deps (pip-audit) (push) Successful in 1m40s
Lint / Secret scan (gitleaks) (push) Successful in 22s
The service worker normally answers the share-sheet POST itself; this
keeps a POST that reaches nginx before the worker controls the page from
returning 405.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 13:36:09 -04:00
jaredandClaude Opus 5 c553c28957 cinny nginx: drop fonts.googleapis.com / fonts.gstatic.com from the CSP
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 6s
Lint / Python deps (pip-audit) (push) Successful in 51s
Lint / Secret scan (gitleaks) (push) Successful in 8s
Lotus Chat 6f250353 self-hosts VT323, so nothing legitimate loads from Google
any more; keeping the hosts allowed would let a regression pass silently.
NOTE: the live config is hand-maintained at /etc/nginx/sites-available/cinny
on LXC 106 — apply the same two edits there after the cinny deploy lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 13:16:52 -04:00
jaredandClaude Opus 5 ee5f78b71e voice-limit-guard: live revoke never fired — LiveKit's JSON uses snake_case permissions
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 1m13s
Lint / Secret scan (gitleaks) (push) Successful in 5s
livekit-server (verified on 1.13.7) serialises ParticipantPermission as
can_publish / can_publish_sources; the reconciler read canPublish /
canPublishSources, saw 'publishes nothing' for everyone and never called
UpdateParticipant, so turning Allow Screen Sharing off did not stop an
in-progress share. Normalise the permission keys before deciding.
Verified on a local Synapse + LiveKit + guard stack: share track gone
from the SFU 2 s after the policy flip. Tests added (44 pass).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 22:10:14 -04:00
jaredandClaude Opus 5 99bc15c6f0 landing: September 2026 features — settings sync, tracking-param stripping, system-wide hotkeys, PWA install + badge
Lint / Shell (shellcheck) (push) Successful in 8s
Lint / JS (eslint) (push) Successful in 9s
Lint / Python (ruff) (push) Successful in 7s
Lint / Python deps (pip-audit) (push) Successful in 46s
Lint / Secret scan (gitleaks) (push) Successful in 6s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-17 23:30:16 -04:00
jaredandClaude Opus 5 d3ee2e2401 fix(lxc106): hooks.json carries matrix-deploy, cinny-build and lotus-deploy
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 1m3s
Lint / Secret scan (gitleaks) (push) Successful in 6s
The host's /etc/webhook/hooks.json was replaced on 2026-05-21 with a copy
containing only lotus-deploy, which dropped matrix-deploy and cinny-build;
this repo's copy had the opposite gap. No push to this repo has deployed
to LXC 106 since May. The tracked file is now the union of all three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-15 22:24:13 -04:00
jaredandClaude Opus 5 b6ea4a333e fix(cinny-deploy): queue a trigger that arrives mid-deploy instead of dropping it
Lint / Shell (shellcheck) (push) Successful in 10s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 40s
Lint / Secret scan (gitleaks) (push) Successful in 8s
lotus_deploy.sh took an flock and exited if a deploy was already running.
The poll loop only retargets origin/lotus while it is still waiting on
CI; a push that lands during npm ci / npm run build was silently never
deployed until the next unrelated push (cinny 81a6d9c9 — the MSC4515
call fix — passed CI but never reached the web root). A concurrent
trigger now leaves a .pending marker and the running deploy re-execs
itself once at the end, which re-fetches and re-gates on CI as usual.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-14 19:25:28 -04:00
jaredandClaude Opus 5 67a08c7402 fix(cinny): upstream-merge script pushes and hands off to CI instead of deploying
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 4s
Lint / Python deps (pip-audit) (push) Successful in 36s
Lint / Secret scan (gitleaks) (push) Successful in 4s
lotus-build.sh merged the latest upstream tag, then built, copied to the
web root, reloaded nginx and only then pushed — the one path that could
change production without any CI gate (cinny#98). It now merges, runs the
same local gates CI runs (npm ci, typecheck, eslint, prettier, tests),
and pushes; the push triggers Gitea CI and lotus_deploy.sh deploys once
"Build & Quality Checks" is green, like every other lotus commit. A
failed gate leaves the merge local for inspection and notifies the room.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-13 00:44:44 -04:00
jaredandClaude Fable 5.1 75d9599e22 docs(landing): note re-tuned Arcade + Deep Space seasonal overlays
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 01:29:39 -04:00
22 changed files with 3338 additions and 191 deletions
+27
View File
@@ -31,6 +31,33 @@ jobs:
- name: Run ESLint
run: npx eslint --ext .js hookshot/
hooks-no-secrets:
name: No secrets in webhook configs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
# deploy/hooks-*.json are public; their secrets come from
# /etc/webhook/secrets.env on each LXC via `{{ getenv "..." }}`.
- name: Reject literal secrets
run: |
if grep -nE '[0-9a-fA-F]{32,}' deploy/hooks-*.json; then
echo "::error::A literal secret/token is committed in deploy/hooks-*.json. Use {{ getenv \"NAME\" | js }} and put the value in /etc/webhook/secrets.env on the LXC."
exit 1
fi
landing-fresh:
name: Landing page is rendered (matrix #11)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
# index.html's feature groups, comparison table and inlined CSS are
# generated from landing/data/*.json + landing/style.css. Fail if someone
# edited the data without re-running the renderer (or edited the output).
- name: Check landing/index.html matches its sources
run: python3 landing/build.py --check
python-lint:
name: Python (ruff)
runs-on: ubuntu-latest
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jared Vititoe
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+18 -5
View File
@@ -109,7 +109,9 @@ matrix/
- Monitor state: `/var/lib/cinny-monitor/last-upstream-commit`
- Monitor log: `/var/log/cinny-monitor.log`
- Build log: `/var/log/cinny-build.log`
- Nginx site config: `/etc/nginx/sites-available/cinny`
- Nginx site config: `/etc/nginx/sites-available/cinny` (copy in this repo: `cinny/nginx.conf`, synced with live on 2026-09-23)
- Nginx security headers: `/etc/nginx/snippets/cinny-security-headers.conf` (`cinny/nginx-security-headers.conf`). Included at server level **and** in every `location` that sets its own `add_header`; nginx drops inherited `add_header`s in such blocks, which left static assets without `nosniff` and `/sw.js` without its CSP (cinny #210). Edit the CSP here, not in the site config. **Exception:** `/public/element-call/` (the bundled Element Call, which the app itself frames) uses `cinny-security-headers-framed.conf` (`cinny/nginx-security-headers-framed.conf`), the same headers **without** the CSP. The app CSP's `frame-ancestors 'none'` would block every web call.
- The `matrix-deploy` hook on 106 is served by `webhook-lotus` on `10.10.10.6:9001` (Gitea hook → `http://10.10.10.6:9001/hooks/matrix-deploy`). It was pointed at `:9000` until 2026-09-28, which is bound to `127.0.0.1`, so nothing deployed from May to then (matrix #13). `cinny/config.json` is **merged** into the live file (server-injected values such as `gifApiKey` are kept), and the script installs its own updates (`deploy/lxc106-cinny.sh` → `/usr/local/bin/matrix-deploy.sh`).
---
@@ -130,7 +132,7 @@ Pushes to `main` on `LotusGuild/matrix` automatically deploy to the relevant LXC
| LXC | Service | IP | Port | Deploys When Changed |
|-----|---------|----|----|----------------------|
| 151 | matrix/hookshot | 10.10.10.29 | **9500** | `hookshot/*.js`, `systemd/livekit-server.service`, `livekit/voice-limit-guard.py`, `systemd/voice-limit-guard.service`, `matrixbot/*` |
| 106 | cinny | 10.10.10.6 | 9000 | `cinny/config.json`, `cinny/upstream-check.sh`, `cinny/lotus-build.sh`, `deploy/hooks-lxc106.json`, `systemd/cinny-upstream-check.cron` |
| 106 | cinny | 10.10.10.6 | 9001 | `cinny/config.json` (merged), `cinny/nginx.conf`, `cinny/upstream-check.sh`, `cinny/lotus-build.sh`, `cinny/lotus_deploy.sh`, `deploy/hooks-lxc106.json`, `deploy/lxc106-cinny.sh` (self), `systemd/cinny-upstream-check.cron` |
| 139 | landing/NPM | 10.10.10.27 | 9000 | `landing/index.html` |
| 110 | draupnir | 10.10.10.24 | 9000 | `draupnir/production.yaml` |
@@ -159,7 +161,8 @@ Pushes to `main` on `LotusGuild/matrix` automatically deploy to the relevant LXC
### Installed Components (per LXC)
- `webhook` binary (Debian package `webhook` v2.8.0) listening on respective port
- `/etc/webhook/hooks.json` — unique HMAC-SHA256 secret per LXC
- `/etc/webhook/hooks.json` — the `deploy/hooks-lxcNNN.json` file from this repo. It holds **no secrets**: each secret is a template placeholder (`{{ getenv "MATRIX_DEPLOY_SECRET" | js }}`), rendered by webhook's `-template` flag
- `/etc/webhook/secrets.env` (root, `0600`, **not in git**) — `MATRIX_DEPLOY_SECRET` (plus `CONFIG_DEPLOY_SECRET` on 151/139/110, and `LOTUS_DEPLOY_SECRET` + `CINNY_BUILD_TOKEN` on 106). Loaded by the drop-in `/etc/systemd/system/webhook.service.d/secrets.conf` (`EnvironmentFile=` + `-template`); 106's `webhook-lotus.service` has the same drop-in
- `/usr/local/bin/matrix-deploy.sh` — deploy script from this repo
- `/etc/systemd/system/webhook.service` — enabled and running
- `/opt/matrix-config/` — clone of this repo
@@ -461,7 +464,17 @@ EOF
chmod 600 /etc/cinny-monitor.env
```
**Cinny-build webhook token** (for LotusBot `!cinny-update`): stored in `deploy/hooks-lxc106.json` (`cinny-build` hook, header `X-Build-Token`). LotusBot must POST to `http://10.10.10.6:9000/hooks/cinny-build` with this header.
**Cinny-build webhook token** (header `X-Build-Token` on the `cinny-build` hook): `CINNY_BUILD_TOKEN` in LXC 106's `/etc/webhook/secrets.env`, not in git. Reachable on `10.10.10.6:9001` (`webhook-lotus`); `:9000` is bound to `127.0.0.1`. Note: LotusBot doesn't implement `!cinny-update` at the moment, so nothing calls this hook.
### Webhook secrets — rotating
Secrets used to be committed in `deploy/hooks-lxc*.json` in this public repo; all exposed ones were rotated on 2026-09-23 and moved to `secrets.env`. To rotate one:
1. Generate a value: `openssl rand -hex 32`.
2. On the LXC: edit `/etc/webhook/secrets.env`, then `systemctl restart webhook` (and `webhook-lotus` on 106 — check no `lotus_deploy.sh` is running first, a restart kills it).
3. In Gitea: the repo → Settings → Webhooks → that hook → set the same secret.
CI (`hooks-no-secrets` in `.gitea/workflows/lint.yml`) fails if a 32-byte hex value is committed in a hooks file again.
**Why 8GB RAM:** Vite's build process needs ~6GB Node heap (`--max_old_space_size=6144`) for the rendering-chunks phase. Previously at 4GB — OOM killed during render.
@@ -524,7 +537,7 @@ All custom code lives in `src/app/` on the `lotus` branch of `code.lotusguild.or
| **PiP position persistence + snap** | `src/app/components/CallEmbedProvider.tsx` | PiP position saved to `localStorage` on drag end; restored on next PiP enter (clamped to viewport). Double-click snaps to nearest corner with 180ms CSS transition |
| **Threads (P3-8 + P4-1)** | `src/app/features/room/thread/`, `state/room/thread.ts`, `utils/threadNotifications.ts`, `hooks/useRoomsListener.ts` | Full m.thread support: side panel (own composer, per-thread drafts), "N replies" unread chips, threaded receipts; SDK `threadSupport` on, markAsRead unthreaded; replies no longer render inline. **Slack-style notifications**: default = participating-only, per-thread All/Mentions/Mute in `io.lotus.thread_notifications` account data; muted threads subtracted from room badges client-side |
| **KaTeX math + encrypted-search cache + session hardening + crypto diagnostics** | `utils/{mathParse,searchCache,cryptoDiagLog}.ts`, `state/sessions.ts`, `LOTUS_E2EE_INVESTIGATION.md` | July 2026 batch: `$…$`/`$$…$$` + `data-mx-maths` via lazy KaTeX; opt-in IndexedDB search index for E2EE rooms (wiped on logout); atomic `cinny_session_v1` blob + cross-tab logout sync; KE-1→4 diagnostics capture card in Developer Tools |
| **Inline media embeds** | `src/app/utils/videoEmbed.ts`, `src/app/components/url-preview/{UrlPreviewCard,UrlPreview.css}.tsx` | Media links play/render **in place** behind a click-to-play **facade** (homeserver `og:image` thumbnail; third-party iframe mounts only on Play). 16 providers: video (YouTube/Shorts, Vimeo, Dailymotion, Streamable, Twitch, Loom, Kick), audio (Spotify, SoundCloud, Apple Music, Tidal), self-resizing posts (X/Twitter, Instagram, Reddit, Bluesky). TikTok short links resolve via CORS `oEmbed`; posts self-size via origin-scoped `postMessage`. Sandbox omits `allow-top-navigation`; previews capped at 6/msg. Setting `inlineMediaEmbeds`. **CSP:** every embed host is enumerated in the desktop Tauri `frame-src` (`cinny-desktop/tauri.conf.json`) **and** the deployed web nginx `frame-src` allowlist on LXC 106 (`/etc/nginx/sites-available/cinny` — hand-maintained; the wildcard `frame-src 'self' https:` in this repo's `cinny/nginx.conf` is the looser fallback) |
| **Inline media embeds** | `src/app/utils/videoEmbed.ts`, `src/app/components/url-preview/{UrlPreviewCard,UrlPreview.css}.tsx` | Media links play/render **in place** behind a click-to-play **facade** (homeserver `og:image` thumbnail; third-party iframe mounts only on Play). 16 providers: video (YouTube/Shorts, Vimeo, Dailymotion, Streamable, Twitch, Loom, Kick), audio (Spotify, SoundCloud, Apple Music, Tidal), self-resizing posts (X/Twitter, Instagram, Reddit, Bluesky). TikTok short links resolve via CORS `oEmbed`; posts self-size via origin-scoped `postMessage`. Sandbox omits `allow-top-navigation`; previews capped at 6/msg. Setting `inlineMediaEmbeds`. **CSP:** every embed host is enumerated in the desktop Tauri `frame-src` (`cinny-desktop/tauri.conf.json`) **and** the deployed web nginx `frame-src` allowlist on LXC 106 (`/etc/nginx/snippets/cinny-security-headers.conf` on LXC 106; repo copy `cinny/nginx-security-headers.conf`) |
| **On-device message translation** | `src/app/features/room/message/`, `src/app/hooks/useMessageTranslation.ts`, `src/app/state/settings.ts` | "Translate" button on foreign-language messages renders the translation inline with a "Translated from &lt;lang&gt; · Show original" toggle + optional auto-translate mode. Runs **100% on-device** via the browser's built-in Translator API (Chromium: Chrome/Edge + Lotus desktop app) — message text never leaves the device and never reaches any cloud translation service (Google/DeepL/Microsoft), so it works in **E2EE rooms** without weakening encryption. Target language in Settings → General (default English). Feature-detected — gracefully hidden where unsupported (Firefox/Safari/mobile) |
| **Desktop app (Tauri)** | `cinny-desktop` → `src-tauri/src/native/*.rs`, `src-tauri/src/lib.rs`; cinny `src/app/hooks/useTauri*.ts`, `src/app/components/TauriDesktopFeatures.tsx` | Tauri v2 native shell: rich WinRT toast notifications (click → open room, inline quick reply), Windows Focus Assist → DND sync, taskbar Jump List of recent rooms, taskbar thumbnail + volume-flyout call controls (mute/deafen/end), no-sleep during calls, network-change awareness (`mx.retryImmediately`), opt-in TDS window chrome, recursive folder drag-drop, auto-update toast. Windows-native pieces compile in CI (Gitea `windows` runner + GitHub `windows-latest`); detail in cinny `LOTUS_FEATURES.md` → Desktop App Features |
| **LiveKit codec config** | `/etc/livekit/config.yaml` (LXC 151) | `enabled_codecs`: VP8, H264, VP9, Opus, RED for better quality and redundancy |
+14 -2
View File
@@ -16,5 +16,17 @@
"enabled": false,
"basename": "/"
},
"gifApiKey": ""
}
"gifApiKey": "",
"elementCallUrl": "https://call.chat.lotusguild.org/public/element-call/index.html",
"statusPages": {
"matrix.lotusguild.org": {
"url": "https://isitup.lotusguild.org",
"slug": "matrix",
"groups": {
"homeserver": "Homeserver",
"calls": "Voice calls",
"login": "Login"
}
}
}
}
+26 -23
View File
@@ -1,5 +1,9 @@
#!/bin/bash
# Merges the latest upstream stable release tag into the lotus branch, builds, and deploys.
# Merges the latest upstream stable release tag into the lotus branch, runs the
# local quality gates, and PUSHES. It no longer builds or deploys itself: the push
# triggers the normal Gitea CI run and lotus_deploy.sh deploys only once the
# "Build & Quality Checks" status is green — the same path every other lotus
# commit takes (cinny#98: this script used to build+deploy+push, bypassing CI).
# Triggered via webhook by LotusBot !cinny-update command.
# Requires:
# /etc/cinny-monitor.env — MATRIX_TOKEN, MATRIX_SERVER, MATRIX_ROOM
@@ -8,9 +12,6 @@
set -euo pipefail
REPO_DIR="/opt/lotus-cinny"
WEB_ROOT="/var/www/html"
CONFIG_BACKUP="/opt/lotus-cinny/.cinny-config.json"
BUILD_DIR="/opt/lotus-cinny/dist"
STATE_FILE="/var/lib/cinny-monitor/last-upstream-tag"
ENV_FILE="/etc/cinny-monitor.env"
LOG="/var/log/cinny-build.log"
@@ -82,38 +83,40 @@ fi
matrix_notify "cinny-build: merging $LATEST_TAG into lotus branch..."
# Back up live config before touching anything
[ -f "$WEB_ROOT/config.json" ] && cp "$WEB_ROOT/config.json" "$CONFIG_BACKUP"
if ! git merge "$LATEST_TAG" --no-edit 2>&1; then
git merge --abort 2>/dev/null || true
matrix_notify "cinny-build: FAILED — merge conflict at $LATEST_TAG. SSH to LXC 106 and resolve manually. See /var/log/cinny-build.log"
exit 1
fi
# ── Local pre-flight (same gates CI runs, minus the build) ──────────────────
# An upstream merge touches hundreds of files we didn't write, so catch an
# obviously broken merge here before it lands on origin. CI is still the
# authority: a failure here leaves the merge commit LOCAL (not pushed) so it can
# be inspected/fixed on the box.
echo "Running npm ci..."
npm ci 2>&1 | tail -5
rm -rf "$BUILD_DIR"
export NODE_OPTIONS='--max_old_space_size=6144'
echo "Building $LATEST_TAG..."
npm run build 2>&1 | tail -10
if [ ! -f "$BUILD_DIR/index.html" ]; then
matrix_notify "cinny-build: FAILED — build produced no output at $LATEST_TAG. See /var/log/cinny-build.log"
if ! npm ci 2>&1 | tail -5; then
matrix_notify "cinny-build: FAILED — npm ci failed after merging $LATEST_TAG. Merge is local only (not pushed). See /var/log/cinny-build.log"
exit 1
fi
rm -rf "${WEB_ROOT:?}"/*
cp -r "$BUILD_DIR"/* "$WEB_ROOT/"
[ -f "$CONFIG_BACKUP" ] && cp "$CONFIG_BACKUP" "$WEB_ROOT/config.json"
nginx -s reload
for gate in "npm run typecheck" "npm run check:eslint" "npm run check:prettier" "npm test"; do
echo "Gate: $gate"
if ! $gate 2>&1 | tail -20; then
matrix_notify "cinny-build: FAILED — '$gate' failed after merging $LATEST_TAG. Merge is local only (not pushed). SSH to LXC 106 to fix forward."
exit 1
fi
done
# Push merged lotus branch to origin
# ── Hand off to CI + lotus_deploy.sh ────────────────────────────────────────
# Pushing is what deploys: Gitea CI builds and runs every gate, and the
# lotus-deploy webhook polls the "Build & Quality Checks" status and only then
# rsyncs dist/ to the web root (preserving the live config.json). Nothing is copied
# to the web root from here.
git push origin lotus
# Update state file so upstream-check knows we're on this tag
echo "$LATEST_TAG" > "$STATE_FILE"
matrix_notify "cinny-build: deployed $LATEST_TAG — Lotus Cinny is live."
echo "=== Build complete: $LATEST_TAG ==="
matrix_notify "cinny-build: merged $LATEST_TAG and pushed — CI is running; lotus_deploy.sh will go live once 'Build & Quality Checks' passes (~11 min). Watch https://code.lotusguild.org/LotusGuild/cinny/actions"
echo "=== Merge pushed: $LATEST_TAG (deploy via CI) ==="
+22 -2
View File
@@ -6,9 +6,20 @@ WEBROOT="/var/www/html"
LOCKFILE="/tmp/lotus-deploy.lock"
LOGFILE="/var/log/lotus-deploy.log"
# Prevent concurrent deploys
# Prevent concurrent deploys. A trigger that arrives while a deploy holds the
# lock is NOT dropped any more: it leaves a marker, and the running deploy
# re-runs itself once when it finishes. (Previously it was skipped outright —
# the poll loop retargets origin/lotus only while it is still polling, so a
# push that landed during `npm ci && npm run build` was never deployed until
# the next unrelated push. Bit us on cinny 81a6d9c9.)
PENDING="$LOCKFILE.pending"
exec 200>"$LOCKFILE"
flock -n 200 || { echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploy already in progress, skipping." >> "$LOGFILE"; exit 0; }
if ! flock -n 200; then
touch "$PENDING"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploy already in progress — queued a follow-up run." >> "$LOGFILE"
exit 0
fi
rm -f "$PENDING"
exec >> "$LOGFILE" 2>&1
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ===== Deploy triggered ====="
@@ -153,6 +164,15 @@ rsync -a --delete --exclude config.json dist/ "$WEBROOT/"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ===== Deploy complete ($VITE_APP_VERSION) ====="
# A trigger arrived while we were deploying: release the lock and run once more
# so the newest origin/lotus gets deployed (it re-fetches and re-gates on CI).
if [ -f "$PENDING" ]; then
rm -f "$PENDING"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Queued trigger found — re-running deploy for the newest commit."
flock -u 200
exec "$0" "$@"
fi
# Inject runtime secrets that are never stored in git. If the production
# config.json carries the "gifApiKey": "" placeholder, fill it from the env.
if [ -n "${GIPHY_API_KEY:-}" ]; then
+14
View File
@@ -0,0 +1,14 @@
# Headers for the Element Call page on its own origin, call.chat.lotusguild.org
# (cinny #43). Installed on LXC 106 as /etc/nginx/snippets/cinny-security-headers-call.conf
# (deploy/lxc106-cinny.sh does NOT copy snippets — install by hand, like the others).
#
# frame-ancestors: only the web app may frame the call page (replaces
# X-Frame-Options SAMEORIGIN, which would block the now cross-origin parent;
# browsers honour frame-ancestors over X-Frame-Options anyway).
# Permissions-Policy: "self" here is the call origin, which is what uses the
# mic/camera/screen; the app's own policy delegates them to this origin.
add_header Content-Security-Policy "frame-ancestors https://chat.lotusguild.org" always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header Permissions-Policy "accelerometer=(), autoplay=(self), camera=(self), display-capture=(self), encrypted-media=(self), fullscreen=(self), geolocation=(), gyroscope=(), magnetometer=(), microphone=(self), midi=(), payment=(), usb=()" always;
+11
View File
@@ -0,0 +1,11 @@
# Headers for the bundled Element Call page (/public/element-call/).
# Same as cinny-security-headers.conf minus the Content-Security-Policy: the app
# embeds this page in an iframe, and the app CSP's frame-ancestors 'none' (plus a
# connect-src that doesn't list the call backends) blocked it. X-Frame-Options
# SAMEORIGIN still limits framing to chat.lotusguild.org itself.
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header Permissions-Policy "accelerometer=(), autoplay=(self), camera=(self), display-capture=(self), encrypted-media=(self), fullscreen=(self), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(self), midi=(), payment=(), usb=()" always;
+15
View File
@@ -0,0 +1,15 @@
# Security headers for chat.lotusguild.org (matrix repo: cinny/nginx-security-headers.conf).
# Included at server level AND in every location that sets its own add_header:
# nginx drops inherited add_header directives in any block that defines one,
# so without the include, static assets lost nosniff and /sw.js lost its CSP
# (a service worker's CSP comes from its own script response). cinny #210.
# Permissions-Policy delegates autoplay/camera/display-capture/microphone to the
# call page's own origin (call.chat.lotusguild.org, cinny #43); without it the
# cross-origin call frame's getUserMedia is refused (NotAllowedError).
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header Permissions-Policy 'accelerometer=(), autoplay=(self "https://call.chat.lotusguild.org"), camera=(self "https://call.chat.lotusguild.org"), display-capture=(self "https://call.chat.lotusguild.org"), encrypted-media=(self), fullscreen=(self), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(self "https://call.chat.lotusguild.org"), midi=(), payment=(), usb=()' always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://matrix.lotusguild.org https://matrix.org https://*.matrix.org https://mozilla.org https://mozilla.modular.im https://drive.lotusguild.org https://media.giphy.com https://media0.giphy.com https://media1.giphy.com https://media2.giphy.com https://media3.giphy.com https://media4.giphy.com https://www.openstreetmap.org https://tile.openstreetmap.org; font-src 'self' data:; connect-src 'self' https://matrix.lotusguild.org wss://matrix.lotusguild.org https://isitup.lotusguild.org https://matrix.org https://*.matrix.org https://mozilla.org https://mozilla.modular.im https://chat.mozilla.org https://vector.im https://api.giphy.com https://*.giphy.com wss:; media-src 'self' https: blob:; frame-src 'self' https:; worker-src 'self' blob:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
+80 -9
View File
@@ -18,21 +18,16 @@ server {
limit_conn chat_conn 25;
index index.html;
# Security headers
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# Security headers (incl. CSP) — see the snippet
include snippets/cinny-security-headers.conf;
# HSTS: TLS terminates upstream (this server is listen 80), so this reaches
# the browser only if the front proxy passes upstream response headers
# through; otherwise set it at the TLS terminator. includeSubDomains covers
# all *.lotusguild.org (all HTTPS); `preload` is inert until submitted to
# hstspreload.org.
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# Permissions-Policy: allow only what the app uses (self) — calls
# (camera/microphone/display-capture), location share (geolocation), sounds
# (autoplay), Element Call (fullscreen/encrypted-media) — and deny the rest.
add_header Permissions-Policy "accelerometer=(), autoplay=(self), camera=(self), display-capture=(self), encrypted-media=(self), fullscreen=(self), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(self), midi=(), payment=(), usb=()" always;
# Block all source map files and dotfiles from public access
location ~* \.(js|css)\.map$ {
@@ -48,27 +43,53 @@ server {
return 404;
}
# Content Security Policy
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https://matrix.lotusguild.org https://matrix.org https://*.matrix.org https://mozilla.org https://mozilla.modular.im https://drive.lotusguild.org https://media.giphy.com https://media0.giphy.com https://media1.giphy.com https://media2.giphy.com https://media3.giphy.com https://media4.giphy.com https://www.openstreetmap.org https://tile.openstreetmap.org; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://matrix.lotusguild.org wss://matrix.lotusguild.org https://matrix.org https://*.matrix.org https://mozilla.org https://mozilla.modular.im https://chat.mozilla.org https://vector.im https://api.giphy.com https://*.giphy.com wss:; media-src 'self' https: blob:; frame-src 'self' https:; worker-src 'self' blob:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
# Service worker must never be cached so updates are picked up immediately
location = /sw.js {
include snippets/cinny-security-headers.conf;
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
}
# Bundled Element Call: framed by the app itself, so it must not carry the
# app CSP (frame-ancestors 'none' blocked every web call). Same caching as
# below: HTML never cached, hashed assets for a year.
location ^~ /public/element-call/ {
include snippets/cinny-security-headers-framed.conf;
location ~* \.html$ {
include snippets/cinny-security-headers-framed.conf;
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
}
location ~* \.(?:js|css|woff2?|png|svg|ico|webp|wasm)$ {
include snippets/cinny-security-headers-framed.conf;
expires 1y;
add_header Cache-Control "public, immutable" always;
}
}
# Cache content-addressed static assets aggressively
location ~* \.(?:js|css|woff2?|png|svg|ico|webp)$ {
include snippets/cinny-security-headers.conf;
expires 1y;
add_header Cache-Control "public, immutable" always;
}
# Never cache HTML or JSON (index.html, config.json, manifest.json)
location ~* \.(json|html)$ {
include snippets/cinny-security-headers.conf;
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
}
# [Gitea #155] PWA share target. The service worker normally answers this
# POST itself; if it isn't controlling the page yet, land on /share
# (the shared files are lost, but nothing 405s).
location = /share-target {
absolute_redirect off;
return 303 /share;
}
# Auto-deploy webhook — proxied to local webhook service
location = /hooks/lotus-deploy {
proxy_pass http://127.0.0.1:9001/hooks/lotus-deploy;
@@ -87,3 +108,53 @@ server {
rewrite ^(.+)$ /index.html break;
}
}
# [cinny #43] Element Call on its own origin. The web app frames
# https://call.chat.lotusguild.org/public/element-call/index.html (config.json
# `elementCallUrl`), so the call page can no longer read the app's storage
# (login token, crypto store) or use its service worker. Serves ONLY the call
# page — the same files as chat.lotusguild.org/public/element-call/ — and 404s
# everything else, so this hostname exposes nothing new.
server {
listen 80;
listen [::]:80;
server_name call.chat.lotusguild.org;
brotli on;
brotli_static on;
brotli_comp_level 6;
brotli_types text/plain text/css application/javascript application/json
image/svg+xml application/wasm font/woff2;
root /var/www/html;
server_tokens off;
limit_req zone=chat_limit burst=60 nodelay;
limit_conn chat_conn 25;
include snippets/cinny-security-headers-call.conf;
location ^~ /public/element-call/ {
include snippets/cinny-security-headers-call.conf;
location ~* \.map$ {
return 404;
}
location ~ /\. {
return 404;
}
location ~* \.html$ {
include snippets/cinny-security-headers-call.conf;
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
}
location ~* \.(?:js|css|woff2?|png|svg|ico|webp|wasm)$ {
include snippets/cinny-security-headers-call.conf;
expires 1y;
add_header Cache-Control "public, immutable" always;
}
try_files $uri =404;
}
location / {
return 404;
}
}
+32 -2
View File
@@ -7,7 +7,7 @@
"trigger-rule": {
"match": {
"type": "payload-hash-sha256",
"secret": "76dd5febd1cc3458545ce37537f4bfe26f241a9635b57a2cba183ebc9221230b",
"secret": "{{ getenv "MATRIX_DEPLOY_SECRET" | js }}",
"parameter": {
"source": "header",
"name": "X-Gitea-Signature"
@@ -23,12 +23,42 @@
"trigger-rule": {
"match": {
"type": "value",
"value": "a82340fc2f07e6afda097494c34aa3a4877924932a0b063a76106fdab9816ec6",
"value": "{{ getenv "CINNY_BUILD_TOKEN" | js }}",
"parameter": {
"source": "header",
"name": "X-Build-Token"
}
}
}
},
{
"id": "lotus-deploy",
"execute-command": "/usr/local/bin/lotus_deploy.sh",
"command-working-directory": "/opt/lotus-cinny",
"response-message": "Deploying Lotus Chat...",
"trigger-rule": {
"and": [
{
"match": {
"type": "payload-hash-sha256",
"secret": "{{ getenv "LOTUS_DEPLOY_SECRET" | js }}",
"parameter": {
"source": "header",
"name": "X-Gitea-Signature"
}
}
},
{
"match": {
"type": "value",
"value": "refs/heads/lotus",
"parameter": {
"source": "payload",
"name": "ref"
}
}
}
]
}
}
]
+31 -1
View File
@@ -7,12 +7,42 @@
"trigger-rule": {
"match": {
"type": "payload-hash-sha256",
"secret": "0d23fab8743e9ee6b52cbd05a889b04c927ffa2b2b21fe50244f1a534d1a22d0",
"secret": "{{ getenv "MATRIX_DEPLOY_SECRET" | js }}",
"parameter": {
"source": "header",
"name": "X-Gitea-Signature"
}
}
}
},
{
"id": "110-config-deploy",
"execute-command": "/usr/local/bin/draupnir_deploy.sh",
"command-working-directory": "/opt/pve-infra",
"response-message": "Deploying draupnir config...",
"trigger-rule": {
"and": [
{
"match": {
"type": "payload-hash-sha256",
"secret": "{{ getenv "CONFIG_DEPLOY_SECRET" | js }}",
"parameter": {
"source": "header",
"name": "X-Gitea-Signature"
}
}
},
{
"match": {
"type": "value",
"value": "refs/heads/main",
"parameter": {
"source": "payload",
"name": "ref"
}
}
}
]
}
}
]
+31 -1
View File
@@ -7,12 +7,42 @@
"trigger-rule": {
"match": {
"type": "payload-hash-sha256",
"secret": "ddea576ef03bff35f0c9d138b626b273d9e9502434e0717899a87677cd5ac267",
"secret": "{{ getenv "MATRIX_DEPLOY_SECRET" | js }}",
"parameter": {
"source": "header",
"name": "X-Gitea-Signature"
}
}
}
},
{
"id": "139-config-deploy",
"execute-command": "/usr/local/bin/nginxproxymanager_deploy.sh",
"command-working-directory": "/opt/pve-infra",
"response-message": "Deploying nginxproxymanager config...",
"trigger-rule": {
"and": [
{
"match": {
"type": "payload-hash-sha256",
"secret": "{{ getenv "CONFIG_DEPLOY_SECRET" | js }}",
"parameter": {
"source": "header",
"name": "X-Gitea-Signature"
}
}
},
{
"match": {
"type": "value",
"value": "refs/heads/main",
"parameter": {
"source": "payload",
"name": "ref"
}
}
}
]
}
}
]
+31 -1
View File
@@ -7,12 +7,42 @@
"trigger-rule": {
"match": {
"type": "payload-hash-sha256",
"secret": "38ba0e66763da2096c47645cbf636ce3c2c51232e006b964e57d6bb94a32dcaa",
"secret": "{{ getenv "MATRIX_DEPLOY_SECRET" | js }}",
"parameter": {
"source": "header",
"name": "X-Gitea-Signature"
}
}
}
},
{
"id": "151-config-deploy",
"execute-command": "/usr/local/bin/matrix_deploy.sh",
"command-working-directory": "/opt/pve-infra",
"response-message": "Deploying matrix config...",
"trigger-rule": {
"and": [
{
"match": {
"type": "payload-hash-sha256",
"secret": "{{ getenv "CONFIG_DEPLOY_SECRET" | js }}",
"parameter": {
"source": "header",
"name": "X-Gitea-Signature"
}
}
},
{
"match": {
"type": "value",
"value": "refs/heads/main",
"parameter": {
"source": "payload",
"name": "ref"
}
}
}
]
}
}
]
+53 -5
View File
@@ -3,7 +3,8 @@
# Handles: cinny/config.json, cinny/nginx.conf, cinny/upstream-check.sh,
# cinny/lotus-build.sh, cinny/lotus_deploy.sh,
# deploy/hooks-lxc106.json, systemd/cinny-upstream-check.cron
# Triggered by: Gitea webhook on push to main
# Triggered by: Gitea webhook on push to main → http://10.10.10.6:9001/hooks/matrix-deploy
# (webhook-lotus; the :9000 listener is bound to 127.0.0.1 and unreachable).
set -euo pipefail
REPO_DIR="/opt/matrix-config"
@@ -27,9 +28,39 @@ else
fi
if echo "$CHANGED" | grep -q '^cinny/config.json'; then
echo "Deploying cinny config.json..."
cp "$REPO_DIR/cinny/config.json" /var/www/html/config.json
echo "✓ config.json deployed"
echo "Deploying cinny config.json (merged: keeps server-only values)..."
# The live file carries values injected on the server that are empty in git
# (gifApiKey, set by lotus_deploy.sh from GIPHY_API_KEY). A plain copy would
# blank them, so repo keys win except where the repo value is empty and the
# live one isn't. Backup outside the web root; on any error the live file
# is left untouched.
mkdir -p /root/config-backups
cp -p /var/www/html/config.json "/root/config-backups/config.json.$(date +%Y%m%d%H%M%S)" 2>/dev/null || true
if python3 - "$REPO_DIR/cinny/config.json" /var/www/html/config.json <<'PY'
import json, os, sys, tempfile
repo_path, live_path = sys.argv[1], sys.argv[2]
repo = json.load(open(repo_path))
live = json.load(open(live_path)) if os.path.exists(live_path) else {}
merged = dict(repo)
kept = []
for key, value in live.items():
if key in repo and repo[key] in ("", None) and value not in ("", None):
merged[key] = value
kept.append(key)
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(live_path))
with os.fdopen(fd, "w") as f:
json.dump(merged, f, indent=2)
f.write("\n")
json.load(open(tmp))
os.chmod(tmp, 0o644)
os.replace(tmp, live_path)
print("kept server values for: " + (", ".join(kept) or "none"))
PY
then
echo "✓ config.json deployed"
else
echo "✗ config.json merge FAILED — live file left unchanged"
fi
fi
if echo "$CHANGED" | grep -q '^cinny/nginx.conf'; then
@@ -80,7 +111,11 @@ if echo "$CHANGED" | grep -q '^deploy/hooks-lxc106.json'; then
echo "Deploying hooks-lxc106.json..."
cp "$REPO_DIR/deploy/hooks-lxc106.json" /etc/webhook/hooks.json
systemctl restart webhook
echo "✓ hooks.json deployed, webhook restarted"
# webhook-lotus (:9001) serves the same hooks and is the one running THIS
# script (Gitea posts matrix-deploy there), so restarting it now would kill
# this deploy mid-run: restart it shortly after we exit instead.
systemd-run --on-active=15s --unit="webhook-lotus-reload-$(date +%s)" systemctl restart webhook-lotus
echo "✓ hooks.json deployed, webhook restarted (webhook-lotus in 15s)"
fi
if echo "$CHANGED" | grep -q '^systemd/cinny-upstream-check.cron'; then
@@ -90,4 +125,17 @@ if echo "$CHANGED" | grep -q '^systemd/cinny-upstream-check.cron'; then
echo "✓ cron deployed"
fi
# Keep the installed copy of this script in step with the repo (the webhook
# runs /usr/local/bin/matrix-deploy.sh, not the repo file). Checked with bash -n
# first; takes effect from the next deploy.
if echo "$CHANGED" | grep -q '^deploy/lxc106-cinny.sh'; then
if bash -n "$REPO_DIR/deploy/lxc106-cinny.sh"; then
cp "$REPO_DIR/deploy/lxc106-cinny.sh" /usr/local/bin/matrix-deploy.sh
chmod +x /usr/local/bin/matrix-deploy.sh
echo "✓ matrix-deploy.sh updated"
else
echo "✗ bash -n FAILED on lxc106-cinny.sh — keeping the installed copy"
fi
fi
echo "=== $(date) === LXC106 deploy complete ==="
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Render the data-driven sections of landing/index.html.
The fork feature list and the client comparison table used to be edited by
hand in two places per feature (matrix #11). They now live in landing/data/:
features.json - the "our fork adds" groups, one short line per feature
comparison.json - the client comparison table + its "reviewed" date
and the stylesheet lives in landing/style.css. It is inlined into index.html
rather than linked, because the LXC 139 deploy copies index.html only.
Run `python3 landing/build.py` after editing either file and commit the
regenerated index.html (LXC 139 serves the files as-is; there is no build step
there). `--check` exits 1 if index.html is out of date, for CI.
Only the text between `<!-- BEGIN:name -->` and `<!-- END:name -->` markers is
rewritten; everything else in index.html stays hand-edited.
"""
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
INDEX = ROOT / "index.html"
MARK = {"yes": "✓", "no": "✗", "part": "~"}
def render_features(groups):
out = ['<div class="fork-features">']
for g in groups:
out.append(' <div class="fork-group">')
out.append(f' <h4>{g["title"]}</h4>')
out.append(" <ul>")
out.extend(f" <li>{item}</li>" for item in g["items"])
out.append(" </ul>")
out.append(" </div>")
out.append("</div>")
return "\n".join(out)
def render_cell(cell, ours):
cls = ' class="ours"' if ours else ""
if "mark" in cell:
inner = f'<span class="{cell["mark"]}">{MARK[cell["mark"]]}</span>'
else:
inner = cell["text"]
if cell.get("note"):
inner += f'<small>{cell["note"]}</small>'
return f"<td{cls}>{inner}</td>"
def render_comparison(data):
clients = data["clients"]
cols = len(clients) + 1
out = ["<table>", " <thead>", " <tr>", " <th></th>"]
for i, c in enumerate(clients):
cls = ' class="ours"' if i == 0 else ""
sub = f'<small>{c["sub"]}</small>' if c["sub"] else ""
out.append(f' <th{cls}>{c["name"]}{sub}</th>')
out += [" </tr>", " </thead>", " <tbody>"]
for sec in data["sections"]:
out.append(f' <tr class="section-header"><td colspan="{cols}">{sec["title"]}</td></tr>')
for row in sec["rows"]:
if len(row["cells"]) != len(clients):
sys.exit(f'comparison.json: "{row["feature"]}" has {len(row["cells"])} cells, expected {len(clients)}')
out.append(" <tr>")
out.append(f' <td>{row["feature"]}</td>')
out.extend(f" {render_cell(c, i == 0)}" for i, c in enumerate(row["cells"]))
out.append(" </tr>")
out += [" </tbody>", "</table>"]
return "\n".join(out)
def replace_block(html, name, content):
pattern = re.compile(
rf"(?P<indent>[ \t]*)<!-- BEGIN:{name} -->.*?<!-- END:{name} -->", re.S
)
m = pattern.search(html)
if not m:
sys.exit(f"index.html: missing <!-- BEGIN:{name} --> / <!-- END:{name} --> markers")
indent = m.group("indent")
body = "\n".join(indent + line if line else line for line in content.split("\n"))
block = f"{indent}<!-- BEGIN:{name} -->\n{body}\n{indent}<!-- END:{name} -->"
return html[: m.start()] + block + html[m.end() :]
def main():
features = json.loads((ROOT / "data" / "features.json").read_text(encoding="utf-8"))
comparison = json.loads((ROOT / "data" / "comparison.json").read_text(encoding="utf-8"))
css = (ROOT / "style.css").read_text(encoding="utf-8").rstrip("\n")
html = INDEX.read_text(encoding="utf-8")
styles = "<style>\n" + "\n".join(" " + line if line else line for line in css.split("\n")) + "\n</style>"
new = replace_block(html, "styles", styles)
new = replace_block(new, "features", render_features(features))
new = replace_block(new, "comparison", render_comparison(comparison))
new = replace_block(
new, "reviewed", f'Client comparison last reviewed <time datetime="{comparison["reviewed"]}">{comparison["reviewed"]}</time>'
)
if "--check" in sys.argv:
if new != html:
sys.exit("landing/index.html is out of date: run python3 landing/build.py")
print("landing/index.html is up to date")
return
INDEX.write_text(new, encoding="utf-8")
print("wrote", INDEX)
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
[
{
"title": "Calls &amp; Voice",
"items": [
"Voice rooms with our own Element Call fork on a self-hosted LiveKit SFU",
"Push-to-talk and push-to-deafen, with system-wide hotkeys on the Windows app (work while a game has focus)",
"Mic level meter on the mute button; per-person call volume that's remembered between calls",
"On-device noise suppression: Off, browser-native, or ML (RNNoise, Speex, DTLN, DeepFilterNet&nbsp;3)",
"In-call soundboard with your own clips, synced across your devices",
"Incoming-call ring with Answer/Decline, join/leave sounds, draggable picture-in-picture window",
"Screenshare fullscreen and audio mute; per-user mic/screenshare bitrate and framerate caps",
"Server-enforced room limits and permissions (max participants, audio-only, no screenshare) for every Matrix client",
"AFK auto-mute after a configurable idle time"
]
},
{
"title": "Messaging",
"items": [
"Threads with a side panel, unread chips, &ldquo;Mark all read&rdquo; and per-thread notification overrides",
"Search operators: <code>from:</code>, <code>in:</code>, <code>before:</code>/<code>after:</code> (dates or <code>7d</code>), <code>has:link|image|video|file</code>, <code>is:pinned</code>",
"Voice messages with 0.75&times;&ndash;2&times; playback (MSC3245, E2EE)",
"Polls, message scheduling (MSC4140), forwarding, captions and location sharing",
"Pinned messages, saved messages/bookmarks and private notes on people, synced across devices",
"Who-reacted viewer, read receipt avatars, quick reactions on hover",
"GIF picker (Giphy), custom emoji &amp; sticker packs, math/LaTeX (KaTeX)",
"Pasted code is offered as a formatted code block, with the language detected",
"On-device message translation: text never leaves your machine, works in encrypted rooms"
]
},
{
"title": "Media &amp; Links",
"items": [
"Click-to-play inline embeds: YouTube, Vimeo, Twitch, TikTok, X, Instagram, Reddit, Bluesky, Spotify, SoundCloud, Apple Music, Tidal and more",
"Rich link cards for GitHub, Steam, Wikipedia, IMDb, npm and others; animated GIF previews",
"Full-screen viewer with pinch-zoom, swipe/arrow-key navigation and captions; media gallery for every file shared in a room",
"Optional image compression, folder drag-and-drop upload"
]
},
{
"title": "Privacy &amp; Security",
"items": [
"Private read receipts, hide typing, hide online status",
"Tracking-parameter stripping (utm_*, fbclid, YouTube si= and ~40 more), on your device",
"Opt-in on-device search index for encrypted rooms, wiped on logout",
"Device verification fixes (cross-client emoji SAS) and a per-member session panel"
]
},
{
"title": "Look &amp; Feel",
"items": [
"Lotus Terminal theme, night-light filter, glassmorphism sidebar",
"Animated chat backgrounds and 11 seasonal overlays (all respect reduced motion)",
"629 animated avatar decorations in 28 categories, visible to other Lotus users (MSC4133)",
"Custom status with emoji and auto-clear, Discord-style presence rings",
"Settings sync across devices (theme, layout, notification and privacy preferences)"
]
},
{
"title": "Desktop App",
"items": [
"Windows and Linux (Tauri): installer, AppImage, .deb and Arch package",
"Rich Windows notifications with inline reply; Focus Assist sync",
"Taskbar call controls, jump list and upload progress; tray with call status",
"Stays awake during calls, reconnects on network changes, updates itself"
]
},
{
"title": "Rooms &amp; Moderation",
"items": [
"Knock-to-join with an admin approve/deny panel and live pending-count badge",
"Invite links with QR codes, favourites, room filter, room history export (txt/json/html)",
"Room activity &amp; mod log, stats panel, server ACL editor, policy-list viewer",
"Room widgets, with a permission prompt before a widget may read or send in the room"
]
}
]
+238 -139
View File
File diff suppressed because one or more lines are too long
+520
View File
@@ -0,0 +1,520 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0a0a;
color: #e0e0e0;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px 16px 40px;
}
body::before {
content: '';
position: fixed;
top: 50%;
left: 50%;
width: 900px;
height: 900px;
transform: translate(-50%, -50%);
background: radial-gradient(circle, rgba(152, 0, 0, 0.07) 0%, transparent 65%);
pointer-events: none;
z-index: 0;
}
.container {
position: relative;
z-index: 1;
text-align: center;
width: 100%;
max-width: 900px;
}
.logo {
width: 140px;
height: 140px;
margin: 0 auto 28px;
border-radius: 50%;
filter: drop-shadow(0 0 24px rgba(152, 0, 0, 0.35));
animation: float 6s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
h1 {
font-size: 2rem;
font-weight: 300;
letter-spacing: 0.15em;
text-transform: uppercase;
color: #fff;
margin-bottom: 4px;
}
h1 span { color: #980000; font-weight: 600; }
.subtitle {
font-size: 0.85rem;
color: #555;
letter-spacing: 0.3em;
text-transform: uppercase;
margin-bottom: 36px;
}
/* ─── Cards / Panels ─── */
.card {
background: linear-gradient(145deg, rgba(22,22,22,0.95), rgba(14,14,14,0.98));
border: 1px solid rgba(152,0,0,0.2);
border-radius: 16px;
padding: 32px;
max-width: 560px;
margin: 0 auto;
}
.card h2 {
font-size: 0.9rem;
font-weight: 500;
color: #980000;
text-transform: uppercase;
letter-spacing: 0.15em;
margin-bottom: 20px;
}
/* ─── Steps ─── */
.steps { list-style: none; text-align: left; margin-bottom: 28px; }
.steps li {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 12px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.steps li:last-child { border-bottom: none; }
.step-num {
flex-shrink: 0;
width: 28px; height: 28px;
background: rgba(152,0,0,0.12);
border: 1px solid rgba(152,0,0,0.35);
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 0.8rem; font-weight: 600; color: #c44;
}
.step-text { padding-top: 3px; font-size: 0.95rem; line-height: 1.5; color: #bbb; }
.step-text strong { color: #e0e0e0; }
.homeserver {
display: inline-block;
background: rgba(152,0,0,0.1);
border: 1px solid rgba(152,0,0,0.25);
color: #e88;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 0.85rem;
padding: 3px 10px;
border-radius: 6px;
}
a { color: #c44; text-decoration: none; }
.step-text a, .option-block a {
border-bottom: 1px solid rgba(204,68,68,0.3);
transition: border-color 0.2s;
}
.step-text a:hover, .option-block a:hover { border-bottom-color: #c44; }
/* ─── Or divider ─── */
.or-divider {
display: flex; align-items: center; gap: 10px;
margin: 4px 0 10px 42px;
color: #444; font-size: 0.78rem; letter-spacing: 0.1em; text-transform: uppercase;
}
.or-divider::before, .or-divider::after {
content: ''; flex: 1; height: 1px; background: rgba(255,255,255,0.06);
}
.option-block {
margin-left: 42px;
padding: 12px 14px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 8px;
text-align: left; font-size: 0.88rem; color: #888; line-height: 1.5;
}
.divider { height: 1px; background: rgba(152,0,0,0.15); margin: 24px 0; }
/* ─── Tags ─── */
.tag {
font-size: 0.65rem;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.1);
padding: 2px 8px; border-radius: 4px;
text-transform: uppercase; letter-spacing: 0.05em; color: #bbb;
white-space: nowrap;
}
.tag.voice { background: rgba(0,180,120,0.2); border-color: rgba(0,180,120,0.4); color: #5effc4; }
.tag.beta { background: rgba(255,180,0,0.15); border-color: rgba(255,180,0,0.3); color: #ffcc55; }
.tag.dev { background: rgba(160,80,255,0.15); border-color: rgba(160,80,255,0.3); color: #cc88ff; }
.tag.rust { background: rgba(80,140,255,0.15); border-color: rgba(80,140,255,0.3); color: #88aaff; }
.tag.warn { background: rgba(255,140,0,0.15); border-color: rgba(255,140,0,0.3); color: #ffaa44; }
.tag.dim { background: rgba(255,255,255,0.04); border-color: rgba(255,255,255,0.08); color: #666; }
/* ─── Featured client ─── */
.client-featured { margin-bottom: 16px; }
.client-featured a {
display: flex; flex-direction: column; align-items: center; gap: 6px;
background: linear-gradient(135deg, rgba(152,0,0,0.25), rgba(120,0,0,0.15));
border: 1px solid rgba(152,0,0,0.55);
color: #fff; text-decoration: none;
padding: 18px 24px; border-radius: 12px;
transition: all 0.25s ease;
box-shadow: 0 0 20px rgba(152,0,0,0.1);
}
.client-featured a:hover {
background: linear-gradient(135deg, rgba(152,0,0,0.38), rgba(120,0,0,0.25));
border-color: rgba(152,0,0,0.8);
box-shadow: 0 0 32px rgba(152,0,0,0.25);
transform: translateY(-2px);
}
.client-name { font-size: 1.15rem; font-weight: 600; letter-spacing: 0.05em; }
.client-desc { font-size: 0.82rem; color: #ccc; }
.tag-row { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; margin-top: 2px; }
/* Also-available note */
.also-available {
font-size: 0.78rem; color: #555; margin-top: 8px; line-height: 1.6;
}
.also-available a { color: #666; border-bottom: 1px solid rgba(102,102,102,0.3); transition: color 0.2s; }
.also-available a:hover { color: #999; }
/* ─── Space join ─── */
.space-join {
margin-top: 20px; padding: 16px 20px;
background: rgba(152,0,0,0.06);
border: 1px solid rgba(152,0,0,0.2); border-radius: 10px;
}
.space-join p { font-size: 0.82rem; color: #666; margin-bottom: 10px; }
.space-join a {
display: inline-block;
background: rgba(152,0,0,0.15);
border: 1px solid rgba(152,0,0,0.35);
color: #c44; text-decoration: none;
padding: 8px 20px; border-radius: 8px; font-size: 0.88rem;
transition: all 0.25s ease;
}
.space-join a:hover { background: rgba(152,0,0,0.25); color: #fff; }
/* ─── Secondary client cards ─── */
.clients-section h3 {
font-size: 0.8rem; font-weight: 500; color: #666;
text-transform: uppercase; letter-spacing: 0.12em; margin-bottom: 14px;
}
.client-group { margin-bottom: 18px; }
.client-group-label {
font-size: 0.75rem; color: #555;
text-transform: uppercase; letter-spacing: 0.1em;
margin-bottom: 8px;
}
.client-cards { display: flex; flex-direction: column; gap: 8px; }
.client-card {
display: flex; flex-direction: column; gap: 6px;
background: rgba(255,255,255,0.025);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 10px; padding: 12px 14px; text-align: left;
transition: border-color 0.2s;
}
.client-card:hover { border-color: rgba(152,0,0,0.3); }
.client-card-top {
display: flex; align-items: center;
justify-content: space-between; gap: 10px; flex-wrap: wrap;
}
.client-card-name {
font-size: 0.95rem; font-weight: 600; color: #e0e0e0;
text-decoration: none;
border-bottom: 1px solid rgba(204,68,68,0.2);
transition: color 0.2s, border-color 0.2s;
}
.client-card-name:hover { color: #c44; border-bottom-color: #c44; }
.client-card-tags { display: flex; gap: 5px; flex-wrap: wrap; }
.client-card-desc { font-size: 0.8rem; color: #666; line-height: 1.45; }
/* ─── Comparison table ─── */
.comparison-section {
margin-top: 32px;
background: linear-gradient(145deg, rgba(22,22,22,0.95), rgba(14,14,14,0.98));
border: 1px solid rgba(152,0,0,0.2);
border-radius: 16px;
overflow: hidden;
}
.comparison-title {
font-size: 0.78rem; font-weight: 500; color: #980000;
text-transform: uppercase; letter-spacing: 0.15em;
padding: 16px 20px 12px;
border-bottom: 1px solid rgba(152,0,0,0.1);
}
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
table {
width: 100%; border-collapse: collapse;
font-size: 0.78rem; min-width: 760px;
}
thead tr { border-bottom: 1px solid rgba(152,0,0,0.15); }
th {
padding: 10px 8px; text-align: center;
font-size: 0.7rem; font-weight: 600;
color: #888; text-transform: uppercase; letter-spacing: 0.08em;
background: rgba(255,255,255,0.02);
}
th:first-child { text-align: left; padding-left: 16px; min-width: 140px; }
th.ours { color: #c66; }
th small { display: block; font-size: 0.6rem; font-weight: 400; color: #555; margin-top: 2px; text-transform: none; letter-spacing: 0; }
tr { border-bottom: 1px solid rgba(255,255,255,0.03); }
tr:last-child { border-bottom: none; }
tr.section-header td {
background: rgba(152,0,0,0.06);
color: #770000; font-weight: 600;
font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.12em;
padding: 6px 8px 5px 16px; text-align: left;
}
td {
padding: 8px 8px; text-align: center; vertical-align: middle;
color: #888; line-height: 1.3;
}
td:first-child {
text-align: left; padding-left: 16px;
color: #aaa; font-size: 0.78rem;
}
td small { display: block; font-size: 0.68rem; color: #555; margin-top: 1px; }
.yes { color: #5effc4; font-size: 1rem; }
.part { color: #ffcc55; font-size: 0.9rem; }
.no { color: #444; font-size: 1rem; }
/* highlight our hosted client column */
th.ours, td.ours { background: rgba(152,0,0,0.04); }
/* ─── Legend ─── */
.legend {
display: flex; gap: 18px; justify-content: center; flex-wrap: wrap;
padding: 10px 16px 14px;
border-top: 1px solid rgba(255,255,255,0.04);
font-size: 0.72rem; color: #555;
}
.legend span { display: flex; align-items: center; gap: 5px; }
/* ─── Security note ─── */
.security-note {
margin: 0 20px 16px;
padding: 10px 14px;
background: rgba(80,140,255,0.04);
border: 1px solid rgba(80,140,255,0.12);
border-radius: 8px;
font-size: 0.76rem; color: #556; line-height: 1.55; text-align: left;
}
.security-note strong { color: #88aaff; }
.all-clients { margin-top: 14px; }
.all-clients a {
font-size: 0.78rem; color: #555;
border-bottom: 1px solid rgba(85,85,85,0.3);
transition: color 0.2s, border-color 0.2s;
}
.all-clients a:hover { color: #888; border-bottom-color: #888; }
/* ─── Server info ─── */
.server-info {
margin-top: 24px;
background: linear-gradient(145deg, rgba(22,22,22,0.95), rgba(14,14,14,0.98));
border: 1px solid rgba(152,0,0,0.2);
border-radius: 12px; overflow: hidden;
}
.server-info-title {
font-size: 0.78rem; font-weight: 500; color: #980000;
text-transform: uppercase; letter-spacing: 0.15em;
padding: 14px 20px 10px;
border-bottom: 1px solid rgba(152,0,0,0.1);
}
.info-grid { display: grid; grid-template-columns: 1fr 1fr; }
.info-item {
padding: 12px 18px;
border-bottom: 1px solid rgba(255,255,255,0.03);
border-right: 1px solid rgba(255,255,255,0.03);
text-align: left;
}
.info-item:nth-child(even) { border-right: none; }
.info-item:nth-last-child(-n+2) { border-bottom: none; }
.info-label { font-size: 0.7rem; color: #555; text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 3px; }
.info-value { font-size: 0.88rem; color: #ccc; }
.privacy-strip {
padding: 10px 18px;
border-top: 1px solid rgba(152,0,0,0.1);
display: flex; gap: 18px; justify-content: center; flex-wrap: wrap;
}
.privacy-badge { font-size: 0.75rem; color: #5effc4; display: flex; align-items: center; gap: 5px; }
.privacy-badge::before { content: '✓'; font-weight: 700; }
/* ─── Legal / contact / footer ─── */
.legal-note {
margin-top: 24px; padding: 12px 18px;
background: rgba(255,255,255,0.02);
border: 1px solid rgba(255,255,255,0.05);
border-radius: 8px; font-size: 0.75rem; color: #444;
line-height: 1.6; text-align: left;
}
.legal-note a { color: #555; border-bottom: 1px solid rgba(85,85,85,0.3); }
.legal-note a:hover { color: #888; }
.contact {
margin-top: 24px; padding: 16px;
background: linear-gradient(145deg, rgba(22,22,22,0.95), rgba(14,14,14,0.98));
border: 1px solid rgba(152,0,0,0.2);
border-radius: 12px; text-align: center;
}
.footer {
margin-top: 20px; font-size: 0.72rem; color: #383838;
letter-spacing: 0.04em;
display: flex; justify-content: center; gap: 14px; flex-wrap: wrap;
}
.footer a { color: #444; transition: color 0.2s; }
.footer a:hover { color: #777; }
/* ─── Sticky first table column (all screen sizes) ─── */
td:first-child {
position: sticky;
left: 0;
z-index: 1;
background: #0d0d0d;
}
th:first-child {
position: sticky;
left: 0;
z-index: 2;
background: #111;
}
tr.section-header td {
/* section headers span full width — override sticky bg */
position: static;
background: rgba(152,0,0,0.06);
}
/* Scroll hint — visible only on mobile via JS class */
.scroll-hint {
display: none;
font-size: 0.72rem;
color: #3a3a3a;
padding: 0 16px 10px;
letter-spacing: 0.05em;
}
/* ─── Tablet (≤ 700px) ─── */
@media (max-width: 700px) {
body { align-items: flex-start; }
}
/* ─── Mobile (≤ 540px) ─── */
@media (max-width: 540px) {
body { padding: 20px 12px 40px; }
.logo { width: 100px; height: 100px; margin-bottom: 20px; }
h1 { font-size: 1.5rem; letter-spacing: 0.1em; }
.subtitle { font-size: 0.78rem; letter-spacing: 0.18em; margin-bottom: 26px; }
.card { padding: 22px 16px; }
.step-text { font-size: 0.88rem; }
.homeserver { font-size: 0.75rem; word-break: break-all; }
.or-divider, .option-block { margin-left: 0; }
.client-card-top { flex-direction: column; align-items: flex-start; gap: 6px; }
.client-card-desc { font-size: 0.79rem; }
.client-card-tags { gap: 4px; }
.also-available { font-size: 0.75rem; }
/* Table */
.scroll-hint { display: block; }
table { font-size: 0.71rem; min-width: 620px; }
th { padding: 8px 5px; font-size: 0.6rem; }
th:first-child { min-width: 100px; padding-left: 10px; }
td { padding: 7px 5px; }
td:first-child { font-size: 0.71rem; padding-left: 10px; }
td small, th small { font-size: 0.58rem; }
.yes { font-size: 0.88rem; }
.part { font-size: 0.82rem; }
.no { font-size: 0.88rem; }
.comparison-title { font-size: 0.7rem; padding: 12px 14px 8px; letter-spacing: 0.1em; }
.security-note { margin: 10px 12px 4px; font-size: 0.71rem; }
.legend { padding: 8px 12px 12px; gap: 12px; font-size: 0.68rem; }
/* Server info */
.server-info-title { font-size: 0.7rem; padding: 12px 14px 8px; }
.info-item { padding: 10px 14px; }
.info-label { font-size: 0.65rem; }
.info-value { font-size: 0.82rem; }
.privacy-strip { flex-direction: column; align-items: center; gap: 8px; padding: 10px 14px; }
.privacy-badge { font-size: 0.71rem; }
/* Legal / footer */
.legal-note { font-size: 0.72rem; padding: 11px 14px; }
.contact { padding: 14px; }
.footer { gap: 8px; font-size: 0.68rem; }
}
/* ─── Very small (≤ 380px) ─── */
@media (max-width: 380px) {
h1 { font-size: 1.3rem; }
.logo { width: 84px; height: 84px; }
.card { padding: 18px 12px; }
table { font-size: 0.66rem; min-width: 580px; }
th { font-size: 0.55rem; padding: 7px 4px; }
td { padding: 6px 4px; }
td:first-child { font-size: 0.66rem; min-width: 90px; }
}
/* ── Fork feature groups (matrix #11) ── */
.fork-intro { font-size: 0.82rem; color: #888; margin: 14px 0 8px; line-height: 1.5; }
.fork-features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
margin: 0 0 12px;
}
.fork-group {
background: rgba(255,255,255,0.02);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 8px;
padding: 10px 12px;
}
.fork-group { text-align: left; }
.fork-group h4 { text-align: center; margin: 0 0 6px; font-size: 0.8rem; color: #c66; letter-spacing: 0.02em; }
.fork-group ul { margin: 0; padding-left: 16px; }
.fork-group li { font-size: 0.74rem; color: #999; line-height: 1.45; margin: 3px 0; }
.fork-more { font-size: 0.76rem; color: #777; margin: 0 0 6px; }
.fork-more a { color: #888; border-bottom: 1px solid rgba(136,136,136,0.3); }
.hs-inline { font-size: 0.8em; color: #e88; }
.reviewed { font-size: 0.7rem; color: #666; text-align: center; margin: -4px 0 10px; }
+27
View File
@@ -362,3 +362,30 @@ class TestRoomStateParsing(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
class NormalizePermissionTest(unittest.TestCase):
"""livekit-server's JSON uses proto names (snake_case); the reconciler must
not read camelCase and conclude nobody publishes (which silently disabled
the live screenshare kill)."""
def test_snake_case_permission_is_reconciled(self):
calls = []
guard.livekit_update_participant = lambda alias, identity, perm: calls.append((identity, perm))
participant = {
"identity": "@a:x:DEV",
"permission": {"can_subscribe": True, "can_publish": True, "can_publish_data": True, "can_publish_sources": []},
}
changed = guard.reconcile_participant("!r:x", participant, {"SCREEN_SHARE", "SCREEN_SHARE_AUDIO"})
self.assertTrue(changed)
identity, perm = calls[0]
self.assertEqual(identity, "@a:x:DEV")
self.assertTrue(perm["canPublish"])
self.assertNotIn("SCREEN_SHARE", perm["canPublishSources"])
self.assertIn("MICROPHONE", perm["canPublishSources"])
self.assertTrue(perm["canSubscribe"]) # preserved
def test_camel_case_still_works(self):
guard.livekit_update_participant = lambda *a: None
participant = {"identity": "@a:x:DEV", "permission": {"canPublish": True, "canPublishSources": ["CAMERA"]}}
self.assertFalse(guard.reconcile_participant("!r:x", participant, {"SCREEN_SHARE"}))
+18 -1
View File
@@ -399,10 +399,27 @@ def reconcile_publish_sources(current, forbidden: set):
return sorted(effective - forbidden)
def _camel(key: str) -> str:
head, *rest = key.split("_")
return head + "".join(part.capitalize() for part in rest)
def normalize_permission(perm: dict) -> dict:
"""LiveKit's Twirp JSON serialises ParticipantPermission with proto field
names (`can_publish`, `can_publish_sources`, ...) — verified against
livekit-server 1.13 — while the JWT grant and older docs use camelCase.
Return a camelCase copy so the policy code reads one shape. (protojson
accepts either spelling on input, so the copy we send back is fine.)"""
out = {}
for key, value in (perm or {}).items():
out[_camel(key) if "_" in key else key] = value
return out
def reconcile_participant(alias: str, participant: dict, forbidden: set) -> bool:
"""Enforce the forbidden-source policy on one live participant. Returns True
if an UpdateParticipant call was issued."""
perm = participant.get("permission") or {}
perm = normalize_permission(participant.get("permission") or {})
if not perm.get("canPublish", False):
return False # publishes nothing -> nothing to revoke
current = perm.get("canPublishSources") or []