Author SHA1 Message Date
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
18 changed files with 3004 additions and 64 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
+17 -4
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 has been unreachable since May (webhook bound to `127.0.0.1:9000`), so edits to these files in the repo are **not** auto-deployed there. Apply them by hand (`nginx -t` then `nginx -s reload`) and keep the repo copy in sync.
---
@@ -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 |
+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
+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;
+12
View File
@@ -0,0 +1,12 @@
# 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.
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;
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://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;
+30 -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;
+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"
}
}
}
]
}
}
]
+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, macOS and Linux (Tauri)",
"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"
]
}
]
+113 -20
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 []