# 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.