# 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 `<a>` 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 `<img src="https://...">` 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.
