Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 7b3d1bad8f fix(previews): no genuine preview card for a site a deceptive link only pretends to be (#286)
CI / Build & Quality Checks (pull_request) Successful in 3m6s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Successful in 21m16s
Previews are built from the plain body, clicks follow formatted_body's
hrefs, and a sender controls both: text "https://paypal.com" linked to
evil.example showed a real PayPal card. Drop body URLs whose site is
only claimed by the visible text of a mismatched link (linkSafety).

Fixes #286

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 00:05:06 -04:00
5 changed files with 82 additions and 100 deletions
+2 -95
View File
@@ -1,7 +1,7 @@
# Lotus Chat — Feature Reference
Everything added to Lotus Chat beyond upstream Cinny v4.12.1.
Last updated: October 2026.
Last updated: July 2026.
---
@@ -28,8 +28,7 @@ Last updated: October 2026.
19. [Infrastructure](#infrastructure)
20. [Localization](#localization)
21. [Desktop App Features](#desktop-app-features)
22. [June–October 2026 Additions](#juneoctober-2026-additions)
23. [Key Custom Files](#key-custom-files)
22. [Key Custom Files](#key-custom-files)
---
@@ -1523,98 +1522,6 @@ Checks for a new desktop release every 12h and offers a one-click update. `Tauri
---
## June–October 2026 Additions
Features shipped since the sections above were last updated (Gitea #294). Each
line names the commit that introduced it.
### Calls
- **Selectable ringtone + volume:** pick the incoming-call ringtone (`4a875884`).
- **In-call incoming-call banner:** a small banner for a second call instead of a full ring (`c67aed01`).
- **Denoise tester:** Settings → Calls lets you audition the ML models and calibrate the noise gate (`14cfa021`).
- **Mic level meter** on the mute button (`3449338b`, #146).
- **"You're muted" nudge** when you talk into a muted mic (`73b1d1e3`, #117).
- **Undeafen catch-up toast:** what you missed while deafened (`33e16e85`, #128).
- **Admin turned off screen share / camera:** a toast explains why your share or camera stopped (`34574178`, #223).
- **Voice-limit / audio-only chip** in the room header (`7f17940d`, #134).
- **"N Live" pill names** who is in the call on hover / focus (`a475531b`, #157).
- **Live-call dot on the space rail** (`4fe9c870`, #148).
- **Missed-call line** in the timeline for DMs and small groups (`c921f115`, #162).
- **Answer with camera or audio-only:** answering says when it turns the camera on (`d2f56817`, #135).
- **Audio output quick-switch** in the call bar (`98c80f36`, #119).
- **Call-ended summary toast:** "Call ended · 41 min · connection was good" (`af1c0ee1`, #143).
- **No WebRTC on desktop:** offers "Open in browser" instead of a dead end (`fff583fc`).
- **Call page on its own origin** (desktop, opt-in) (`7d7a379c`, #43).
### Messaging & composer
- **Reactions collapse** to one row with a +N chip (`bf05751e`, #138).
- **Consecutive photos / videos** render as one gallery grid (`bd8c79e0`, #137).
- **Inline audio playback** for `m.file` audio attachments (`eb34b047`).
- **Recently used GIFs** in the GIF picker (`5b58e5fe`).
- **Pasted image → caption field:** a single pasted or dropped image lands in its caption (`0c45bde8`, #129).
- **Offer to format pasted code** as a code block (`75d55e86`, #107).
- **Tap to preview stickers / GIFs** on touch, tap again to send (`8d11a62e`, #147).
- **Link-mismatch confirmation** before opening a link whose text names another site (`74d8e311`, #122).
- **Embed fallback** for hung embeds and deleted X posts (`ba5b1ffe`, #200).
- **"Back to where you were"** after jumping in the timeline (`7da91bd8`, #127).
- **Offline outbox:** unsent messages survive a reload and retry (`02d86cae`, #112).
- **Terms-consent explanation** when "Failed to send" is the server wanting its terms accepted (`c96c47dd`).
### Search & navigation
- **Quick switcher (Ctrl/⌘+K)**, with a configurable key (`9d4679d2`).
- **Search filters:** `has:image/file/video`, recent searches, pinned only (`1ee0f0b5`).
- **Search operators:** `in:`, `before:` / `after:`, `has:`, `is:pinned` (`75951f90`, #106).
- **Explore:** featured Lotus Guild space and the matrixrooms.info directory (`b1ecb0c4`).
### Rooms & threads
- **Disappearing messages** (MSC1763 `m.room.retention`) (`82e52e1b`).
- **Mark as Unread** (MSC2867) and **Low Priority** rooms (`d46b91b1`).
- **"Who can see this"** line under the room name in Room Settings (`ad1cbcf7`, #133).
- **Threads: Mark all read** in the threads list (`fdec3ed7`, #165).
### Media viewer
- **Swipe** between items in the full-screen viewer (`cf80729a`, #164).
- **Two-finger pinch zoom** (`39589b40`, #164).
### Profile, privacy & safety
- **Report User** (MSC4260) (`26f90087`).
- **Active now / Last active** from presence (`dea1f7af`, #150).
- **Delete all my messages** in a room (`edb46247`, #169).
- **Self-hosted VT323 font** instead of Google Fonts (`6f250353`, #214).
- **Device-security nudge:** verify this device / set up key backup (`6a7627fa`, #110, #123).
- **Homeserver status banner** from Uptime Kuma (`6ae00872`, #124).
### Appearance
- **Custom accent colour** for non-TDS themes (`3c4842df`, P5-1).
- **Theme presets:** Cyberpunk, Ocean, Blood Red, Matrix, Midnight (`65e24bd4`, P5-2).
### Mobile & PWA
- **Saved Messages** from the room header's More Options menu (`8c711f5f`).
- **Long-press bottom sheet** of message actions (`53a80adb`, #166).
- **Haptics** on PTT press / release and on reactions (`492b57c6`, #125).
- **Android share target:** share into Lotus Chat from other apps (`96a97a2f`, #155).
### Notifications
- **Inline reply** from a browser notification (unencrypted rooms) (`d4420905`, #203).
### Desktop app
- **Tray icon mirrors call state** (`25fa0f6e`, cinny-desktop #4).
- **Start minimized** under Launch on login (`4a982389`, cinny-desktop #3).
- **First-close choice:** asks once what closing the window should do (`359c79a4`, cinny-desktop #5).
- **Keychain mirror:** login tokens also kept in the OS keychain (`9b9f33b2`, #105).
---
## Key Custom Files
| File | Purpose |
+1 -1
View File
@@ -34,7 +34,7 @@ This file keeps only what a contributor needs to have open while working: the tw
## 🚫 Blocked Features (server / upstream gated)
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade. **Re-checked on 1.162.0 (2026-10-18, Gitea #294):** live location (`msc3489`/`msc3672`) and relation redaction (`msc3892`) are still unadvertised; thread subscriptions (`msc4306`), sticky events (`msc4354`) and QR login (`msc4108`) are still `false`. New: `org.matrix.msc3026.busy_presence` is advertised as `false`, and enabling it would give Do Not Disturb a real presence state (#283). `msc3266` and `msc4222` are no longer listed, but room preview still works (`im.nheko.summary` and `v1/room_summary` both answer 200). (Previous check: 1.157.1 on 2026-07-23, no change then.)
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade. **Re-checked on 1.157.1 (2026-07-23): no change — all four below are still `false`.** The 1.156.0→1.157.1 delta unblocked nothing (it's a bugfix release; the only feature-bearing release in the gap was 1.156.0, which we were already running).
- **[BLOCKED] Live Location Sharing** (MSC3489 + MSC3672 both `false`) — real-time GPS beacons over the existing static share.
- **[BLOCKED] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable.
@@ -40,6 +40,7 @@ import { parseGeoUri, scaleYDimension } from '../../utils/common';
import { formatAccuracy } from '../../utils/geoShare';
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
import { FileHeader, FileDownloadButton } from './FileHeader';
import { previewUrlsFor } from '../../utils/linkSafety';
// ≈ 20 lines; in rem so a larger font setting doesn't show fewer lines (Gitea #343).
const COLLAPSE_MAX_HEIGHT_REM = 20;
@@ -343,7 +344,13 @@ export function MText({
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
// [Gitea #286] no genuine card for a site a deceptive link only pretends to be
const urls = urlsMatch
? previewUrlsFor(
[...new Set(urlsMatch)],
typeof customBody === 'string' ? customBody : undefined,
)
: undefined;
return (
<>
@@ -401,7 +408,13 @@ export function MEmote({
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
// [Gitea #286] no genuine card for a site a deceptive link only pretends to be
const urls = urlsMatch
? previewUrlsFor(
[...new Set(urlsMatch)],
typeof customBody === 'string' ? customBody : undefined,
)
: undefined;
return (
<>
@@ -458,7 +471,13 @@ export function MNotice({
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
// [Gitea #286] no genuine card for a site a deceptive link only pretends to be
const urls = urlsMatch
? previewUrlsFor(
[...new Set(urlsMatch)],
typeof customBody === 'string' ? customBody : undefined,
)
: undefined;
return (
<>
+23 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { analyzeLink, hostFromText, registrableDomain } from './linkSafety';
import { analyzeLink, hostFromText, previewUrlsFor, registrableDomain } from './linkSafety';
test('visible text that names another site is a mismatch', () => {
const r = analyzeLink('https://matrix.lotusguild.org/login', 'https://evil.example/login');
@@ -73,3 +73,25 @@ test('shared hosting suffixes are separate sites (#335)', () => {
);
assert.equal(registrableDomain('news.bbc.co.uk'), 'bbc.co.uk');
});
test('previewUrlsFor: no preview for a site a deceptive link only claims (#286)', () => {
const urls = ['https://paypal.com'];
assert.deepEqual(
previewUrlsFor(urls, '<a href="https://evil.example/login">https://paypal.com</a>'),
[],
);
// An honest link keeps its preview.
assert.deepEqual(
previewUrlsFor(urls, '<a href="https://www.paypal.com/x">https://paypal.com</a>'),
urls,
);
assert.deepEqual(previewUrlsFor(urls, undefined), urls);
// Other links in the message keep theirs.
assert.deepEqual(
previewUrlsFor(
['https://paypal.com', 'https://lotusguild.org'],
'see <a href="https://evil.example">paypal.com</a> and https://lotusguild.org',
),
['https://lotusguild.org'],
);
});
+34
View File
@@ -221,3 +221,37 @@ export const analyzeLink = (text: string, href: string): LinkSafety | null => {
const other = shown.find((h) => registrableDomain(h) !== realSite);
return { mismatch: other !== undefined, shownHost: other ?? shown[0], realHost, punycode };
};
const ANCHOR_REG = /<a\s[^>]*href\s*=\s*(["'])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi;
const decodeEntities = (s: string): string =>
s
.replace(/<[^>]+>/g, '')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
/**
* [Gitea #286] The URLs to build link previews for. Previews come from the
* plain `body`, but clicks follow `formatted_body`'s hrefs, and a sender
* controls both: text reading "https://paypal.com" linked to evil.example got
* a genuine PayPal card. Drop any body URL whose site is only *claimed* by a
* deceptive link's text.
*/
export const previewUrlsFor = (urls: string[], formattedBody: string | undefined): string[] => {
if (!formattedBody || urls.length === 0) return urls;
const claimed = new Set<string>();
Array.from(formattedBody.matchAll(ANCHOR_REG)).forEach((m) => {
const safety = analyzeLink(decodeEntities(m[3]), decodeEntities(m[2]));
if (safety?.mismatch && safety.shownHost) claimed.add(registrableDomain(safety.shownHost));
});
if (claimed.size === 0) return urls;
return urls.filter((u) => {
try {
return !claimed.has(registrableDomain(new URL(u).hostname));
} catch {
return true;
}
});
};