Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
039b74c2b9 | ||
|
|
0cb0f91e43 | ||
|
|
09562061a4 | ||
|
|
3a15bd89c0 | ||
|
|
6bb90b7e1d | ||
|
|
4d7a510e06 | ||
|
|
31b3cf63c6 | ||
|
|
2ca59be9fc | ||
|
|
2137c37c8b | ||
|
|
bd033baf19 |
@@ -1,2 +1,6 @@
|
||||
node_modules/
|
||||
.git/
|
||||
dist/
|
||||
experiment/
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
@@ -57,6 +57,16 @@ jobs:
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
|
||||
# ── #99 — lockfile.yml (GitHub-only, deleted) commented on lockfile
|
||||
# diffs after the fact; this is the CI-native equivalent, ported as a
|
||||
# hard gate. `npm ci` already refuses to run on an out-of-range
|
||||
# mismatch, but it can silently normalize lesser lockfile drift (e.g.
|
||||
# metadata/resolved fields behind a stale-but-satisfiable range)
|
||||
# without failing — so assert zero diff afterward, which is the
|
||||
# cheapest way to also catch that class of drift.
|
||||
- name: Verify lockfile is in sync
|
||||
run: git diff --exit-code package-lock.json
|
||||
|
||||
# ── Quality gates run BEFORE the slow build so a format/lint/type/test
|
||||
# error fails in seconds instead of after the ~minutes-long build. All are
|
||||
# hard gates — any failure fails the job and blocks the deploy. The tree is
|
||||
@@ -142,3 +152,140 @@ jobs:
|
||||
git push origin main
|
||||
echo "Pushed — cinny-desktop release.yml will start via on:push trigger"
|
||||
fi
|
||||
|
||||
# ── #95 — secret scanning ────────────────────────────────────────────────
|
||||
# zricethezav/gitleaks-action is GitHub-Actions-only; on the Gitea act_runner
|
||||
# we can't assume the host has a Docker daemon reachable from job containers
|
||||
# (see the `docker` job below), so this downloads the pinned linux/amd64
|
||||
# binary release directly instead. `--no-git` scans the checked-out tree as
|
||||
# plain files (a point-in-time content scan) rather than walking history,
|
||||
# since this runs on both push and pull_request and a PR's shallow checkout
|
||||
# doesn't carry full history anyway.
|
||||
gitleaks:
|
||||
name: Secret scan (gitleaks)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install gitleaks 8.30.1
|
||||
run: |
|
||||
curl -fsSL -o gitleaks.tar.gz \
|
||||
https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz
|
||||
tar -xzf gitleaks.tar.gz gitleaks
|
||||
chmod +x gitleaks
|
||||
|
||||
- name: Scan for secrets
|
||||
run: ./gitleaks detect --no-git -v --redact --source . --config .gitleaks.toml
|
||||
|
||||
# ── #93 — the image was never actually built in CI, so a Dockerfile break
|
||||
# (or a header regression, once #95's nginx CSP shipped) could sit unnoticed
|
||||
# until a manual `docker build` on deploy infra caught it. This builds the
|
||||
# real image, boots it, and asserts both a 200 and the security headers
|
||||
# added to docker-nginx.conf for #95.
|
||||
#
|
||||
# `continue-on-error: true` — informational for now. The shared act_runner
|
||||
# may not expose a Docker daemon to job containers (same class of problem
|
||||
# as the unreachable cache server noted above); flip this off once it's
|
||||
# confirmed the runner can actually run `docker build`/`docker run` here.
|
||||
docker:
|
||||
name: Docker image build & smoke test
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build image
|
||||
run: docker build -t cinny-ci .
|
||||
|
||||
- name: Run container
|
||||
run: docker run -d --name cinny-ci -p 8095:80 cinny-ci
|
||||
|
||||
- name: Wait for container to be ready
|
||||
run: |
|
||||
for i in $(seq 1 30); do
|
||||
curl -fsS -o /dev/null http://localhost:8095/ && exit 0
|
||||
sleep 1
|
||||
done
|
||||
echo "container never became ready" >&2
|
||||
exit 1
|
||||
|
||||
- name: Check response and security headers
|
||||
run: |
|
||||
headers="$(curl -fsSI http://localhost:8095/)"
|
||||
echo "$headers"
|
||||
echo "$headers" | grep -qi '^HTTP/[0-9.]* 200' || { echo "expected HTTP 200"; exit 1; }
|
||||
echo "$headers" | grep -qi '^content-security-policy:' || { echo "missing Content-Security-Policy header"; exit 1; }
|
||||
echo "$headers" | grep -qi "frame-ancestors 'none'" || { echo "CSP missing frame-ancestors 'none'"; exit 1; }
|
||||
echo "$headers" | grep -qi '^referrer-policy: *no-referrer' || { echo "missing Referrer-Policy header"; exit 1; }
|
||||
echo "$headers" | grep -qi '^x-content-type-options: *nosniff' || { echo "missing X-Content-Type-Options header"; exit 1; }
|
||||
|
||||
- name: Stop container
|
||||
if: always()
|
||||
run: docker rm -f cinny-ci || true
|
||||
|
||||
# ── #90 — Playwright smoke test ──────────────────────────────────────────
|
||||
# Boots the built client in a real (headless) Chromium and drives it. Two
|
||||
# tiers live under e2e/ (see LOTUS_TESTING.md → "Playwright smoke test"):
|
||||
# boot tier — always runs: login page renders with no console/page
|
||||
# errors, sw.js is served + registers, bundled Element Call
|
||||
# mounts in a frame.
|
||||
# E2EE tier — password login, create a private encrypted room, send text
|
||||
# + a compressed image, assert every `PUT …/send/*` went out
|
||||
# as m.room.encrypted. Skips itself unless the E2E_* secrets
|
||||
# below are set (create them under repo → Settings → Actions
|
||||
# → Secrets; they are empty until then).
|
||||
# dist/ is rebuilt in-job because actions/upload-artifact@v4 does not work
|
||||
# on this Gitea runner (LOTUS_TODO), so `needs: build` only gates on the
|
||||
# main job having passed, not on its artifact.
|
||||
# continue-on-error: `playwright install --with-deps` needs apt on the
|
||||
# runner image; promote to hard once green on the runner.
|
||||
e2e:
|
||||
name: Playwright smoke (e2e)
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm config set fetch-retries 5
|
||||
npm config set fetch-retry-mintimeout 20000
|
||||
npm config set fetch-retry-maxtimeout 120000
|
||||
npm config set fetch-timeout 600000
|
||||
for attempt in 1 2 3; do
|
||||
echo "npm ci attempt $attempt…"
|
||||
npm ci && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
echo "npm ci failed after 3 attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "npm ci failed; retrying in $((attempt * 15))s…" >&2
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=6144'
|
||||
VITE_APP_VERSION: ${{ github.sha }}
|
||||
|
||||
- name: Playwright smoke test
|
||||
run: npm run test:e2e
|
||||
env:
|
||||
CI: 'true'
|
||||
E2E_HOMESERVER: ${{ secrets.E2E_HOMESERVER }}
|
||||
E2E_USER: ${{ secrets.E2E_USER }}
|
||||
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Renovate
|
||||
|
||||
# Gitea #94 — no dependency update automation existed at all. Runs the
|
||||
# official renovate/renovate Docker image against this Gitea instance.
|
||||
#
|
||||
# Requires a `RENOVATE_TOKEN` repo/org secret: a Gitea access token with
|
||||
# read/write on LotusGuild/cinny and LotusGuild/element-call, created by a
|
||||
# maintainer — this workflow does not (and cannot) create one for you.
|
||||
# Note: Gitea reserves the `GITEA_` secret-name prefix, so the token cannot
|
||||
# be named e.g. `GITEA_TOKEN` — hence `RENOVATE_TOKEN`.
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 4 * * 1' # weekly, Monday 04:00 UTC
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
name: Renovate
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # informational until RENOVATE_TOKEN is confirmed present
|
||||
steps:
|
||||
- name: Run Renovate
|
||||
uses: docker://renovate/renovate:44
|
||||
env:
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
RENOVATE_PLATFORM: gitea
|
||||
RENOVATE_ENDPOINT: https://code.lotusguild.org/api/v1
|
||||
RENOVATE_REPOSITORIES: LotusGuild/cinny,LotusGuild/element-call
|
||||
@@ -1,40 +0,0 @@
|
||||
name: Build pull request
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: ['opened', 'synchronize']
|
||||
|
||||
jobs:
|
||||
build-pull-request:
|
||||
name: Build pull request
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PR_NUMBER: ${{github.event.number}}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
package-manager-cache: false
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build app
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
run: npm run build
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: preview
|
||||
path: dist
|
||||
retention-days: 1
|
||||
- name: Save pr number
|
||||
run: echo ${PR_NUMBER} > ./pr.txt
|
||||
- name: Upload pr number
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: pr
|
||||
path: ./pr.txt
|
||||
retention-days: 1
|
||||
@@ -1,36 +0,0 @@
|
||||
name: 'CLA Assistant'
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [opened, closed, synchronize]
|
||||
|
||||
jobs:
|
||||
CLAssistant:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'CLA Assistant'
|
||||
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
|
||||
# Beta Release
|
||||
uses: cla-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# the below token should have repo scope and must be manually added by you in the repository's secret
|
||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_PAT }}
|
||||
with:
|
||||
path-to-signatures: 'signatures.json'
|
||||
path-to-document: 'https://github.com/cinnyapp/cla/blob/main/cla.md' # e.g. a CLA or a DCO document
|
||||
# branch should not be protected
|
||||
branch: 'main'
|
||||
allowlist: ajbura,bot*
|
||||
|
||||
#below are the optional inputs - If the optional inputs are not given, then default values will be taken
|
||||
remote-organization-name: cinnyapp
|
||||
remote-repository-name: cla
|
||||
#create-file-commit-message: 'For example: Creating file for storing CLA Signatures'
|
||||
#signed-commit-message: 'For example: $contributorName has signed the CLA in #$pullRequestNo'
|
||||
#custom-notsigned-prcomment: 'pull request comment with Introductory message to ask new contributors to sign'
|
||||
#custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA'
|
||||
#custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.'
|
||||
#lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true)
|
||||
#use-dco-flag: true - If you are using DCO instead of CLA
|
||||
@@ -1,63 +0,0 @@
|
||||
name: Deploy PR to Netlify
|
||||
run-name: 'Deploy PR to Netlify (${{ github.event.workflow_run.head_branch }})'
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['Build pull request']
|
||||
types: [completed]
|
||||
|
||||
jobs:
|
||||
deploy-pull-request:
|
||||
name: Deploy pull request
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
- name: Download pr number
|
||||
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
|
||||
with:
|
||||
workflow: ${{ github.event.workflow.id }}
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
name: pr
|
||||
- name: Validate and output pr number
|
||||
id: pr
|
||||
run: |
|
||||
PR_ID=$(<pr.txt)
|
||||
if ! [[ "${PR_ID}" =~ ^[0-9]+$ ]]; then
|
||||
echo "::error::pr.txt contains non-numeric content: ${PR_ID}"
|
||||
exit 1
|
||||
fi
|
||||
echo "id=${PR_ID}" >> "${GITHUB_OUTPUT}"
|
||||
- name: Download artifact
|
||||
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
|
||||
with:
|
||||
workflow: ${{ github.event.workflow.id }}
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
name: preview
|
||||
path: dist
|
||||
- name: Deploy to Netlify
|
||||
id: netlify
|
||||
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0.0
|
||||
with:
|
||||
publish-dir: dist
|
||||
deploy-message: 'Deploy PR ${{ steps.pr.outputs.id }}'
|
||||
alias: ${{ steps.pr.outputs.id }}
|
||||
# These don't work because we're in workflow_run
|
||||
enable-pull-request-comment: false
|
||||
enable-commit-comment: false
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN_PR }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_PR_CINNY }}
|
||||
timeout-minutes: 1
|
||||
- name: Comment preview on PR
|
||||
uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b #v3.0.1
|
||||
env:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
pr-number: ${{ steps.pr.outputs.id }}
|
||||
comment-tag: ${{ steps.pr.outputs.id }}
|
||||
message: |
|
||||
Preview: ${{ steps.netlify.outputs.deploy-url }}
|
||||
⚠️ Exercise caution. Use test accounts. ⚠️
|
||||
@@ -1,63 +0,0 @@
|
||||
name: 'Docker check'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'Dockerfile'
|
||||
- '.github/workflows/docker-pr.yml'
|
||||
- '.github/workflows/prod-deploy.yml'
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Login to Docker Hub #Do not update this action from a outside PR
|
||||
if: github.event.pull_request.head.repo.fork == false
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
continue-on-error: true
|
||||
|
||||
- name: Login to the Github Container registry #Do not update this action from a outside PR
|
||||
if: github.event.pull_request.head.repo.fork == false
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
continue-on-error: true
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker, GHCR
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
ajbura/cinny
|
||||
ghcr.io/${{ github.repository }}
|
||||
|
||||
- name: Build Docker image (no push)
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Show Docker images
|
||||
run: docker images
|
||||
@@ -1,26 +0,0 @@
|
||||
name: NPM Lockfile Changes
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'package-lock.json'
|
||||
|
||||
jobs:
|
||||
lockfile_changes:
|
||||
runs-on: ubuntu-latest
|
||||
# Permission overwrite is required for Dependabot PRs, see "Common issues" below.
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: NPM Lockfile Changes
|
||||
uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891 # v1.0.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Optional inputs, can be deleted safely if you are happy with default values.
|
||||
collapsibleThreshold: 25
|
||||
failOnDowngrade: false
|
||||
path: package-lock.json
|
||||
updateComment: true
|
||||
@@ -1,39 +0,0 @@
|
||||
name: Deploy to Netlify (dev)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
deploy-to-netlify:
|
||||
name: Deploy to Netlify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
package-manager-cache: false
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build app
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
run: npm run build
|
||||
- name: Deploy to Netlify
|
||||
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0.0
|
||||
with:
|
||||
publish-dir: dist
|
||||
deploy-message: 'Dev deploy ${{ github.sha }}'
|
||||
enable-commit-comment: false
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
production-deploy: true
|
||||
github-deployment-environment: nightly
|
||||
github-deployment-description: 'Nightly deployment on each commit to dev branch'
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_DEV }}
|
||||
timeout-minutes: 1
|
||||
@@ -1,15 +0,0 @@
|
||||
name: Check PR title
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,99 +0,0 @@
|
||||
name: Production deploy
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
deploy-and-tarball:
|
||||
name: Netlify deploy and tarball
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
package-manager-cache: false
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build app
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
run: npm run build
|
||||
- name: Deploy to Netlify
|
||||
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0.0
|
||||
with:
|
||||
publish-dir: dist
|
||||
deploy-message: 'Prod deploy ${{ github.ref_name }}'
|
||||
enable-commit-comment: false
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
production-deploy: true
|
||||
github-deployment-environment: stable
|
||||
github-deployment-description: 'Stable deployment on each release'
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_APP }}
|
||||
timeout-minutes: 1
|
||||
- name: Get version from tag
|
||||
id: vars
|
||||
run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT
|
||||
- name: Create tar.gz
|
||||
run: tar -czvf cinny-${{ steps.vars.outputs.tag }}.tar.gz dist
|
||||
- name: Sign tar.gz
|
||||
run: |
|
||||
echo '${{ secrets.GNUPG_KEY }}' | gpg --batch --import
|
||||
# Sadly a few lines in the private key match a few lines in the public key,
|
||||
# As a result just --export --armor gives us a few lines replaced with ***
|
||||
# making it useless for importing the signing key. Instead, we dump it as
|
||||
# non-armored and hex-encode it so that its printable.
|
||||
echo "PGP Signing key, in raw PGP format in hex. Import with cat ... | xxd -r -p - | gpg --import"
|
||||
gpg --export | xxd -p
|
||||
echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
||||
- name: Upload tagged release
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
files: |
|
||||
cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
||||
cinny-${{ steps.vars.outputs.tag }}.tar.gz.asc
|
||||
|
||||
publish-image:
|
||||
name: Push Docker image to Docker Hub, GHCR
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
- name: Login to Docker Hub #Do not update this action from a outside PR
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Login to the Github Container registry #Do not update this action from a outside PR
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker, GHCR
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_USERNAME }}/cinny
|
||||
ghcr.io/${{ github.repository }}
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -6,3 +6,7 @@ devAssets
|
||||
.DS_Store
|
||||
.ideapackage-lock.json
|
||||
public/decorations/
|
||||
|
||||
# Playwright (npm run test:e2e)
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
title = "gitleaks config for Lotus Chat (cinny fork)"
|
||||
|
||||
# Gitea #95 — secret scanning was entirely absent. Extend gitleaks' built-in
|
||||
# ruleset (don't replace it) and allowlist the known-public infrastructure
|
||||
# URLs that show up in tracked config, which are hostnames, not secrets.
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
[allowlist]
|
||||
description = "Known-public Lotus/Matrix homeserver + npm registry URLs — not secrets"
|
||||
regexes = [
|
||||
'''https?://matrix\.lotusguild\.org''',
|
||||
'''https?://code\.lotusguild\.org/api/packages/LotusGuild/npm/''',
|
||||
'''matrix\.lotusguild\.org''',
|
||||
]
|
||||
paths = [
|
||||
'''config\.json''',
|
||||
'''\.npmrc''',
|
||||
]
|
||||
+19
-2
@@ -26,8 +26,9 @@ Last updated: July 2026.
|
||||
17. [Notifications](#notifications)
|
||||
18. [Server Integration](#server-integration)
|
||||
19. [Infrastructure](#infrastructure)
|
||||
20. [Desktop App Features](#desktop-app-features)
|
||||
21. [Key Custom Files](#key-custom-files)
|
||||
20. [Localization](#localization)
|
||||
21. [Desktop App Features](#desktop-app-features)
|
||||
22. [Key Custom Files](#key-custom-files)
|
||||
|
||||
---
|
||||
|
||||
@@ -1408,6 +1409,22 @@ The session persists as ONE atomic `cinny_session_v1` JSON write (previously ~10
|
||||
|
||||
---
|
||||
|
||||
## Localization
|
||||
|
||||
Lotus Chat is **English-only for now**, by explicit decision (Sept 2026 audit, #53). The i18next
|
||||
mechanism (`i18next-browser-languagedetector` + `public/locales/{en,de}.json`, wired in
|
||||
`src/app/i18n.ts`) is real and still used by the ~11 upstream-inherited files that call
|
||||
`useTranslation()`, but none of the Lotus-added UI (presence picker, calls/soundboard, avatar
|
||||
decorations, seasonal settings, keyboard shortcuts help, toasts, etc.) is routed through it. Letting
|
||||
the language detector pick a non-English browser locale therefore produced a UI that was only
|
||||
partially translated. `src/app/i18n.ts` now sets `supportedLngs: ['en']` so the whole app renders
|
||||
consistently in English regardless of browser locale, while leaving the detector/backend/`de.json`
|
||||
in place. Re-enabling another language requires two things: (1) route Lotus strings through
|
||||
`useTranslation()`/`public/locales/<lng>.json` like the existing localized files, then (2) drop (or
|
||||
extend) `supportedLngs` in `src/app/i18n.ts` — a one-line change.
|
||||
|
||||
---
|
||||
|
||||
## Desktop App Features
|
||||
|
||||
Native capabilities of the Lotus Chat **Tauri v2** desktop app (Windows, macOS, Linux) on top of the shared web client. Web hooks live in `src/app/hooks/useTauri*.ts` (each no-ops in the browser) and call Rust commands in `cinny-desktop/src-tauri/src/native/*`. Windows-only pieces are `#[cfg(target_os = "windows")]`, compile-verified in CI (Windows runners).
|
||||
|
||||
@@ -52,6 +52,24 @@ Everything else in the guide (calls, screen readers, desktop/Tauri, chat backgro
|
||||
|
||||
---
|
||||
|
||||
## Playwright smoke test (Gitea #90) — `npm run test:e2e`
|
||||
|
||||
Browser-level smoke tests under `e2e/` (config: `playwright.config.ts`). They boot the **built** `dist/` through `vite preview` on port 4173, so run `npm run build` first (one-time: `npm run test:e2e:install` downloads the pinned Chromium). Two tiers:
|
||||
|
||||
| Tier | File | When it runs | What it proves |
|
||||
| :------------------------ | :-------------------------- | :----------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Boot** (always) | `e2e/boot.spec.ts` | every CI run (`e2e` job in `.gitea/workflows/ci.yml`) and locally | login page renders with `#root` populated and **no** `pageerror` / unexpected `console.error` (allowlist in `e2e/helpers.ts`: the README's avatar-thumbnail 404, the login page's `POST /register` 401 probe, offline discovery), `sw.js` is served and registers, bundled Element Call mounts in a frame with every `/public/element-call/` asset returning 200 |
|
||||
| **E2EE composer** (gated) | `e2e/e2ee-composer.spec.ts` | only when `E2E_HOMESERVER`, `E2E_USER`, `E2E_PASSWORD` are all set | password login → `/home/create/` with the End-to-End Encryption switch on (asserts `createRoom` carries `m.room.encryption`) → text message renders → attach a generated JPEG with "Compress image before uploading" ticked, image renders → every `PUT …/rooms/*/send/*` was `m.room.encrypted` with `ciphertext` and no plaintext `body` / `url` / `file` / `mxc://` |
|
||||
|
||||
**CI secrets** (Gitea → repo → Settings → Actions → Secrets; the `e2e` job forwards them via `env:`; until they exist the E2EE tier reports `skipped`, the boot tier still runs):
|
||||
|
||||
- `E2E_HOMESERVER` — server name as typed on the login page (e.g. `matrix.example.org`). Must offer `m.login.password`; a next-gen-auth (MAS/OIDC-issuer) server shows only the OIDC button and the tier will fail at the username field.
|
||||
- `E2E_USER` / `E2E_PASSWORD` — a **throwaway** account: each run logs in as a new device and creates a new `e2e-smoke-<timestamp>` room. Prune devices/rooms occasionally.
|
||||
|
||||
The `e2e` job is `continue-on-error: true` for now because `playwright install --with-deps` needs `apt` on the runner image — promote it to a hard gate once it is green on the runner. Locally: `npm run test:e2e` (boot tier only), or `E2E_HOMESERVER=… E2E_USER=… E2E_PASSWORD=… npm run test:e2e` for both; on failure look in `test-results/` (screenshot + trace) and `playwright-report/`.
|
||||
|
||||
---
|
||||
|
||||
## A. Calls — new ringtone + notification work (highest priority)
|
||||
|
||||
### A1. Ringtone selection — preview in Settings
|
||||
|
||||
+2
-1
@@ -411,4 +411,5 @@ Before marking a feature complete: `npx tsc --noEmit` (0 errors) · `npx eslint
|
||||
- [ ] **Dedicated `desktop-linux` runner** (infra) — concurrency only collapses _burst_ stacking; a single in-flight `build-linux` (Tauri, `ubuntu-latest`) still shares the runner with web CI and can queue a web CI/deploy up to ~30 min. Fix = register a 2nd Linux act_runner labelled `desktop-linux` (root, network, RAM for a Tauri build; do NOT also label it `ubuntu-latest`) and point only `build-linux: runs-on` at it. Relabeling without a matching runner hangs the job forever.
|
||||
- [ ] **Debounce the desktop trigger** — `trigger-desktop` fires a full desktop build on _every_ lotus commit; consider tag/`workflow_dispatch`/schedule-gating to decouple desktop cadence from web commits (biggest remaining runner-load source).
|
||||
- [ ] **Verify Gitea ≥ 1.24** actually honors workflow `concurrency` (older silently ignores it → safe no-op, but the change is then inert — confirm on a test burst).
|
||||
- [ ] **Deferred (chosen-not-now):** build-once/deploy-the-artifact (kill the CI-then-deploy double build); CI-gate the `lotus-build.sh` upstream-merge path (currently builds+deploys+then pushes, bypassing CI).
|
||||
- [ ] **Deferred (chosen-not-now):** build-once/deploy-the-artifact (kill the CI-then-deploy double build).
|
||||
- [x] **CI-gate the `lotus-build.sh` upstream-merge path** — DONE (matrix repo, cinny#98): the script now merges, runs the local gates (npm ci, typecheck, eslint, prettier, tests), and pushes; CI + `lotus_deploy.sh` deploy exactly like any other lotus commit. A failed gate leaves the merge local (not pushed).
|
||||
|
||||
@@ -119,6 +119,7 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
|
||||
- Pending knock requests shown in the members list for room admins with a live badge count on the Members button
|
||||
- Homeserver support contact displayed in Help & About (MSC1929)
|
||||
- Server notice rooms are visually distinct from regular DMs
|
||||
- Known limitation: the UI is English-only for now — Lotus-added surfaces aren't yet localized, so language selection is restricted to English rather than showing a partially-translated UI (see [`LOTUS_FEATURES.md`](./LOTUS_FEATURES.md#localization))
|
||||
|
||||
---
|
||||
|
||||
@@ -229,3 +230,5 @@ NODE_OPTIONS=--max_old_space_size=6144 npm run build
|
||||
```
|
||||
edit → commit → git push → ~11 min → live at chat.lotusguild.org
|
||||
```
|
||||
|
||||
CI (`.gitea/workflows/ci.yml`) also runs a gitleaks secret scan, builds and smoke-tests the Docker image (`docker build`, boot + security-header checks against `docker-nginx.conf`), and dependency updates are proposed weekly by Renovate (`.gitea/workflows/renovate.yml`, config in `renovate.json`).
|
||||
|
||||
@@ -2,6 +2,42 @@ server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
|
||||
# ── Gitea #95 / #44 — shipped image had no security headers at all.
|
||||
# `always` so these are sent on error responses too, not just 200s.
|
||||
#
|
||||
# Content-Security-Policy, directive by directive:
|
||||
# default-src 'self' baseline: same-origin unless a directive below opens it up
|
||||
# script-src 'self' 'wasm-unsafe-eval'
|
||||
# app code is same-origin only; 'wasm-unsafe-eval' is required
|
||||
# for the wasm modules used for E2EE crypto and audio denoise
|
||||
# style-src 'self' 'unsafe-inline'
|
||||
# vanilla-extract (the app's CSS-in-JS) emits inline <style>,
|
||||
# so 'unsafe-inline' is required — no remote stylesheets needed
|
||||
# img-src * data: blob: avatars/media/previews come from whichever homeserver or
|
||||
# media repo the user points the client at — not knowable
|
||||
# ahead of time — plus data: URIs and blob: for local previews
|
||||
# media-src * blob: same reasoning as img-src, for audio/video attachments
|
||||
# connect-src * the Matrix homeserver is user-chosen at runtime, so this
|
||||
# can't be pinned to a fixed origin
|
||||
# worker-src 'self' blob: service worker + blob: web workers (crypto/denoise) are
|
||||
# same-origin or created from in-memory blobs, never remote
|
||||
# frame-src ... the rich link-preview embeds in
|
||||
# src/app/utils/videoEmbed.ts, one entry per provider:
|
||||
# YouTube, Vimeo, Dailymotion, Streamable, Twitch, Spotify,
|
||||
# SoundCloud, Apple Music, Tidal, Mixcloud, Deezer,
|
||||
# Instagram, Reddit, Bluesky, Loom, Kick, TikTok, Steam —
|
||||
# plus 'self' (no first-party iframes today, cheap to allow)
|
||||
# object-src 'none' no <object>/<embed> plugin content is used anywhere
|
||||
# base-uri 'self' blocks a <base> tag injection from redirecting relative URLs
|
||||
# frame-ancestors 'none' this app must never be framed by another site (clickjacking)
|
||||
#
|
||||
# Shipped config — verify against chat.lotusguild.org's live headers before
|
||||
# enabling this in the production nginx config; this file is currently only
|
||||
# exercised by the CI `docker` smoke-test job, not by the live deploy path.
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src * data: blob:; media-src * blob:; connect-src *; worker-src 'self' blob:; frame-src 'self' https://www.youtube-nocookie.com https://player.vimeo.com https://geo.dailymotion.com https://streamable.com https://clips.twitch.tv https://player.twitch.tv https://open.spotify.com https://w.soundcloud.com https://embed.music.apple.com https://embed.tidal.com https://www.mixcloud.com https://widget.deezer.com https://www.instagram.com https://embed.reddit.com https://embed.bsky.app https://www.loom.com https://player.kick.com https://www.tiktok.com https://store.steampowered.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { collectConsole } from './helpers';
|
||||
|
||||
// Tier 1 — boot smoke (Gitea #90). Runs against the built dist/ served by
|
||||
// `vite preview` (see playwright.config.ts webServer). No homeserver needed.
|
||||
|
||||
test.describe('boot', () => {
|
||||
test('client boots to the login screen without errors', async ({ page }) => {
|
||||
const consoleLog = collectConsole(page);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
// The auth page is what an unauthenticated visitor lands on.
|
||||
await expect(page).toHaveURL(/\/login\//);
|
||||
await expect(page.getByLabel('Username or email')).toBeVisible();
|
||||
await expect(page.getByLabel('Password', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Login' })).toBeVisible();
|
||||
|
||||
// The React root rendered something (a blank #root is the classic
|
||||
// "bundle built but doesn't run" failure).
|
||||
const rootChildren = await page.locator('#root > *').count();
|
||||
expect(rootChildren, '#root should have rendered children').toBeGreaterThan(0);
|
||||
|
||||
expect(consoleLog.unexpected(), 'unexpected console/page errors during boot').toEqual([]);
|
||||
});
|
||||
|
||||
test('service worker script is served and registers', async ({ page }) => {
|
||||
const swResponse = await page.request.get('/sw.js');
|
||||
expect(swResponse.status(), 'GET /sw.js').toBe(200);
|
||||
expect(swResponse.headers()['content-type'] ?? '').toMatch(/javascript/);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.getByLabel('Username or email')).toBeVisible();
|
||||
|
||||
// src/index.tsx registers sw.js on load; wait for the registration to
|
||||
// exist (localhost counts as a secure context so this works in CI).
|
||||
const registered = await page.evaluate(async () => {
|
||||
if (!('serviceWorker' in navigator)) return 'unsupported';
|
||||
const deadline = Date.now() + 15_000;
|
||||
while (Date.now() < deadline) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const reg = await navigator.serviceWorker.getRegistration();
|
||||
if (reg) return 'registered';
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, 250);
|
||||
});
|
||||
}
|
||||
return 'timeout';
|
||||
});
|
||||
expect(registered).toBe('registered');
|
||||
});
|
||||
|
||||
test('bundled Element Call loads in a frame', async ({ page }) => {
|
||||
const consoleLog = collectConsole(page);
|
||||
// Any EC asset that fails to come back (wrong base path, missing chunk)
|
||||
// is the regression this test exists to catch.
|
||||
const failedEcRequests: string[] = [];
|
||||
page.on('response', (res) => {
|
||||
if (res.url().includes('/public/element-call/') && res.status() >= 400) {
|
||||
failedEcRequests.push(`${res.status()} ${res.url()}`);
|
||||
}
|
||||
});
|
||||
page.on('requestfailed', (req) => {
|
||||
if (req.url().includes('/public/element-call/')) {
|
||||
failedEcRequests.push(`${req.failure()?.errorText ?? 'failed'} ${req.url()}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Same-origin host page so the iframe is served exactly as the client
|
||||
// embeds it.
|
||||
await page.goto('/');
|
||||
await expect(page.getByLabel('Username or email')).toBeVisible();
|
||||
|
||||
const ecResponse = await page.request.get('/public/element-call/index.html');
|
||||
expect(ecResponse.status(), 'GET /public/element-call/index.html').toBe(200);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const frame = document.createElement('iframe');
|
||||
frame.id = 'e2e-ec-frame';
|
||||
frame.src = '/public/element-call/index.html';
|
||||
frame.style.width = '800px';
|
||||
frame.style.height = '600px';
|
||||
document.body.appendChild(frame);
|
||||
});
|
||||
|
||||
const frame = page.frameLocator('#e2e-ec-frame');
|
||||
// EC mounts into its own #root; rendering anything at all proves the
|
||||
// bundle resolved its assets from the /public/element-call/ base.
|
||||
await expect(frame.locator('#root > *').first()).toBeAttached({ timeout: 30_000 });
|
||||
// Let EC finish its initial render/requests before inspecting the logs.
|
||||
await page.waitForTimeout(2_000);
|
||||
|
||||
expect(failedEcRequests, 'Element Call asset requests that failed').toEqual([]);
|
||||
// Loaded bare (no widget params / no homeserver) EC runs in standalone
|
||||
// mode and logs a caught React error about its missing config — that is
|
||||
// console noise, not a broken bundle. Uncaught page errors are still
|
||||
// fatal, and so is anything the boot test would reject on the host page.
|
||||
expect(consoleLog.pageErrors, 'uncaught page errors while loading Element Call').toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { test, expect, Page, Request } from '@playwright/test';
|
||||
import { collectConsole, generateJpeg } from './helpers';
|
||||
|
||||
// Tier 2 — E2EE composer smoke (Gitea #90). Needs a real homeserver account
|
||||
// that supports `m.login.password`, supplied via env (CI secrets, see
|
||||
// LOTUS_TESTING.md). Skips cleanly when unset so the boot tier still gates CI.
|
||||
//
|
||||
// E2E_HOMESERVER server name as typed in the login page, e.g. matrix.example.org
|
||||
// E2E_USER localpart or full MXID
|
||||
// E2E_PASSWORD password
|
||||
//
|
||||
// Every run logs in as a fresh device (fresh browser context), so the account
|
||||
// accumulates one device per run — use a throwaway test account.
|
||||
const HOMESERVER = process.env.E2E_HOMESERVER;
|
||||
const USER = process.env.E2E_USER;
|
||||
const PASSWORD = process.env.E2E_PASSWORD;
|
||||
const HAS_CREDENTIALS = Boolean(HOMESERVER && USER && PASSWORD);
|
||||
|
||||
type SentEvent = { url: string; body: Record<string, unknown> };
|
||||
|
||||
/** Records every `PUT .../send/<type>/<txn>` the client makes. */
|
||||
function recordSentEvents(page: Page): SentEvent[] {
|
||||
const sent: SentEvent[] = [];
|
||||
page.on('request', (req: Request) => {
|
||||
if (
|
||||
req.method() !== 'PUT' ||
|
||||
!/\/_matrix\/client\/[^/]+\/rooms\/[^/]+\/send\//.test(req.url())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let body: Record<string, unknown> = {};
|
||||
try {
|
||||
body = JSON.parse(req.postData() ?? '{}');
|
||||
} catch {
|
||||
// leave empty; the assertion below will surface it
|
||||
}
|
||||
sent.push({ url: req.url(), body });
|
||||
});
|
||||
return sent;
|
||||
}
|
||||
|
||||
const eventTypeOf = (url: string): string =>
|
||||
decodeURIComponent(url.match(/\/send\/([^/]+)\//)?.[1] ?? '');
|
||||
|
||||
test.describe('E2EE composer', () => {
|
||||
test.skip(!HAS_CREDENTIALS, 'needs E2E_HOMESERVER / E2E_USER / E2E_PASSWORD');
|
||||
// The three scenarios build on one another (login → room → messages), so
|
||||
// share a single page and run them in order.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
test.setTimeout(120_000);
|
||||
|
||||
let page: Page;
|
||||
let sentEvents: SentEvent[];
|
||||
let consoleLog: ReturnType<typeof collectConsole>;
|
||||
let roomUrl: string;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
consoleLog = collectConsole(page);
|
||||
sentEvents = recordSentEvents(page);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page?.close();
|
||||
});
|
||||
|
||||
test('logs in with a password and reaches the client', async () => {
|
||||
await page.goto(`/login/${encodeURIComponent(HOMESERVER as string)}/`);
|
||||
|
||||
await page.getByLabel('Username or email').fill(USER as string);
|
||||
await page.getByLabel('Password', { exact: true }).fill(PASSWORD as string);
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
|
||||
// Leaving /login/ means the session was stored and the client mounted.
|
||||
await expect(page).not.toHaveURL(/\/login\//, { timeout: 60_000 });
|
||||
// The client shell mounts at /home/ (or the last-visited space) once the
|
||||
// session is restored and initial sync starts.
|
||||
await expect(page).toHaveURL(/\/(home|direct|explore|inbox|!|#)/, { timeout: 60_000 });
|
||||
await expect(page.locator('#root > *').first()).toBeAttached();
|
||||
|
||||
expect(consoleLog.pageErrors, 'uncaught page errors during login').toEqual([]);
|
||||
});
|
||||
|
||||
test('creates a private encrypted room and sends a text message', async () => {
|
||||
const roomName = `e2e-smoke-${Date.now()}`;
|
||||
|
||||
const createRoomRequest = page.waitForRequest(
|
||||
(req) => req.method() === 'POST' && /\/_matrix\/client\/[^/]+\/createRoom/.test(req.url()),
|
||||
);
|
||||
|
||||
await page.goto('/home/create/');
|
||||
const form = page.locator('form').filter({ has: page.locator('input[name="nameInput"]') });
|
||||
await expect(form).toBeVisible();
|
||||
|
||||
await form.locator('input[name="nameInput"]').fill(roomName);
|
||||
// Default access is Private (or Restricted, which also allows E2EE); the
|
||||
// encryption switch lives in the "End-to-End Encryption" setting tile.
|
||||
const encryptionSwitch = form
|
||||
.getByText('End-to-End Encryption', { exact: true })
|
||||
.locator('xpath=ancestor::div[.//*[@role="switch"]][1]')
|
||||
.getByRole('switch');
|
||||
await expect(encryptionSwitch).toBeVisible();
|
||||
if ((await encryptionSwitch.getAttribute('aria-checked')) !== 'true') {
|
||||
await encryptionSwitch.click();
|
||||
}
|
||||
await expect(encryptionSwitch).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
await form.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// The createRoom request itself must ask for encryption up front.
|
||||
const createBody = JSON.parse((await createRoomRequest).postData() ?? '{}') as {
|
||||
initial_state?: { type: string; content?: { algorithm?: string } }[];
|
||||
};
|
||||
const encryptionState = createBody.initial_state?.find((s) => s.type === 'm.room.encryption');
|
||||
expect(encryptionState?.content?.algorithm, 'createRoom initial_state m.room.encryption').toBe(
|
||||
'm.megolm.v1.aes-sha2',
|
||||
);
|
||||
|
||||
// Landed in the new room.
|
||||
await expect(page).toHaveURL(/\/home\/!/, { timeout: 30_000 });
|
||||
roomUrl = page.url();
|
||||
await expect(page.getByText(roomName, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const text = `hello from playwright ${Date.now()}`;
|
||||
const composer = page.getByRole('textbox', { name: 'Send a message...' });
|
||||
await expect(composer).toBeVisible();
|
||||
await composer.click();
|
||||
await composer.fill(text);
|
||||
await composer.press('Enter');
|
||||
|
||||
await expect(page.getByText(text, { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const messageSends = sentEvents.filter((e) => eventTypeOf(e.url).startsWith('m.room.'));
|
||||
expect(messageSends.length, 'at least one room event sent').toBeGreaterThan(0);
|
||||
for (const e of messageSends) {
|
||||
expect(eventTypeOf(e.url), `event type for ${e.url}`).toBe('m.room.encrypted');
|
||||
expect(e.body).toHaveProperty('ciphertext');
|
||||
expect(e.body).not.toHaveProperty('body');
|
||||
expect(JSON.stringify(e.body)).not.toContain(text);
|
||||
}
|
||||
expect(consoleLog.pageErrors, 'uncaught page errors while sending text').toEqual([]);
|
||||
});
|
||||
|
||||
test('attaches a compressed image and it is sent encrypted', async () => {
|
||||
await expect(page).toHaveURL(roomUrl);
|
||||
const fileName = `lotus-e2e-${Date.now()}.jpg`;
|
||||
const jpeg = await generateJpeg(page);
|
||||
const sentBefore = sentEvents.length;
|
||||
|
||||
// The composer opens a detached <input type=file> via selectFile(); the
|
||||
// file chooser event is the hook Playwright gives us for that.
|
||||
const fileChooser = page.waitForEvent('filechooser');
|
||||
await page.getByRole('button', { name: 'Attach file' }).click();
|
||||
await (await fileChooser).setFiles({ name: fileName, mimeType: 'image/jpeg', buffer: jpeg });
|
||||
|
||||
// Upload board: tick "Compress image before uploading", then Send.
|
||||
const compressSwitch = page
|
||||
.getByText('Compress image before uploading', { exact: true })
|
||||
.locator('xpath=ancestor::div[.//*[@role="switch"]][1]')
|
||||
.getByRole('switch');
|
||||
await expect(compressSwitch).toBeVisible({ timeout: 30_000 });
|
||||
if ((await compressSwitch.getAttribute('aria-checked')) !== 'true') {
|
||||
await compressSwitch.click();
|
||||
}
|
||||
await expect(compressSwitch).toHaveAttribute('aria-checked', 'true');
|
||||
// compressImage() runs asynchronously once ticked; wait for it to settle
|
||||
// so the Send picks up the compressed result.
|
||||
await expect(page.getByText('compressing…')).toHaveCount(0, { timeout: 30_000 });
|
||||
|
||||
await page.getByRole('button', { name: 'Send', exact: true }).click();
|
||||
|
||||
// The timeline shows the image (alt/title = file body; compression
|
||||
// renames to .jpg which our name already is).
|
||||
const image = page.locator(`img[alt="${fileName}"]`);
|
||||
const viewButton = page.getByRole('button', { name: 'View', exact: true });
|
||||
await expect(image.or(viewButton).first()).toBeVisible({ timeout: 60_000 });
|
||||
if (!(await image.count())) {
|
||||
// Media auto-load disabled — click through and wait for the image.
|
||||
await viewButton.first().click();
|
||||
}
|
||||
await expect(image.first()).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
// Every room event sent for the image was encrypted: no plaintext
|
||||
// m.room.message with a `url`/`file`/`body`.
|
||||
const newSends = sentEvents
|
||||
.slice(sentBefore)
|
||||
.filter((e) => eventTypeOf(e.url).startsWith('m.room.'));
|
||||
expect(newSends.length, 'image produced at least one room event').toBeGreaterThan(0);
|
||||
for (const e of newSends) {
|
||||
expect(eventTypeOf(e.url), `event type for ${e.url}`).toBe('m.room.encrypted');
|
||||
expect(e.body).toHaveProperty('ciphertext');
|
||||
expect(e.body).not.toHaveProperty('url');
|
||||
expect(e.body).not.toHaveProperty('file');
|
||||
expect(e.body).not.toHaveProperty('body');
|
||||
expect(JSON.stringify(e.body)).not.toContain('mxc://');
|
||||
}
|
||||
expect(consoleLog.pageErrors, 'uncaught page errors while sending image').toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
// Console noise that is expected on a clean boot and must not fail the smoke
|
||||
// test. Keep this list short and specific — every entry should name a known,
|
||||
// understood source.
|
||||
const BENIGN_CONSOLE_PATTERNS: RegExp[] = [
|
||||
// README: after login you may see a 404 for a missing avatar thumbnail —
|
||||
// "not a login failure". Also covers the generic resource-404 console line.
|
||||
/_matrix\/(client|media)\/v\d+\/(media\/)?thumbnail/i,
|
||||
/Failed to load resource: the server responded with a status of 404/i,
|
||||
// The login page probes `POST /_matrix/client/v3/register` to learn whether
|
||||
// registration is open; the homeserver answers 401 + UIA flows by design.
|
||||
/Failed to load resource: the server responded with a status of 401/i,
|
||||
// Homeserver discovery pings can fail on a runner with no outbound network.
|
||||
/\/\.well-known\/matrix\/client/i,
|
||||
/Failed to fetch|NetworkError|ERR_NAME_NOT_RESOLVED|ERR_INTERNET_DISCONNECTED/i,
|
||||
// React devtools hint in production bundles.
|
||||
/Download the React DevTools/i,
|
||||
];
|
||||
|
||||
export type ConsoleCollector = {
|
||||
errors: string[];
|
||||
pageErrors: string[];
|
||||
/** Errors not matched by the benign allowlist. */
|
||||
unexpected: () => string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Records console.error lines and uncaught page errors for the given page.
|
||||
* Attach BEFORE navigating so nothing emitted during boot is missed.
|
||||
*/
|
||||
export function collectConsole(page: Page): ConsoleCollector {
|
||||
const errors: string[] = [];
|
||||
const pageErrors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') errors.push(msg.text());
|
||||
});
|
||||
page.on('pageerror', (err) => {
|
||||
pageErrors.push(err.message);
|
||||
});
|
||||
return {
|
||||
errors,
|
||||
pageErrors,
|
||||
unexpected: () => [
|
||||
...pageErrors.map((m) => `pageerror: ${m}`),
|
||||
...errors.filter((m) => !BENIGN_CONSOLE_PATTERNS.some((re) => re.test(m))),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a small JPEG in the browser (canvas.toBlob) and returns its bytes.
|
||||
* JPEG rather than PNG so the composer's "Compress image" path actually
|
||||
* re-encodes (compressImage() deliberately skips PNG to preserve alpha).
|
||||
*/
|
||||
export async function generateJpeg(page: Page, size = 96): Promise<Buffer> {
|
||||
const dataUrl = await page.evaluate((px) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = px;
|
||||
canvas.height = px;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('canvas 2d context unavailable');
|
||||
const grad = ctx.createLinearGradient(0, 0, px, px);
|
||||
grad.addColorStop(0, '#7c3aed');
|
||||
grad.addColorStop(1, '#f59e0b');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, px, px);
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = `${Math.floor(px / 4)}px sans-serif`;
|
||||
ctx.fillText('e2e', px / 8, px / 2);
|
||||
return canvas.toDataURL('image/jpeg', 0.95);
|
||||
}, size);
|
||||
return Buffer.from(dataUrl.split(',')[1], 'base64');
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["./**/*.ts", "../playwright.config.ts"]
|
||||
}
|
||||
Generated
+47
-1
@@ -81,6 +81,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.1",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
"@types/chroma-js": "3.1.2",
|
||||
@@ -120,7 +121,7 @@
|
||||
"vite-plugin-static-copy": "4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@apideck/better-ajv-errors": {
|
||||
@@ -2973,6 +2974,22 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-types/shared": {
|
||||
"version": "3.34.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.34.0.tgz",
|
||||
@@ -10708,6 +10725,35 @@
|
||||
"pathe": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"fix:prettier": "prettier --write .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --import tsx --test $(find src -name '*.test.ts')",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:install": "playwright install chromium",
|
||||
"prepare": "husky",
|
||||
"commit": "git-cz",
|
||||
"postinstall": "node scripts/patch-folds.mjs",
|
||||
@@ -106,6 +108,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.1",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
"@types/chroma-js": "3.1.2",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
// Playwright smoke tests (Gitea #90). Two tiers live under e2e/:
|
||||
// - boot.spec.ts always runs; serves the built dist/ via `vite preview`
|
||||
// and checks the client actually boots in a real browser.
|
||||
// - e2ee-composer.spec.ts skips itself unless E2E_HOMESERVER/E2E_USER/E2E_PASSWORD
|
||||
// are set (CI secrets — see LOTUS_TESTING.md).
|
||||
// `npm run build` must have produced dist/ before `npm run test:e2e`.
|
||||
const PORT = 4173;
|
||||
const BASE_URL = `http://localhost:${PORT}/`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 15_000 },
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: 1,
|
||||
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']],
|
||||
outputDir: 'test-results',
|
||||
use: {
|
||||
baseURL: BASE_URL,
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
webServer: {
|
||||
command: `npx vite preview --port ${PORT} --strictPort`,
|
||||
url: BASE_URL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 60_000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"],
|
||||
"schedule": ["before 6am on monday"],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackagePatterns": ["^matrix-js-sdk"],
|
||||
"groupName": "matrix-js-sdk"
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["^@lotusguild/"],
|
||||
"groupName": "@lotusguild packages"
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["@lotusguild/element-call-embedded"],
|
||||
"matchUpdateTypes": ["major"],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"matchCategories": ["security"],
|
||||
"groupName": "security updates",
|
||||
"automerge": false
|
||||
}
|
||||
]
|
||||
}
|
||||
+41
-13
@@ -4,28 +4,56 @@ import { join, dirname } from 'path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const foldsPath = join(__dirname, '../node_modules/folds/dist/index.js');
|
||||
const foldsPkgPath = join(__dirname, '../node_modules/folds/package.json');
|
||||
|
||||
// Context lines around the target, not just the single `children: src(filled)`
|
||||
// expression, so a coincidental match elsewhere in the bundle (e.g. some other
|
||||
// `src(filled)` call) can't be mistaken for the Icon component we're patching.
|
||||
// This is still string matching, not an AST edit, but the extra context makes
|
||||
// an accidental match far less likely (Gitea #55).
|
||||
const original = [' ...props,', ' ref,', ' children: src(filled)', ' }'].join(
|
||||
'\n',
|
||||
);
|
||||
const patched = [
|
||||
' ...props,',
|
||||
' ref,',
|
||||
' children: typeof src === "function" ? src(filled) : null',
|
||||
' }',
|
||||
].join('\n');
|
||||
|
||||
function foldsVersion() {
|
||||
try {
|
||||
return JSON.parse(readFileSync(foldsPkgPath, 'utf8')).version ?? 'unknown';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let content = readFileSync(foldsPath, 'utf8');
|
||||
|
||||
// Defensive guard: if src is not a function, render null instead of crashing
|
||||
const original = 'children: src(filled)';
|
||||
const patched = 'children: typeof src === "function" ? src(filled) : null';
|
||||
const content = readFileSync(foldsPath, 'utf8');
|
||||
|
||||
if (content.includes(patched)) {
|
||||
// Already patched (e.g. re-running postinstall, or a fresh checkout that
|
||||
// already has a patched node_modules cache) — no-op, exit 0.
|
||||
console.log('folds patch already applied.');
|
||||
} else if (content.includes(original)) {
|
||||
content = content.replace(original, patched);
|
||||
writeFileSync(foldsPath, content, 'utf8');
|
||||
writeFileSync(foldsPath, content.replace(original, patched), 'utf8');
|
||||
console.log('Applied defensive Icon src guard to folds.');
|
||||
} else {
|
||||
// Genuine "patch could not be applied" case: the target string is gone
|
||||
// (folds renamed/restructured it) AND it isn't already patched. Fail hard
|
||||
// so the postinstall hook / CI breaks loudly instead of silently shipping
|
||||
// an unpatched folds (which crashes at render with "src is not a function").
|
||||
// Genuine "patch could not be applied" case: neither the original nor the
|
||||
// patched form was found, meaning folds changed the Icon implementation.
|
||||
// Fail loudly so the postinstall hook / CI breaks instead of silently
|
||||
// shipping an unpatched folds (which crashes at render with "src is not a
|
||||
// function"). See LOTUS_TODO.md "Dependencies / Build / Hygiene" for
|
||||
// context on why this is a direct node_modules patch rather than
|
||||
// patch-package.
|
||||
console.error('ERROR: folds Icon patch target not found.');
|
||||
console.error(` folds version installed: ${foldsVersion()}`);
|
||||
console.error(` Expected to find (surrounding context):\n${original}`);
|
||||
console.error(
|
||||
'ERROR: folds Icon patch target not found - folds may have updated. ' +
|
||||
'Update the patch target string in scripts/patch-folds.mjs before building.',
|
||||
' folds likely changed its Icon implementation. Update the patch target ' +
|
||||
'in scripts/patch-folds.mjs (see LOTUS_TODO -> "Dependencies / Build / Hygiene" ' +
|
||||
'-> patch-folds.mjs entry) before building.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -103,9 +103,38 @@ missing.forEach((r) => console.log(` Removing (HTTP ${r.status}): ${r.slug}`));
|
||||
const missingSet = new Set(missing.map((r) => r.slug));
|
||||
|
||||
// Remove individual entries for missing slugs
|
||||
let updated = catalog.replace(/^[ \t]*\{ slug: '([^']+)', name: .+\},?\r?\n/gm, (match, slug) =>
|
||||
missingSet.has(slug) ? '' : match,
|
||||
);
|
||||
const removedSlugs = new Set();
|
||||
let updated = catalog.replace(/^[ \t]*\{ slug: '([^']+)', name: .+\},?\r?\n/gm, (match, slug) => {
|
||||
if (!missingSet.has(slug)) return match;
|
||||
removedSlugs.add(slug);
|
||||
return '';
|
||||
});
|
||||
|
||||
// Regex-based removal is brittle: if the catalog is reformatted (different
|
||||
// indentation, line-wrapped entries, etc.) the pattern above can silently
|
||||
// match zero entries while HTTP probing still reports slugs missing. Verify
|
||||
// every slug we intended to remove actually got matched — otherwise abort
|
||||
// without writing, so a formatting change fails loudly instead of leaving
|
||||
// stale/dead entries in the catalog (see Gitea #88).
|
||||
const unmatched = [...missingSet].filter((slug) => !removedSlugs.has(slug));
|
||||
if (unmatched.length > 0) {
|
||||
console.error(
|
||||
`Aborting: expected to remove ${missingSet.size} entr${missingSet.size === 1 ? 'y' : 'ies'} ` +
|
||||
`but only matched ${removedSlugs.size}. The catalog's formatting may have changed and the ` +
|
||||
`parser in scripts/syncDecorations.mjs needs updating. Refusing to write a partial result.`,
|
||||
);
|
||||
console.error(` Unmatched slugs: ${unmatched.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (removedSlugs.size === 0) {
|
||||
// We already exited above when `missing.length === 0`, so reaching here
|
||||
// with zero removals despite `missing.length > 0` means the diff between
|
||||
// "expected" and "actual" itself is broken — fail rather than proceed.
|
||||
console.error(
|
||||
'Aborting: no entries were matched for removal despite missing slugs. Refusing to write.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Drop category blocks that now have an empty decorations array
|
||||
updated = updated.replace(
|
||||
@@ -118,6 +147,6 @@ updated = updated.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
writeFileSync(catalogPath, updated, 'utf8');
|
||||
console.log(
|
||||
`\nDone. Removed ${missing.length} entr${missing.length === 1 ? 'y' : 'ies'} from the catalog.`,
|
||||
`\nDone. Removed ${removedSlugs.size} entr${removedSlugs.size === 1 ? 'y' : 'ies'} from the catalog.`,
|
||||
);
|
||||
console.log('Review with: git diff src/app/features/lotus/avatarDecorations.ts');
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
color,
|
||||
config,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Spinner,
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
} from 'folds';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { StatusDivider } from './components';
|
||||
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
||||
@@ -20,6 +7,7 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { callEmbedAtom } from '../../state/callEmbed';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
|
||||
import { ScreenshareConfirm } from '../call/ScreenshareConfirm';
|
||||
|
||||
type MicrophoneButtonProps = {
|
||||
enabled: boolean;
|
||||
@@ -199,14 +187,6 @@ export function CallControl({
|
||||
const showCamera = allowCamera || video;
|
||||
const showScreenshare = allowScreenshare || screenshare;
|
||||
const [shareConfirm, setShareConfirm] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!shareConfirm) return undefined;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setShareConfirm(false);
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [shareConfirm]);
|
||||
|
||||
const handleMicrophoneToggle = useCallback(
|
||||
() => callEmbed.control.toggleMicrophone(),
|
||||
@@ -230,64 +210,15 @@ export function CallControl({
|
||||
|
||||
return (
|
||||
<Box shrink="No" alignItems="Center" gap="300" style={{ position: 'relative' }}>
|
||||
{shareConfirm && (
|
||||
<>
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 99 }}
|
||||
onClick={() => setShareConfirm(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '110%',
|
||||
left: 0,
|
||||
background: color.Surface.Container,
|
||||
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
borderRadius: '0.75rem',
|
||||
padding: '1rem 1.25rem',
|
||||
zIndex: 100,
|
||||
minWidth: '260px',
|
||||
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.35)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<Text size="T300" style={{ fontWeight: 600 }}>
|
||||
Share your screen?
|
||||
</Text>
|
||||
<Text size="T200" style={{ opacity: 0.75 }}>
|
||||
Your screen will be visible to all participants in this call.
|
||||
</Text>
|
||||
<Box gap="200">
|
||||
<Button
|
||||
size="300"
|
||||
variant="Success"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
callEmbed.control.toggleScreenshare();
|
||||
setShareConfirm(false);
|
||||
}}
|
||||
>
|
||||
<Text size="B300">Share</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
outlined
|
||||
onClick={() => setShareConfirm(false)}
|
||||
>
|
||||
<Text size="B300">Cancel</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
<ScreenshareConfirm
|
||||
open={shareConfirm}
|
||||
align="Start"
|
||||
onConfirm={() => {
|
||||
callEmbed.control.toggleScreenshare();
|
||||
setShareConfirm(false);
|
||||
}}
|
||||
onCancel={() => setShareConfirm(false)}
|
||||
/>
|
||||
<Box alignItems="Inherit" gap="200">
|
||||
<MicrophoneButton
|
||||
enabled={microphone}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { MouseEventHandler, useCallback, useRef, useState, useEffect } from 'react';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
color,
|
||||
config,
|
||||
Icon,
|
||||
IconButton,
|
||||
@@ -41,9 +40,8 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
||||
import { pttActiveAtom } from '../../hooks/useCallHotkeys';
|
||||
import { CallSoundboard } from './CallSoundboard';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { RoomQualityContent } from '../../utils/callQuality';
|
||||
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
|
||||
import { ScreenshareConfirm } from './ScreenshareConfirm';
|
||||
|
||||
type CallControlsProps = {
|
||||
callEmbed: CallEmbed;
|
||||
@@ -90,14 +88,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
|
||||
const [cords, setCords] = useState<RectCords>();
|
||||
const [shareConfirm, setShareConfirm] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!shareConfirm) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setShareConfirm(false);
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [shareConfirm]);
|
||||
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
||||
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
||||
const [soundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled');
|
||||
@@ -107,16 +97,15 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
// visual PTT chip remains here.
|
||||
const pttActive = useAtomValue(pttActiveAtom);
|
||||
|
||||
// [P5-31] Hard room publish policy — hide controls the server will refuse so
|
||||
// users don't click dead buttons. Absent/true = allowed.
|
||||
const roomQualityEvent = useStateEvent(callEmbed.room, StateEvent.LotusRoomQuality);
|
||||
const roomQuality = roomQualityEvent?.getContent<RoomQualityContent>();
|
||||
const cameraAllowed = roomQuality?.allow_camera !== false;
|
||||
const screenshareAllowed = roomQuality?.allow_screenshare !== false;
|
||||
// [P5-31 / Gitea #101] Hard room publish policy — hide controls the server
|
||||
// will refuse so users don't click dead buttons. Absent/true = allowed.
|
||||
// Shared with the app-wide CallStatus bar's CallControl via useRoomCallPolicy
|
||||
// so both surfaces apply the same gating.
|
||||
const { allowCamera, allowScreenshare } = useRoomCallPolicy(callEmbed.room);
|
||||
// Keep a forbidden control visible while its track is still live (so the user
|
||||
// can stop it); otherwise hide it entirely.
|
||||
const showCamera = cameraAllowed || video;
|
||||
const showScreenshare = screenshareAllowed || screenshare;
|
||||
const showCamera = allowCamera || video;
|
||||
const showScreenshare = allowScreenshare || screenshare;
|
||||
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setCords(evt.currentTarget.getBoundingClientRect());
|
||||
@@ -191,67 +180,15 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
</Text>
|
||||
</Chip>
|
||||
)}
|
||||
{shareConfirm && (
|
||||
<>
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 99 }}
|
||||
onClick={() => setShareConfirm(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '110%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: color.Surface.Container,
|
||||
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
borderRadius: '0.75rem',
|
||||
padding: '1rem 1.25rem',
|
||||
zIndex: 100,
|
||||
minWidth: '260px',
|
||||
// Don't run past the screen edges on a narrow phone (centered via
|
||||
// translateX(-50%)); clamp to the viewport minus a small margin.
|
||||
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.35)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<Text size="T300" style={{ fontWeight: 600 }}>
|
||||
Share your screen?
|
||||
</Text>
|
||||
<Text size="T200" style={{ opacity: 0.75 }}>
|
||||
Your screen will be visible to all participants in this call.
|
||||
</Text>
|
||||
<Box gap="200">
|
||||
<Button
|
||||
size="300"
|
||||
variant="Success"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
callEmbed.control.toggleScreenshare();
|
||||
setShareConfirm(false);
|
||||
}}
|
||||
>
|
||||
<Text size="B300">Share</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
outlined
|
||||
onClick={() => setShareConfirm(false)}
|
||||
>
|
||||
<Text size="B300">Cancel</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
<ScreenshareConfirm
|
||||
open={shareConfirm}
|
||||
align="Center"
|
||||
onConfirm={() => {
|
||||
callEmbed.control.toggleScreenshare();
|
||||
setShareConfirm(false);
|
||||
}}
|
||||
onCancel={() => setShareConfirm(false)}
|
||||
/>
|
||||
<SequenceCard
|
||||
className={css.ControlCard}
|
||||
variant="SurfaceVariant"
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Box, Button, Text, color, config } from 'folds';
|
||||
|
||||
export type ScreenshareConfirmProps = {
|
||||
open: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
/**
|
||||
* Horizontal placement relative to the trigger button. `Center` (the
|
||||
* in-call bar's centered layout) transforms to center itself over the
|
||||
* anchor; `Start` (the app-wide status bar, anchored to its own left edge)
|
||||
* hugs the anchor's left edge instead.
|
||||
*/
|
||||
align?: 'Center' | 'Start';
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #101] Shared "Share your screen?" confirmation popover, used by both
|
||||
* the in-call `CallControls` bar and the app-wide `CallStatus` bar's
|
||||
* `CallControl`. Previously each bar carried its own near-identical copy;
|
||||
* hoisted here so their behaviour (Escape / click-outside to close, confirm
|
||||
* starts the share) can't drift apart.
|
||||
*/
|
||||
export function ScreenshareConfirm({
|
||||
open,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
align = 'Center',
|
||||
}: ScreenshareConfirmProps) {
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [open, onCancel]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 99 }}
|
||||
onClick={onCancel}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '110%',
|
||||
...(align === 'Center' ? { left: '50%', transform: 'translateX(-50%)' } : { left: 0 }),
|
||||
background: color.Surface.Container,
|
||||
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
borderRadius: '0.75rem',
|
||||
padding: '1rem 1.25rem',
|
||||
zIndex: 100,
|
||||
minWidth: '260px',
|
||||
// Don't run past the screen edges on a narrow phone (centered via
|
||||
// translateX(-50%)); clamp to the viewport minus a small margin.
|
||||
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.35)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<Text size="T300" style={{ fontWeight: 600 }}>
|
||||
Share your screen?
|
||||
</Text>
|
||||
<Text size="T200" style={{ opacity: 0.75 }}>
|
||||
Your screen will be visible to all participants in this call.
|
||||
</Text>
|
||||
<Box gap="200">
|
||||
<Button size="300" variant="Success" fill="Solid" radii="300" onClick={onConfirm}>
|
||||
<Text size="B300">Share</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
outlined
|
||||
onClick={onCancel}
|
||||
>
|
||||
<Text size="B300">Cancel</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export function useForceUpdate() {
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
return [
|
||||
data,
|
||||
function forceUpdateHook() {
|
||||
setData({});
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect } from 'react';
|
||||
import { MatrixClient, RoomEvent } from 'matrix-js-sdk';
|
||||
import { useForceUpdate } from './useForceUpdate';
|
||||
|
||||
/**
|
||||
* Bumps a counter whenever any room's `m.tag` account data changes
|
||||
* (favourite/low-priority add or remove via `mx.setRoomTag`/`deleteRoomTag`).
|
||||
*
|
||||
* `RoomEvent.Tags` is emitted on the individual `Room` by `Room.addTags()`
|
||||
* (matrix-js-sdk `lib/models/room.js`) and re-emitted onto the `MatrixClient`
|
||||
* itself by the sync loop (`lib/sync.js`, the `client.reEmitter.reEmit(room, [...
|
||||
* RoomEvent.Tags ...])` call), so a single client-level listener here sees the
|
||||
* change for every room without needing to attach/detach a per-room listener.
|
||||
*
|
||||
* Consumers should add the returned counter to the deps of any memo that
|
||||
* derives favourite/low-priority categorisation from `room.tags`, so toggling
|
||||
* a tag moves the room immediately instead of waiting for an unrelated
|
||||
* re-render to pick up the mutated (but stale-looking) `Room` object.
|
||||
*/
|
||||
export const useRoomTagsVersion = (mx: MatrixClient): number => {
|
||||
const [version, bumpVersion] = useForceUpdate();
|
||||
|
||||
useEffect(() => {
|
||||
mx.on(RoomEvent.Tags, bumpVersion);
|
||||
return () => {
|
||||
mx.removeListener(RoomEvent.Tags, bumpVersion);
|
||||
};
|
||||
}, [mx, bumpVersion]);
|
||||
|
||||
return version;
|
||||
};
|
||||
@@ -19,6 +19,16 @@ i18n
|
||||
.init<HttpBackendOptions>({
|
||||
debug: false,
|
||||
fallbackLng: 'en',
|
||||
// Lotus Chat is English-only for now: none of the Lotus-added UI (presence,
|
||||
// calls, soundboard, decorations, seasonal settings, keyboard shortcuts help,
|
||||
// etc.) is routed through useTranslation()/public/locales yet, so letting
|
||||
// LanguageDetector pick a non-English browser locale produced a UI that was
|
||||
// only partially translated (the ~11 upstream strings switched language,
|
||||
// everything Lotus-added stayed English). Restricting supportedLngs keeps
|
||||
// the detector/backend/mechanism intact (see LOTUS_FEATURES.md) so other
|
||||
// languages can come back with a one-line change once Lotus strings are
|
||||
// localized.
|
||||
supportedLngs: ['en'],
|
||||
interpolation: {
|
||||
escapeValue: false, // not needed for react as it escapes by default
|
||||
},
|
||||
|
||||
@@ -5,7 +5,12 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { setFallbackSession } from '../../../state/sessions';
|
||||
import { completeAuthorizationCodeGrant } from './oidcLoginUtil';
|
||||
import { getOidcCallbackUrl } from './oidcConfig';
|
||||
import { parseOidcCallbackParams } from './oidcState';
|
||||
import {
|
||||
invalidateCachedClient,
|
||||
isStaleClientError,
|
||||
parseOidcCallbackParams,
|
||||
readStoredOidcIssuer,
|
||||
} from './oidcState';
|
||||
|
||||
/**
|
||||
* Exchange the authorization code for a Matrix session and persist it. The SDK
|
||||
@@ -76,6 +81,19 @@ export function OidcCallback() {
|
||||
if (params.kind === 'success') complete(params.code, params.state);
|
||||
}, [params, complete]);
|
||||
|
||||
useEffect(() => {
|
||||
// #102 — the login-start path (startOidcLogin) already evicts a cached
|
||||
// dynamic client id when the provider rejects it, but that only covers
|
||||
// failures raised before the redirect. A client id can just as well be
|
||||
// rejected on the way back (`?error=invalid_client` on this callback),
|
||||
// and until now that case never invalidated the cache — so the same
|
||||
// stale id gets retried, and rejected again, on every subsequent login.
|
||||
if (params.kind === 'error' && isStaleClientError(params) && params.state) {
|
||||
const issuer = readStoredOidcIssuer(params.state);
|
||||
if (issuer) invalidateCachedClient(issuer);
|
||||
}
|
||||
}, [params]);
|
||||
|
||||
useEffect(() => {
|
||||
// Session persisted — full-page reload at the app root so it boots the
|
||||
// authenticated client (works for both hash and browser router configs).
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
invalidateCachedClient,
|
||||
parseOidcCallbackParams,
|
||||
isStaleClientError,
|
||||
readStoredOidcIssuer,
|
||||
} from './oidcState';
|
||||
|
||||
const installStorage = (): Map<string, string> => {
|
||||
@@ -22,6 +23,20 @@ const installStorage = (): Map<string, string> => {
|
||||
return store;
|
||||
};
|
||||
|
||||
const installSessionStorage = (): Map<string, string> => {
|
||||
const store = new Map<string, string>();
|
||||
(globalThis as { sessionStorage?: unknown }).sessionStorage = {
|
||||
getItem: (k: string) => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k: string, v: string) => {
|
||||
store.set(k, String(v));
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
store.delete(k);
|
||||
},
|
||||
};
|
||||
return store;
|
||||
};
|
||||
|
||||
test('registration cache: get / put / invalidate, scoped by issuer + redirectUri', () => {
|
||||
installStorage();
|
||||
assert.equal(getCachedClientId('iss', 'rd'), undefined);
|
||||
@@ -52,11 +67,21 @@ test('parseOidcCallbackParams classifies success / error / invalid', () => {
|
||||
kind: 'error',
|
||||
error: 'access_denied',
|
||||
errorDescription: 'nope',
|
||||
state: undefined,
|
||||
});
|
||||
assert.deepEqual(parseOidcCallbackParams('?error=bad'), {
|
||||
kind: 'error',
|
||||
error: 'bad',
|
||||
errorDescription: undefined,
|
||||
state: undefined,
|
||||
});
|
||||
// #102 — the provider echoes back `state` on an error redirect too; the
|
||||
// callback needs it to look up the pending sign-in's issuer.
|
||||
assert.deepEqual(parseOidcCallbackParams('?error=invalid_client&state=xyz'), {
|
||||
kind: 'error',
|
||||
error: 'invalid_client',
|
||||
errorDescription: undefined,
|
||||
state: 'xyz',
|
||||
});
|
||||
assert.deepEqual(parseOidcCallbackParams('?code=only'), { kind: 'invalid' });
|
||||
assert.deepEqual(parseOidcCallbackParams(''), { kind: 'invalid' });
|
||||
@@ -82,3 +107,45 @@ test('isStaleClientError: transient / discovery / local failures keep the cache'
|
||||
assert.equal(isStaleClientError(undefined), false);
|
||||
assert.equal(isStaleClientError('invalid_client'), false); // bare strings are not error objects
|
||||
});
|
||||
|
||||
test('isStaleClientError: also recognises the OidcCallback error-redirect shape (#102)', () => {
|
||||
// Same field name (`error`) as the ErrorResponse shape, so no special-casing
|
||||
// is needed — but pin it down since the callback now depends on this.
|
||||
assert.equal(
|
||||
isStaleClientError({
|
||||
kind: 'error',
|
||||
error: 'invalid_client',
|
||||
errorDescription: undefined,
|
||||
state: 'xyz',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isStaleClientError({
|
||||
kind: 'error',
|
||||
error: 'access_denied',
|
||||
errorDescription: undefined,
|
||||
state: 'xyz',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('readStoredOidcIssuer reads the issuer persisted by generateOidcAuthorizationUrl', () => {
|
||||
const store = installSessionStorage();
|
||||
assert.equal(readStoredOidcIssuer('xyz'), undefined); // nothing stored yet
|
||||
store.set(
|
||||
'mx_oidc_xyz',
|
||||
JSON.stringify({ authority: 'https://issuer.example', client_id: 'abc' }),
|
||||
);
|
||||
assert.equal(readStoredOidcIssuer('xyz'), 'https://issuer.example');
|
||||
assert.equal(readStoredOidcIssuer('other-state'), undefined); // different state = miss
|
||||
});
|
||||
|
||||
test('readStoredOidcIssuer tolerates corrupt or missing storage', () => {
|
||||
const store = installSessionStorage();
|
||||
store.set('mx_oidc_bad', '{ not json');
|
||||
assert.equal(readStoredOidcIssuer('bad'), undefined);
|
||||
store.set('mx_oidc_noauth', JSON.stringify({ client_id: 'abc' }));
|
||||
assert.equal(readStoredOidcIssuer('noauth'), undefined);
|
||||
});
|
||||
|
||||
@@ -45,14 +45,16 @@ export const invalidateCachedClient = (issuer: string): void => {
|
||||
const STALE_CLIENT_ERROR_CODES = new Set(['invalid_client', 'unauthorized_client']);
|
||||
|
||||
/**
|
||||
* #67 — pure: does a `startOidcLogin` failure indicate the CACHED client id is
|
||||
* #67 / #102 — pure: does an OIDC failure indicate the CACHED client id is
|
||||
* bad? Only then is dropping the registration cache justified; a transient
|
||||
* network error, an offline discovery fetch, or a local (`crypto.randomUUID`)
|
||||
* failure must leave it alone, otherwise every retry performs a fresh dynamic
|
||||
* registration and piles throwaway clients onto the provider. Recognised
|
||||
* shapes: an OAuth error response (`{ error: 'invalid_client' }`, as thrown by
|
||||
* oidc-client-ts `ErrorResponse`), or an HTTP 400/401 carried as
|
||||
* `httpStatus`/`status` (MatrixError-style) from the registration/authorize step.
|
||||
* oidc-client-ts `ErrorResponse`, OR as echoed back on the callback redirect's
|
||||
* `?error=` query param via {@link OidcCallbackParams}), or an HTTP 400/401
|
||||
* carried as `httpStatus`/`status` (MatrixError-style) from the
|
||||
* registration/authorize step.
|
||||
*/
|
||||
export const isStaleClientError = (e: unknown): boolean => {
|
||||
if (!e || typeof e !== 'object') return false;
|
||||
@@ -69,7 +71,7 @@ export const isStaleClientError = (e: unknown): boolean => {
|
||||
/** Parsed shape of the provider's redirect back to our callback URL. */
|
||||
export type OidcCallbackParams =
|
||||
| { kind: 'success'; code: string; state: string }
|
||||
| { kind: 'error'; error: string; errorDescription?: string }
|
||||
| { kind: 'error'; error: string; errorDescription?: string; state?: string }
|
||||
| { kind: 'invalid' };
|
||||
|
||||
/** Pure: classify the callback query string into success / error / invalid. */
|
||||
@@ -77,10 +79,41 @@ export const parseOidcCallbackParams = (search: string): OidcCallbackParams => {
|
||||
const params = new URLSearchParams(search);
|
||||
const error = params.get('error');
|
||||
if (error) {
|
||||
return { kind: 'error', error, errorDescription: params.get('error_description') ?? undefined };
|
||||
return {
|
||||
kind: 'error',
|
||||
error,
|
||||
errorDescription: params.get('error_description') ?? undefined,
|
||||
// OAuth error redirects echo back the `state` we sent, same as a
|
||||
// success redirect would — needed to look up the pending sign-in's
|
||||
// issuer (see readStoredOidcIssuer) when the client id itself was
|
||||
// rejected.
|
||||
state: params.get('state') ?? undefined,
|
||||
};
|
||||
}
|
||||
const code = params.get('code');
|
||||
const state = params.get('state');
|
||||
if (code && state) return { kind: 'success', code, state };
|
||||
return { kind: 'invalid' };
|
||||
};
|
||||
|
||||
/**
|
||||
* #102 — the issuer for a pending sign-in isn't available on the callback's
|
||||
* error branch (we bail out before `completeAuthorizationCodeGrant`, which is
|
||||
* what would otherwise surface it). oidc-client-ts's `generateOidcAuthorizationUrl`
|
||||
* persists the pending sign-in's `SigninState` (including `authority` and
|
||||
* `client_id`) into sessionStorage before redirecting, keyed by
|
||||
* `mx_oidc_<state>` (WebStorageStateStore prefix `mx_oidc_`, keyed by the same
|
||||
* `state` value the provider echoes back on redirect). Read it directly so a
|
||||
* genuinely stale cached client id (`invalid_client`) can be evicted even when
|
||||
* the callback errors before a token exchange is attempted.
|
||||
*/
|
||||
export const readStoredOidcIssuer = (state: string): string | undefined => {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(`mx_oidc_${state}`);
|
||||
if (!raw) return undefined;
|
||||
const { authority } = JSON.parse(raw) as { authority?: unknown };
|
||||
return typeof authority === 'string' ? authority : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -79,6 +79,7 @@ import { UseStateProvider } from '../../../components/UseStateProvider';
|
||||
import { JoinAddressPrompt } from '../../../components/join-address-prompt';
|
||||
import { _RoomSearchParams } from '../../paths';
|
||||
import { getLocalRoomNamesContent } from '../../../hooks/useRoomMeta';
|
||||
import { useRoomTagsVersion } from '../../../hooks/useRoomTagsVersion';
|
||||
|
||||
type HomeMenuProps = {
|
||||
requestClose: () => void;
|
||||
@@ -253,6 +254,11 @@ export function Home() {
|
||||
const [homeRoomSort, setHomeRoomSort] = useSetting(settingsAtom, 'homeRoomSort');
|
||||
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
||||
const [sortMenuAnchor, setSortMenuAnchor] = useState<RectCords>();
|
||||
// Bumped whenever any room's `m.tag` account data changes (favourite/low-priority
|
||||
// toggle via RoomNavItemMenu). Nothing else re-runs this memo on a tag change
|
||||
// (see useRoomTagsVersion.ts), so without this dep a room only moves section
|
||||
// once some unrelated event (e.g. an unread-count change) forces a re-render.
|
||||
const roomTagsVersion = useRoomTagsVersion(mx);
|
||||
|
||||
const { favoriteRooms, lowPriorityRooms, otherRooms } = useMemo(() => {
|
||||
const favs: string[] = [];
|
||||
@@ -269,7 +275,10 @@ export function Home() {
|
||||
}
|
||||
});
|
||||
return { favoriteRooms: favs, lowPriorityRooms: low, otherRooms: others };
|
||||
}, [mx, rooms]);
|
||||
// roomTagsVersion is a trigger-only counter, not read in the body; it forces
|
||||
// this memo to re-run whenever any room's tags change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mx, rooms, roomTagsVersion]);
|
||||
|
||||
const sortedFavoriteRooms = useMemo(() => {
|
||||
const isClosed = closedCategories.has(FAVORITES_CATEGORY_ID);
|
||||
|
||||
Reference in New Issue
Block a user