diff --git a/owl/README.md b/owl/README.md index 16349fa7..c78a4cdd 100644 --- a/owl/README.md +++ b/owl/README.md @@ -19,3 +19,8 @@ In this owl folder should go all documents we produce for this Endeavour. - Find out how we can set a theme by default for new users. - img tags in messages should be rendered, without requiring them to be on a local cache. We do not need to secure against identification attacks. - youtube links should create embedded players (if enabled). + + + ## Commit Messages + + All Commits for OWL changes start with the Owl Emoji πŸ¦‰ diff --git a/owl/REPORT-002-external-images.md b/owl/REPORT-002-external-images.md new file mode 100644 index 00000000..fc0c7c20 --- /dev/null +++ b/owl/REPORT-002-external-images.md @@ -0,0 +1,176 @@ +# OWL Report #002 - External Images: Settings & Scoping + +Date: 2026-04-15 + +## The Question + +We've already removed the external image block (sanitize.ts + react-custom-html-parser.tsx). But should this be unconditional? Options: + +1. Global toggle (user setting) +2. Per-room setting +3. Automatic based on federation status +4. Homeserver-level default via config.json + +## What Context Is Available at Render Time + +The key rendering call chain is: + +``` +RoomTimeline.tsx (has: room object, mx client, settings) + β†’ getReactCustomHtmlParser(mx, roomId, params) + β†’ HTMLReactParserOptions (handles img tags) + β†’ RenderMessageContent (receives pre-built parser options) +``` + +At the `RoomTimeline` level (line 528 of `src/app/features/room/RoomTimeline.tsx`), we have: +- Full `room` object (Room from matrix-js-sdk) +- `mx` (MatrixClient) β€” knows the homeserver domain +- All user settings via Jotai atoms + +### Federation Detection + +Rooms carry an `m.federate` flag in their `m.room.create` state event: +```typescript +const createEvent = getStateEvent(room, StateEvent.RoomCreate); +const isFederated = createEvent?.getContent()?.['m.federate'] !== false; +``` +Default is `true` (federated). Local-only rooms explicitly set `m.federate: false`. + +### Homeserver Detection + +We can tell if a message sender is local: +```typescript +const senderDomain = event.getSender()?.split(':')[1]; +const isLocal = senderDomain === mx.getDomain(); +``` + +## Options Analysis + +### Option A: Global User Setting + +Add `allowExternalImages: boolean` to settings, default `false` (or `true` via config.json for owl). + +**Pros:** Simple, one toggle, works everywhere. +**Cons:** All-or-nothing. A privacy-conscious user can't allow it for trusted rooms only. + +### Option B: Per-Room Setting (Room State Event) + +Store a custom state event like `cx.owl.room.settings` with `{ allowExternalImages: true }` per room. Room admins control it. + +**Pros:** Fine-grained, admin-controlled. +**Cons:** Requires room admin action. UX overhead. Need UI for room settings. Only works in rooms we control. + +### Option C: Automatic by Federation Status + +If `m.federate === false` (local-only room) β†’ allow external images. If federated β†’ block. + +**Pros:** Zero configuration, matches the threat model (federated = untrusted external servers can leak IPs via images). +**Cons:** Most rooms default to `m.federate: true` even on a single homeserver. Users would need to explicitly create non-federated rooms. Doesn't match the real intent β€” on owl.cx, all rooms are effectively local even if technically federated. + +### Option D: Homeserver-Level Default via config.json + +Add to config.json: +```json +{ + "defaultSettings": { + "allowExternalImages": "homeserver" + } +} +``` + +Where the value is one of: `"always"`, `"never"`, `"homeserver"` (only in rooms on this homeserver), `"local"` (only non-federated rooms). + +**Pros:** Server operator controls the policy. Matches the owl use case perfectly β€” "we trust our own server." +**Cons:** More logic to implement. Need to define what "homeserver room" means (all members local? room alias local? room ID local?). + +### Option E: Layered Approach (Recommended) + +Combine a global setting with smart defaults: + +1. **Global setting** `externalImages`: `"always"` | `"never"` | `"homeserver"` + - Default: `"never"` (safe for general Cinny users) + - owl config.json sets: `"homeserver"` or `"always"` +2. **"homeserver" mode**: allow external images when the room's server matches the user's homeserver (check room ID domain: `!roomid:owl.cx`) +3. **Per-room override** (future): room admins can set `cx.owl.room.settings` state event to override + +This gives us: +- Safe default for upstream/general use +- owl.cx sets `"homeserver"` via config.json β€” external images work in all owl rooms +- Users can override to `"always"` or `"never"` in settings +- Room-level control can be added later without changing the architecture + +## Implementation Plan + +### Settings Layer + +**`src/app/state/settings.ts`:** +```typescript +// Add to Settings interface: +externalImages: 'always' | 'never' | 'homeserver'; + +// Add to defaultSettings: +externalImages: 'never', +``` + +**`config.json` (owl deployment):** +```json +{ + "defaultSettings": { + "externalImages": "homeserver" + } +} +``` + +### Rendering Layer + +**`src/app/plugins/react-custom-html-parser.tsx`:** +Add `allowExternalImages?: boolean` to the params type. When false, revert to the original behavior (convert external img to `` link). + +**`src/app/features/room/RoomTimeline.tsx`:** +Read the `externalImages` setting, check room context, compute the boolean `allowExternalImages`, pass it into `getReactCustomHtmlParser` params. + +```typescript +const [externalImages] = useSetting(settingsAtom, 'externalImages'); +const allowExternalImages = useMemo(() => { + if (externalImages === 'always') return true; + if (externalImages === 'never') return false; + // 'homeserver': check if room belongs to our homeserver + const roomDomain = room.roomId.split(':')[1]; + return roomDomain === mx.getDomain(); +}, [externalImages, room.roomId, mx]); +``` + +**`src/app/utils/sanitize.ts`:** +`sanitizeCustomHtml` needs the flag too, since it runs before the React parser. Either: +- Pass it as a parameter: `sanitizeCustomHtml(html, { allowExternalImages })` +- Or move all image logic to the React parser (simpler β€” sanitizer just passes img tags through, parser decides what to render) + +The second approach is cleaner: the sanitizer allows img tags with any src (already done), and the React parser decides whether to render or convert to link based on the flag. + +### Settings UI + +Add a dropdown in `src/app/features/settings/general/General.tsx` under the "Media" or "Privacy" section: + +``` +External Images: [Always / Homeserver Only / Never] +``` + +### Files to Touch + +| File | Change | +|------|--------| +| `src/app/state/settings.ts` | Add `externalImages` to Settings + defaults | +| `src/app/plugins/react-custom-html-parser.tsx` | Accept + check `allowExternalImages` param | +| `src/app/features/room/RoomTimeline.tsx` | Compute flag from setting + room context, pass to parser | +| `src/app/features/settings/general/General.tsx` | Add UI dropdown | +| `src/app/utils/sanitize.ts` | Already done β€” img tags pass through | + +### What About the Sanitizer? + +Current state: we already removed the imgβ†’link conversion in `sanitize.ts`. For the settings-based approach, we have two options: + +**Option 1 (simpler):** Leave sanitize.ts as-is (allows all img src through). The React parser handles the allow/block decision. This means the sanitized HTML contains `` but the React parser may convert it to a link at render time. + +**Option 2 (stricter):** Restore the original sanitize.ts behavior and only bypass it when `allowExternalImages` is true. This requires passing options to `sanitizeCustomHtml`, which changes its signature and every call site. + +**Recommendation:** Option 1. The sanitizer's job is preventing XSS, not policy decisions. Let the React parser handle the rendering policy β€” it already has all the context it needs. diff --git a/src/app/features/message-search/SearchResultGroup.tsx b/src/app/features/message-search/SearchResultGroup.tsx index 14817cc4..49d329b4 100644 --- a/src/app/features/message-search/SearchResultGroup.tsx +++ b/src/app/features/message-search/SearchResultGroup.tsx @@ -5,6 +5,7 @@ import { HTMLReactParserOptions } from 'html-react-parser'; import { Avatar, Box, Chip, Header, Icon, Icons, Text, config } from 'folds'; import { Opts as LinkifyOpts } from 'linkifyjs'; import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useAllowExternalImages } from '../../../owl/hooks/useAllowExternalImages'; import { factoryRenderLinkifyWithMention, getReactCustomHtmlParser, @@ -76,6 +77,7 @@ export function SearchResultGroup({ }: SearchResultGroupProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); + const allowExternalImages = useAllowExternalImages(mx, room); const highlightRegex = useMemo(() => makeHighlightRegex(highlights), [highlights]); const powerLevels = usePowerLevels(room); @@ -106,6 +108,7 @@ export function SearchResultGroup({ linkifyOpts, highlightRegex, useAuthentication, + allowExternalImages, handleSpoilerClick: spoilerClickHandler, handleMentionClick: mentionClickHandler, }), @@ -117,6 +120,7 @@ export function SearchResultGroup({ mentionClickHandler, spoilerClickHandler, useAuthentication, + allowExternalImages, ] ); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 39d7e50a..6dd05b0d 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -116,6 +116,7 @@ import { useMentionClickHandler } from '../../hooks/useMentionClickHandler'; import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler'; import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useAllowExternalImages } from '../../../owl/hooks/useAllowExternalImages'; import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { useImagePackRooms } from '../../hooks/useImagePackRooms'; import { useIsDirectRoom } from '../../hooks/useRoom'; @@ -447,6 +448,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview; const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools'); + const allowExternalImages = useAllowExternalImages(mx, room); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); @@ -528,10 +530,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli getReactCustomHtmlParser(mx, room.roomId, { linkifyOpts, useAuthentication, + allowExternalImages, handleSpoilerClick: spoilerClickHandler, handleMentionClick: mentionClickHandler, }), - [mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication] + [mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication, allowExternalImages] ); const parseMemberEvent = useMemberEventParser(); diff --git a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx index 9986849f..223a0cc3 100644 --- a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx +++ b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx @@ -63,6 +63,7 @@ import { RenderMatrixEvent, useMatrixEventRenderer } from '../../../hooks/useMat import { RenderMessageContent } from '../../../components/RenderMessageContent'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; +import { useAllowExternalImages } from '../../../../owl/hooks/useAllowExternalImages'; import * as customHtmlCss from '../../../styles/CustomHtml.css'; import { EncryptedContent } from '../message'; import { Image } from '../../../components/media'; @@ -273,6 +274,7 @@ export const RoomPinMenu = forwardRef( const useAuthentication = useMediaAuthentication(); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); + const allowExternalImages = useAllowExternalImages(mx, room); const direct = useIsDirectRoom(); const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor'); @@ -307,10 +309,11 @@ export const RoomPinMenu = forwardRef( getReactCustomHtmlParser(mx, room.roomId, { linkifyOpts, useAuthentication, + allowExternalImages, handleSpoilerClick: spoilerClickHandler, handleMentionClick: mentionClickHandler, }), - [mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler, useAuthentication] + [mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler, useAuthentication, allowExternalImages] ); const renderMatrixEvent = useMatrixEventRenderer<[MatrixEvent, string, GetContentCallback]>( diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index b861a060..fdede5b9 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -32,7 +32,7 @@ import FocusTrap from 'focus-trap-react'; import { Page, PageContent, PageHeader } from '../../../components/page'; import { SequenceCard } from '../../../components/sequence-card'; import { useSetting } from '../../../state/hooks/settings'; -import { DateFormat, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings'; +import { DateFormat, ExternalImageMode, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings'; import { SettingTile } from '../../../components/setting-tile'; import { KeySymbol } from '../../../utils/key-symbol'; import { isMacOS } from '../../../utils/user-agent'; @@ -879,6 +879,80 @@ function SelectMessageSpacing() { ); } +const externalImageItems: { mode: ExternalImageMode; name: string }[] = [ + { mode: 'never', name: 'Never' }, + { mode: 'homeserver', name: 'Homeserver Only' }, + { mode: 'always', name: 'Always' }, +]; + +function SelectExternalImages() { + const [menuCords, setMenuCords] = useState(); + const [externalImages, setExternalImages] = useSetting(settingsAtom, 'externalImages'); + + const handleMenu: MouseEventHandler = (evt) => { + setMenuCords(evt.currentTarget.getBoundingClientRect()); + }; + + const handleSelect = (mode: ExternalImageMode) => { + setExternalImages(mode); + setMenuCords(undefined); + }; + + return ( + <> + + setMenuCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => + evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => + evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + + + {externalImageItems.map((item) => ( + handleSelect(item.mode)} + > + {item.name} + + ))} + + + + } + /> + + ); +} + function Messages() { const [legacyUsernameColor, setLegacyUsernameColor] = useSetting( settingsAtom, @@ -966,6 +1040,12 @@ function Messages() { after={} /> + + } + /> + ( diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index 97e2c99a..bbed7275 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -319,6 +319,7 @@ export const getReactCustomHtmlParser = ( handleSpoilerClick?: ReactEventHandler; handleMentionClick?: ReactEventHandler; useAuthentication?: boolean; + allowExternalImages?: boolean; } ): HTMLReactParserOptions => { const opts: HTMLReactParserOptions = { @@ -475,9 +476,17 @@ export const getReactCustomHtmlParser = ( if (name === 'img') { const isMxc = typeof props.src === 'string' && props.src.startsWith('mxc://'); + const isExternal = !isMxc && typeof props.src === 'string'; const htmlSrc = isMxc ? mxcUrlToHttp(mx, props.src, params.useAuthentication) : props.src; + if (isExternal && !params.allowExternalImages) { + return ( + + {props.alt || props.title || props.src} + + ); + } if (htmlSrc && 'data-mx-emoticon' in props) { return ( diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 9603fdec..31ad88b7 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -15,6 +15,8 @@ export enum MessageLayout { Bubble = 2, } +export type ExternalImageMode = 'always' | 'homeserver' | 'never'; + export interface Settings { themeId?: string; useSystemTheme: boolean; @@ -39,6 +41,7 @@ export interface Settings { encUrlPreview: boolean; showHiddenEvents: boolean; legacyUsernameColor: boolean; + externalImages: ExternalImageMode; showNotifications: boolean; isNotificationSounds: boolean; @@ -73,6 +76,7 @@ const defaultSettings: Settings = { encUrlPreview: false, showHiddenEvents: false, legacyUsernameColor: false, + externalImages: 'never', showNotifications: true, isNotificationSounds: true, diff --git a/src/owl/hooks/useAllowExternalImages.ts b/src/owl/hooks/useAllowExternalImages.ts new file mode 100644 index 00000000..158c8b66 --- /dev/null +++ b/src/owl/hooks/useAllowExternalImages.ts @@ -0,0 +1,14 @@ +import { useMemo } from 'react'; +import { MatrixClient, Room } from 'matrix-js-sdk'; +import { useSetting } from '../../app/state/hooks/settings'; +import { settingsAtom } from '../../app/state/settings'; + +export function useAllowExternalImages(mx: MatrixClient, room: Room): boolean { + const [externalImages] = useSetting(settingsAtom, 'externalImages'); + return useMemo(() => { + if (externalImages === 'always') return true; + if (externalImages === 'never') return false; + const roomDomain = room.roomId.split(':')[1]; + return roomDomain === mx.getDomain(); + }, [externalImages, room.roomId, mx]); +}