Strict routing: a Tor or proxy account's media goes through its route (#257)

Each account row now has a three-position control in place of the Tor
switch: Clearnet, Service tunnel (the protocol and the media the daemon
fetches for it), and Strict. On Strict every remote URL the window would
load for that account's buffers - pictures and video in messages, embeds,
avatars, emoji, reactions, the link check - is rewritten to
moho-routed://fetch/?u=<url>. The main process answers it from a session of
its own whose proxy is the daemon's route, with hostnames resolved by the
proxy. It fails closed: no route yet means the picture does not draw, never
a direct fetch.

Verified against a logging SOCKS proxy and a web server: a link posted into
a strict IRC channel reached the server only through the proxy, and the
window made no direct request.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-03 00:13:19 -04:00
co-authored by Claude Opus 5.5
parent a01930939b
commit cee8604d5f
34 changed files with 441 additions and 74 deletions
+1 -1
Submodule nobilis updated: 7976452332...a3897bab95
+4
View File
@@ -33,6 +33,7 @@ import { defaultDownloadDir, saveMedia } from './downloads'
import type { Buffer as ChatBuffer } from '../shared/wire'
import { log } from './log'
import { applyPolicy, applyTunnel } from './tunnel'
import { installRoutedHandler, resetRoute } from './routed'
registerMediaScheme()
@@ -965,6 +966,7 @@ app.whenReady().then(() => {
// Rail icons somebody chose, copied here after being checked as images.
allowRoot(path.join(app.getPath('userData'), 'group-icons'))
installMediaHandler()
installRoutedHandler((method, params) => client.request(method, params))
prefs = new Prefs()
nobilis = new NobilisProcess()
@@ -999,6 +1001,7 @@ app.whenReady().then(() => {
// Before anything is fetched for the window: whether its own traffic goes
// through Tor or the proxy is the daemon's setting, asked of it here.
void applyTunnel((method, params) => client.request(method, params))
resetRoute()
// The daemon usually outlives this process, so its buffers were announced
// long before this connection existed and no bufferListChange is coming
// for them. Without asking outright, main knows of no conversations at
@@ -1024,6 +1027,7 @@ app.whenReady().then(() => {
void notifier.handle(frame.data)
} else if (frame.event === 'netSettings') {
void applyTunnel((method, params) => client.request(method, params))
resetRoute()
}
send(IPC.event, frame)
})
+7
View File
@@ -252,6 +252,13 @@ export function registerMediaScheme(): void {
// this scheme cross-origin, and an <img> needs no permission where a
// fetch() does. A Lottie sticker is JSON read by script.
privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true, corsEnabled: true }
},
// Registered together because registration happens once: remote media
// for a strictly routed account, fetched through its route - see
// routed.ts.
{
scheme: 'moho-routed',
privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true, corsEnabled: true }
}
])
}
+116
View File
@@ -0,0 +1,116 @@
import { protocol, session } from 'electron'
import { log } from './log'
/**
* Remote media for a strictly routed account, fetched through its route.
*
* An account's routing has three positions (#257). The first two only decide
* how the daemon reaches the service; everything the window loads for itself
* - pictures and video linked in a message, avatars, emoji, embeds, the link
* check that decides whether a URL is a picture - is Chromium's, and goes out
* directly from the user's own address. For an account on Tor that is the
* leak routing it was meant to prevent: anybody in the channel can post a link
* to a server of theirs and learn the reader's IP the moment it arrives.
*
* Strict closes it. The window rewrites every remote URL it loads for such an
* account to `moho-routed://fetch/?u=<url>`, and this handler fetches it
* through a session of its own, pointed at the daemon's route: moho's Tor, or
* the proxy the user named. Hostnames go to the proxy unresolved, so the
* lookup does not leak either.
*
* Fails closed. With no route yet - Tor still starting, a proxy not answering
* - the answer is an error and the picture does not draw, rather than being
* fetched directly.
*/
export const ROUTED_SCHEME = 'moho-routed'
type Request = (method: string, params?: Record<string, unknown>) => Promise<unknown>
/** Only what a picture, a video or a link check needs to read. */
const PASSED_HEADERS = [
'content-type',
'content-length',
'content-range',
'accept-ranges',
'cache-control',
'last-modified',
'etag'
]
let configured = false
let configuring: Promise<boolean> | null = null
let ask: Request | null = null
/** In memory only: nothing it is sent is kept, and it holds no cookies. */
function routedSession(): Electron.Session {
return session.fromPartition(ROUTED_SCHEME)
}
/** Points the session at the route, once, and again after a change. */
function ensureRoute(): Promise<boolean> {
if (configured) return Promise.resolve(true)
if (configuring) return configuring
configuring = (async () => {
try {
if (!ask) return false
const answer = (await ask('netRoute')) as { socks?: string }
if (!answer?.socks) return false
await routedSession().setProxy({ proxyRules: `socks5://${answer.socks}` })
configured = true
log.info(`routed: strict media through ${answer.socks}`)
return true
} catch (e) {
log.warn(`routed: no route for strict media, holding it back: ${(e as Error).message}`)
return false
} finally {
configuring = null
}
})()
return configuring
}
/** The route may have moved - Tor restarted, a proxy changed. Asked afresh. */
export function resetRoute(): void {
configured = false
}
export function installRoutedHandler(request: Request): void {
ask = request
protocol.handle(ROUTED_SCHEME, async (req) => {
const target = new URL(req.url).searchParams.get('u') ?? ''
// Web addresses only. Anything else is not a thing to fetch on the
// network's behalf, and a local path has its own, guarded scheme.
if (!/^https?:\/\//i.test(target)) return new Response('bad request', { status: 400 })
if (!(await ensureRoute())) return new Response('no route', { status: 502 })
try {
const headers = new Headers()
const range = req.headers.get('range')
if (range) headers.set('range', range)
const res = await routedSession().fetch(target, {
method: req.method === 'HEAD' ? 'HEAD' : 'GET',
headers,
credentials: 'omit',
redirect: 'follow'
})
const out = new Headers()
for (const name of PASSED_HEADERS) {
const value = res.headers.get(name)
if (value) out.set(name, value)
}
// Readable by the page as well as drawable, as the media scheme is: the
// link check reads the content type.
out.set('Access-Control-Allow-Origin', '*')
return new Response(req.method === 'HEAD' ? null : res.body, {
status: res.status,
statusText: res.statusText,
headers: out
})
} catch (e) {
// A dead route looks like this too; ask again next time.
configured = false
log.warn(`routed: fetching through the route failed: ${(e as Error).message}`)
return new Response('route failed', { status: 502 })
}
})
}
+1 -1
View File
@@ -12,7 +12,7 @@
<title>moho</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' data: blob: https: moho-media:; media-src 'self' blob: https: moho-media:; font-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' moho-media: https: wss: ws://localhost:* http://localhost:*"
content="default-src 'self'; img-src 'self' data: blob: https: moho-media: moho-routed:; media-src 'self' blob: https: moho-media: moho-routed:; font-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' moho-media: moho-routed: https: wss: ws://localhost:* http://localhost:*"
/>
</head>
<body>
+57
View File
@@ -8272,3 +8272,60 @@ video.lightbox-media {
align-items: center;
margin: 0 2px;
}
/* An account's routing: three positions on one track, the chosen one under
a sliding thumb (#257). */
.route-level-head {
font-weight: 500;
}
.route-level {
position: relative;
display: grid;
grid-template-columns: repeat(3, 1fr);
margin-top: var(--spacing-xs);
padding: 3px;
border-radius: 999px;
background: var(--surface-container-high);
}
.route-level-thumb {
position: absolute;
top: 3px;
bottom: 3px;
left: 3px;
width: calc((100% - 6px) / 3);
border-radius: 999px;
background: var(--primary-container);
transform: translateX(calc(var(--route-index, 0) * 100%));
transition: transform 160ms ease;
}
.route-level-option {
position: relative;
z-index: 1;
padding: 5px 8px;
border: none;
border-radius: 999px;
background: none;
color: var(--surface-variant-text);
font: inherit;
font-size: var(--font-size-small);
cursor: pointer;
}
.route-level-option.on {
color: var(--primary-text);
font-weight: 500;
}
.route-level-desc {
margin: var(--spacing-xs) 0 0;
max-width: 60ch;
}
@media (prefers-reduced-motion: reduce) {
.route-level-thumb {
transition: none;
}
}
+61 -11
View File
@@ -1,3 +1,4 @@
import { useMediaUrl } from '../lib/route'
import { useEffect, useState } from 'react'
import { Icon, IconButton, MaskIcon } from './Icon'
import { MatrixAccountTools } from './MatrixAccountTools'
@@ -296,6 +297,7 @@ function GroupIcons({ accountId }: { accountId: string }): JSX.Element | null {
}
function AccountRow({ account }: { account: Account }): JSX.Element {
const media = useMediaUrl(account.id)
const store = useStore()
const [expanded, setExpanded] = useState(false)
const icon = serviceIcon(account.service)
@@ -325,7 +327,7 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
<div className="account-card">
<div className="account-card-head">
{account.avatarUrl ? (
<img className="account-avatar" src={resolveMediaUrl(account.avatarUrl)} alt="" />
<img className="account-avatar" src={media(account.avatarUrl)} alt="" />
) : icon.mark ? (
<MaskIcon src={icon.mark} size={20} />
) : (
@@ -437,11 +439,7 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
)}
{account.service !== 'sneedchat' && (
<TorSwitch
service={account.service}
on={account.useTor}
onChange={(useTor) => call('setAccountRouted', { accountId: account.id, enabled: useTor })}
/>
<RouteLevel account={account} onChange={(level) => call('setAccountRoute', { accountId: account.id, level })} />
)}
{account.service === 'discord' && <DiscordReauth account={account} />}
@@ -450,11 +448,7 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
{account.service === 'sneedchat' && (
<>
<TorSwitch
service="sneedchat"
on={account.useTor}
onChange={(useTor) => call('setAccountRouted', { accountId: account.id, enabled: useTor })}
/>
<RouteLevel account={account} onChange={(level) => call('setAccountRoute', { accountId: account.id, level })} />
<SneedChatBrowserLogin accountId={account.id} useTor={account.useTor} />
</>
)}
@@ -1186,6 +1180,62 @@ function KickForm({ onDone }: { onDone: () => void }): JSX.Element {
* differs by service, and somebody turning it on should know before the
* connection fails rather than after. Shown only when it is on.
*/
type Level = 'clearnet' | 'service' | 'strict'
const LEVELS: { id: Level; label: string }[] = [
{ id: 'clearnet', label: 'Clearnet' },
{ id: 'service', label: 'Service tunnel' },
{ id: 'strict', label: 'Strict' }
]
/**
* Where an account's traffic goes, in three positions (#257).
*
* The middle one is what a Tor switch used to mean: the connection to the
* service, and the media the daemon fetches for it, go through the route -
* but everything the window loads for itself (a linked picture, an avatar,
* the check on a link) goes out directly, and anybody who can post a link
* learns the address. Strict closes that: those go through the route too, and
* nothing loads if the route is down.
*/
function RouteLevel({ account, onChange }: { account: Account; onChange: (level: Level) => void }): JSX.Element {
const level: Level = account.routeLevel ?? (account.useTor ? 'service' : 'clearnet')
const sneedchat = account.service === 'sneedchat'
const via = sneedchat ? 'Tor' : 'Tor or the SOCKS5 proxy'
const describe: Record<Level, string> = {
clearnet: sneedchat
? 'Reaches kiwifarms.st directly over the open internet. Tor is not started.'
: 'Connects directly. Tor is not started.',
service: `The connection, and the media moho fetches for it, go through ${via}. Pictures, links and avatars the window loads itself still come directly from your address.`,
strict: `Everything for this account goes through ${via} - the connection, and every picture, link check, avatar and emoji its conversations load. If the route is down, they do not load.`
}
const index = LEVELS.findIndex((l) => l.id === level)
return (
<div className="tor-switch">
<div className="route-level-head">
<span>Routing</span>
</div>
<div className="route-level" role="radiogroup" aria-label="Routing" style={{ '--route-index': index } as React.CSSProperties}>
<span className="route-level-thumb" aria-hidden="true" />
{LEVELS.map((l) => (
<button
key={l.id}
type="button"
role="radio"
aria-checked={level === l.id}
className={level === l.id ? 'route-level-option on' : 'route-level-option'}
onClick={() => level !== l.id && onChange(l.id)}
>
{l.label}
</button>
))}
</div>
<p className="small muted route-level-desc">{describe[level]}</p>
{level !== 'clearnet' && <TorWarning service={account.service} />}
</div>
)
}
function TorSwitch({
service,
on,
@@ -1,8 +1,9 @@
import { useMediaUrl } from '../lib/route'
import { useEffect, useRef, useState } from 'react'
import { Icon, IconButton } from './Icon'
import { HeaderPopover } from './HeaderPopover'
import { useChat, useStore } from '../state/hooks'
import { classes, resolveMediaUrl } from '../lib/util'
import { classes } from '../lib/util'
import type { BufferEntry } from '../state/store'
import type { DiscordFriend } from '../../../shared/wire'
@@ -24,6 +25,7 @@ const GROUP_DM_MAX_OTHERS = 9
* their conversation appears to have forked.
*/
export function AddToConversation({ buffer }: { buffer: BufferEntry }): JSX.Element | null {
const media = useMediaUrl(buffer.accountId)
const store = useStore()
const roster = useChat((s) => s.presenceByBuffer[buffer.id])
const [open, setOpen] = useState(false)
@@ -149,7 +151,7 @@ export function AddToConversation({ buffer }: { buffer: BufferEntry }): JSX.Elem
size={16}
/>
{f.avatarUrl ? (
<img className="friend-avatar" src={resolveMediaUrl(f.avatarUrl)} alt="" />
<img className="friend-avatar" src={media(f.avatarUrl)} alt="" />
) : (
<span className="friend-avatar placeholder">
{(f.globalName || f.username).slice(0, 1)}
+9 -3
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { resolveMediaUrl, nickColor } from '../lib/util'
import { useMediaUrl } from '../lib/route'
import { nickColor } from '../lib/util'
import { presenceColor, presenceLabel } from '../lib/presence'
/**
@@ -19,13 +20,18 @@ export function Avatar({
name,
url,
size = 24,
status
status,
accountId
}: {
name: string
url?: string
size?: number
status?: string
/** Whose picture: a strictly routed account's is fetched through its route
* (#257). Taken from the surrounding conversation when not given. */
accountId?: string
}): JSX.Element {
const media = useMediaUrl(accountId)
// A picture that will not load falls back to the initial rather than to a
// broken-image glyph. Not hypothetical: a room directory lists icons hosted
// on servers that may refuse to hand them over, and a column of broken
@@ -36,7 +42,7 @@ export function Avatar({
<span className="avatar" style={{ width: size, height: size }}>
{url && !broken ? (
<img
src={resolveMediaUrl(url)}
src={media(url)}
alt=""
style={{ width: size, height: size }}
// A member list or a long log is hundreds of these, most of them
+1 -1
View File
@@ -17,7 +17,7 @@ export function BufferFace({ buffer }: { buffer: BufferEntry }): JSX.Element | n
return (
<span className="header-face">
<Avatar name={buffer.name} url={buffer.avatarUrl} size={24} status={dmStatus(buffer, presence, buffers)} />
<Avatar name={buffer.name} url={buffer.avatarUrl} size={24} status={dmStatus(buffer, presence, buffers)} accountId={buffer.accountId} />
</span>
)
}
+6 -3
View File
@@ -1,3 +1,4 @@
import { useMediaUrl } from '../lib/route'
import { useEffect, useMemo, useRef, useState } from 'react'
import { bufferLink } from '../lib/bufferlink'
import { Icon, IconButton, MaskIcon } from './Icon'
@@ -26,7 +27,6 @@ import {
bufferKindGlyph,
serviceRoomGlyph,
classes,
resolveMediaUrl,
serviceIcon
} from '../lib/util'
import type { Account, Member } from '../../../shared/wire'
@@ -873,6 +873,9 @@ function BufferRow({
}: BufferRowProps): JSX.Element {
const { menu, open, close } = useContextMenu()
const account = accounts.find((a) => a.id === buffer.accountId)
// A DM's picture comes off the service's CDN; through the route for a
// strictly routed account (#257).
const media = useMediaUrl(buffer.accountId)
// Whether this channel is on air. Selected down to the one boolean rather
// than the map, so a viewer count ticking over in one channel does not
// re-render every row in the list.
@@ -901,9 +904,9 @@ function BufferRow({
// A conversation with a person is headed by that person, whatever
// protocol they are on: their picture where there is one, their initial
// where there is not.
<Avatar name={buffer.name} url={buffer.avatarUrl} size={22} status={status} />
<Avatar name={buffer.name} url={buffer.avatarUrl} size={22} status={status} accountId={buffer.accountId} />
) : buffer.avatarUrl ? (
<img className="buffer-avatar" src={resolveMediaUrl(buffer.avatarUrl)} alt="" />
<img className="buffer-avatar" src={media(buffer.avatarUrl)} alt="" />
) : buffer.serviceRoom ? (
// The server's own room. Marked here because the fact worth knowing is
// that it is not one of the others - a notice about a quota is easy to
+3 -1
View File
@@ -1,3 +1,4 @@
import { useMediaUrl } from '../lib/route'
import { useEffect, useRef, useState } from 'react'
import { Icon } from './Icon'
import { useChat, useStore } from '../state/hooks'
@@ -534,6 +535,7 @@ interface SoundboardSound {
* need Nitro. Pressing one plays it to the whole channel, this end included.
*/
function Soundboard({ accountId }: { accountId: string }): JSX.Element {
const media = useMediaUrl(accountId)
const store = useStore()
const [sounds, setSounds] = useState<SoundboardSound[] | null>(null)
const [query, setQuery] = useState('')
@@ -581,7 +583,7 @@ function Soundboard({ accountId }: { accountId: string }): JSX.Element {
onClick={() => play(s)}
>
{s.emojiId ? (
<img src={`https://cdn.discordapp.com/emojis/${s.emojiId}.webp?size=32`} alt="" />
<img src={media(`https://cdn.discordapp.com/emojis/${s.emojiId}.webp?size=32`)} alt="" />
) : s.emojiName ? (
<span className="soundboard-emoji">{s.emojiName}</span>
) : (
+3 -1
View File
@@ -1,3 +1,4 @@
import { useMediaUrl } from '../lib/route'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Icon, IconButton, MaskIcon } from './Icon'
import torMark from '../assets/tor.svg'
@@ -172,6 +173,7 @@ export function Composer(): JSX.Element | null {
const tunnelAll = useChat((s) => s.tunnelAll)
const route = useChat((s) => s.route)
const routed = tunnelAll || !!account?.useTor
const media = useMediaUrl(account?.id)
const hasStickers = service === 'matrix' || service === 'discord'
/**
* The account's stickers, asked for when the picker opens - from either
@@ -332,7 +334,7 @@ export function Composer(): JSX.Element | null {
if (preview) {
const img = document.createElement('img')
img.className = 'composer-emoji'
img.src = resolveMediaUrl(preview.src)
img.src = media(preview.src)
img.alt = preview.label
img.title = preview.label
// What leaves the box when the message is sent. The picture is for the
@@ -149,6 +149,7 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme
>
{buffer.kind === 'dm' || buffer.avatarUrl ? (
<Avatar
accountId={buffer.accountId}
name={buffer.name}
url={buffer.avatarUrl}
size={24}
@@ -1,3 +1,4 @@
import { useMediaUrl } from '../lib/route'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Icon, IconButton } from './Icon'
@@ -56,6 +57,7 @@ export function eventWhen(iso: string, now = new Date()): string {
export function EventsPane(): JSX.Element | null {
const store = useStore()
const pane = useChat((s) => s.eventsPane)
const media = useMediaUrl(pane?.accountId)
const counts = useChat((s) => s.discordEventCounts)
const [events, setEvents] = useState<DiscordEvent[] | null>(null)
const [menu, setMenu] = useState<{ x: number; y: number; event: DiscordEvent } | null>(null)
@@ -157,13 +159,13 @@ export function EventsPane(): JSX.Element | null {
)}
{events?.map((event) => (
<div key={event.id} className={`event-card${event.live ? ' live' : ''}`}>
{event.image && <img className="event-card-image" src={event.image} alt="" />}
{event.image && <img className="event-card-image" src={media(event.image)} alt="" />}
<div className="event-card-body">
<div className="event-card-when">
<Icon name={event.live ? 'radio_button_checked' : 'calendar_month'} size={16} />
<span>{event.live ? 'Happening now' : eventWhen(event.start)}</span>
<span className="events-pane-spacer" />
{event.creatorName && <Avatar name={event.creatorName} url={event.creatorAvatar ?? undefined} size={20} />}
{event.creatorName && <Avatar name={event.creatorName} url={event.creatorAvatar ?? undefined} size={20} accountId={pane.accountId} />}
<span className="event-card-count" title={`${event.userCount} interested`}>
<Icon name="group" size={14} />
{event.userCount}
@@ -536,7 +538,7 @@ function ShareEvent({
const state = sent[t.userId]
return (
<div key={t.userId} className="share-row">
<Avatar name={t.globalName || t.username} url={t.avatarUrl ?? undefined} size={32} />
<Avatar name={t.globalName || t.username} url={t.avatarUrl ?? undefined} size={32} accountId={accountId} />
<span className="share-row-name">
<span className="ellipsis">{t.globalName || t.username}</span>
<span className="small muted ellipsis">{t.username}</span>
+12 -9
View File
@@ -1,6 +1,6 @@
import React, { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { resolveMediaUrl } from '../lib/util'
import { useMediaUrl } from '../lib/route'
import { Icon } from './Icon'
import { discordEmojiUrl, emojiPreview, type SmilieEntry } from '../lib/format'
import { drawableEmoteUrl, onLocalEmotes } from '../lib/emotecache'
@@ -212,6 +212,9 @@ export function EmojiPicker({
onSticker,
onClose
}: Props): JSX.Element {
// The account's emoji and stickers through its route when it is strictly
// routed (#257) - Discord's come straight off its CDN otherwise.
const media = useMediaUrl(accountId)
const ref = useRef<HTMLDivElement>(null)
const [tab, setTab] = useState<'emoji' | 'stickers'>(onSticker ? initialTab : 'emoji')
const stickersOnly = tab === 'stickers'
@@ -459,7 +462,7 @@ export function EmojiPicker({
/** A sticker as a picture: an image, or a Lottie animation that plays on hover. */
const stickerArt = (sticker: StickerEntry, size: number, play: 'hover' | 'never'): React.ReactNode =>
sticker.url ? (
<img src={resolveMediaUrl(sticker.url)} alt={sticker.name} loading="lazy" />
<img src={media(sticker.url)} alt={sticker.name} loading="lazy" />
) : sticker.lottie && sticker.id && accountId ? (
<LottieSticker accountId={accountId} stickerId={sticker.id} size={size} play={play} label={sticker.name} />
) : (
@@ -502,7 +505,7 @@ export function EmojiPicker({
id: src.id,
name: src.name,
icon: src.iconUrl ? (
<img src={resolveMediaUrl(src.iconUrl)} alt="" />
<img src={media(src.iconUrl)} alt="" />
) : (
<span className="emoji-jump-initial">{src.name.slice(0, 1).toUpperCase()}</span>
)
@@ -630,7 +633,7 @@ export function EmojiPicker({
onClick={() => pick(r)}
>
{preview ? (
<img src={resolveMediaUrl(preview.src)} alt={preview.label} />
<img src={media(preview.src)} alt={preview.label} />
) : (
r
)}
@@ -663,7 +666,7 @@ export function EmojiPicker({
// its own label and the section drew as a smear of overlapping
// words instead of a table of faces.
const art = e.url
? resolveMediaUrl(drawableEmoteUrl(e.url))
? media(drawableEmoteUrl(e.url))
: src.service === 'discord'
? discordEmojiUrl(e.id, 48)
: emojiPreview(e.id, smilies)?.src
@@ -680,7 +683,7 @@ export function EmojiPicker({
title={why || e.name}
onClick={() => pick(e.id)}
>
{art ? <img src={art} alt={e.name} loading="lazy" /> : e.name}
{art ? <img src={media(art)} alt={e.name} loading="lazy" /> : e.name}
</button>
)
})}
@@ -747,7 +750,7 @@ export function EmojiPicker({
// renders it and it goes out as this literal text.
onClick={() => pick(emojiToken(e))}
>
<img src={emojiImage(e)} alt={e.name} />
<img src={media(emojiImage(e))} alt={e.name} />
</button>
))}
</Section>
@@ -763,7 +766,7 @@ export function EmojiPicker({
title={s.label}
onClick={() => pick(s.aliases[0] || s.label)}
>
{s.url ? <img src={resolveMediaUrl(s.url)} alt={s.label} /> : s.aliases[0]}
{s.url ? <img src={media(s.url)} alt={s.label} /> : s.aliases[0]}
</button>
))}
</Section>
@@ -790,7 +793,7 @@ export function EmojiPicker({
{hovered ? (
<>
{hovered.src ? (
<img src={hovered.src} alt="" />
<img src={media(hovered.src)} alt="" />
) : hovered.text ? (
<span className="emoji-footer-glyph">{hovered.text}</span>
) : null}
@@ -93,7 +93,7 @@ export function ForwardPicker({
disabled={!!sending}
onClick={() => send(buffer.id)}
>
<Avatar name={buffer.name} url={buffer.avatarUrl} size={22} />
<Avatar name={buffer.name} url={buffer.avatarUrl} size={22} accountId={buffer.accountId} />
<span className="ellipsis">{bufferDisplayName(buffer.name)}</span>
<span className="small muted ellipsis">
{account?.displayName ?? ''}
@@ -48,7 +48,7 @@ export function IncomingCallPanel(): JSX.Element | null {
const name = buffer ? bufferDisplayName(buffer.name) : 'Someone'
return (
<div key={call.bufferId} className="incoming-call">
<Avatar name={name} url={buffer?.avatarUrl} size={44} />
<Avatar name={name} url={buffer?.avatarUrl} size={44} accountId={buffer?.accountId} />
<div className="incoming-call-who">
<span className="ellipsis incoming-call-name">{name}</span>
<span className="small muted ellipsis">
+5 -3
View File
@@ -1,9 +1,10 @@
import { useMediaUrl } from '../lib/route'
import { useEffect, useState } from 'react'
import { Icon } from './Icon'
import { ContextMenu } from './ContextMenu'
import { RoomSearch } from './RoomSearch'
import { useChat, useStore } from '../state/hooks'
import { classes, resolveMediaUrl } from '../lib/util'
import { classes } from '../lib/util'
import type { Account, DiscordFriend } from '../../../shared/wire'
/**
@@ -658,6 +659,7 @@ function SneedchatRooms({ account }: { account: Account }): JSX.Element {
* the friends list rather than a search box.
*/
function DiscordJoin({ account }: { account: Account }): JSX.Element {
const media = useMediaUrl(account.id)
const store = useStore()
const [friends, setFriends] = useState<DiscordFriend[]>([])
const [onlineOnly, setOnlineOnly] = useState(true)
@@ -761,7 +763,7 @@ function DiscordJoin({ account }: { account: Account }): JSX.Element {
{waiting.map((f) => (
<div key={f.userId} className="friend-row request">
{f.avatarUrl ? (
<img className="friend-avatar" src={resolveMediaUrl(f.avatarUrl)} alt="" />
<img className="friend-avatar" src={media(f.avatarUrl)} alt="" />
) : (
<span className="friend-avatar placeholder">{(f.globalName || f.username).slice(0, 1)}</span>
)}
@@ -885,7 +887,7 @@ function DiscordJoin({ account }: { account: Account }): JSX.Element {
<Icon name={picking.includes(f.userId) ? 'check_circle' : 'radio_button_unchecked'} size={16} />
)}
{f.avatarUrl ? (
<img className="friend-avatar" src={resolveMediaUrl(f.avatarUrl)} alt="" />
<img className="friend-avatar" src={media(f.avatarUrl)} alt="" />
) : (
<span className="friend-avatar placeholder">{(f.globalName || f.username).slice(0, 1)}</span>
)}
+10 -8
View File
@@ -4,7 +4,7 @@ import { Lightbox } from './Lightbox'
import { VoiceMessage } from './VoiceMessage'
import { LottieSticker, lottieStickerId } from './LottieSticker'
import { fullImageFor, knownFullImage } from '../lib/fullimage'
import { resolveMediaUrl } from '../lib/util'
import { useMediaUrl } from '../lib/route'
import type { MediaItem } from '../lib/format'
import type { Attachment } from '../../../shared/wire'
@@ -90,6 +90,8 @@ export function MediaEmbed({
onRefresh,
onStale
}: Props): JSX.Element | null {
// Through the account's route when it is strictly routed (#257).
const media = useMediaUrl()
const [failed, setFailed] = useState(false)
/**
* The locally cached preview is gone, so fall back to the original.
@@ -223,7 +225,7 @@ export function MediaEmbed({
title={refreshError || 'Link expired — click to reload from Discord'}
onClick={refresh}
>
<img src={resolveMediaUrl(preview)} alt={attachment?.filename || ''} loading="lazy" />
<img src={media(preview)} alt={attachment?.filename || ''} loading="lazy" />
<span className="expired-overlay small">
<Icon name={refreshing ? 'hourglass_empty' : 'refresh'} size={18} />
{refreshing ? 'Reloading…' : refreshError || 'Click to reload'}
@@ -318,7 +320,7 @@ export function MediaEmbed({
<img
className="media-embed"
style={ratio}
src={resolveMediaUrl(showingPreview ? previewSrc : fullSrc)}
src={media(showingPreview ? previewSrc : fullSrc)}
alt={attachment?.filename || ''}
title={refreshError || attachment?.filename}
loading="lazy"
@@ -349,7 +351,7 @@ export function MediaEmbed({
<Lightbox
source={{
kind: 'image',
src: resolveMediaUrl(expandedSrc),
src: media(expandedSrc),
externalUrl: openTarget,
filename: attachment?.filename,
width: attachment?.width,
@@ -375,7 +377,7 @@ export function MediaEmbed({
<video
className="media-embed"
style={ratio}
src={resolveMediaUrl(fullSrc)}
src={media(fullSrc)}
controls
// Enough to draw the first frame and know how long it is, without
// pulling megabytes for every video in a scrolled-past backlog.
@@ -397,7 +399,7 @@ export function MediaEmbed({
<Lightbox
source={{
kind: 'video',
src: resolveMediaUrl(fullSrc),
src: media(fullSrc),
externalUrl: openTarget,
filename: attachment?.filename,
loop,
@@ -422,7 +424,7 @@ export function MediaEmbed({
return (
<div className="media-embed audio">
<span className="small ellipsis">{attachment?.filename || 'audio'}</span>
<audio src={resolveMediaUrl(fullSrc)} controls onError={() => setFailed(true)} />
<audio src={media(fullSrc)} controls onError={() => setFailed(true)} />
</div>
)
}
@@ -447,7 +449,7 @@ export function MediaEmbed({
return (
<button type="button" className="media-embed youtube" onClick={open} title={fullSrc}>
<img
src={`https://i.ytimg.com/vi/${item?.youtubeId}/hqdefault.jpg`}
src={media(`https://i.ytimg.com/vi/${item?.youtubeId}/hqdefault.jpg`)}
alt=""
loading="lazy"
onError={() => setFailed(true)}
@@ -1,3 +1,4 @@
import { accountOfBuffer } from '../lib/route'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Icon, IconButton } from './Icon'
import { HeaderPopover } from './HeaderPopover'
@@ -157,7 +158,7 @@ export function MentionsInbox(): JSX.Element {
onClick={() => buffer && void jumpTo(buffer.id, m.id)}
disabled={!buffer}
>
<Avatar name={m.from} url={m.avatarUrl} size={28} />
<Avatar name={m.from} url={m.avatarUrl} size={28} accountId={accountOfBuffer(m.bufferId)} />
<span className="mention-body">
<span className="mention-head small">
<span className="mention-from">{m.from}</span>
+2 -1
View File
@@ -1,3 +1,4 @@
import { accountOfBuffer } from '../lib/route'
import { Icon } from './Icon'
import { Avatar } from './Avatar'
import { useChat, usePref, useStore } from '../state/hooks'
@@ -66,7 +67,7 @@ export function MentionsPage(): JSX.Element {
}}
disabled={!buffer}
>
<Avatar name={m.from} url={m.avatarUrl} size={32} />
<Avatar name={m.from} url={m.avatarUrl} size={32} accountId={accountOfBuffer(m.bufferId)} />
<span className="mention-body">
<span className="mention-head small">
<span className="mention-from">{m.from}</span>
+8 -1
View File
@@ -7,6 +7,7 @@ import { CallView } from './CallView'
import { CallStage, RoomCallBar } from './CallStage'
import { StreamStage } from './StreamStage'
import { ServiceBanner } from './ServiceBanner'
import { RouteAccount, useStrictRoute } from '../lib/route'
import { useChat, usePref, useStore } from '../state/hooks'
import { bufferDisplayName, isChatKind } from '../lib/util'
import type { ChannelIndex } from '../lib/format'
@@ -219,6 +220,7 @@ export function MessageList(): JSX.Element {
const accountId = buffers.find((b) => b.id === bufferId)?.accountId || ''
const strictRoute = useStrictRoute(accountId || undefined)
const syncing = !!buffers.find((b) => b.id === bufferId)?.syncing
const all = messagesByBuffer[bufferId] || []
@@ -644,7 +646,11 @@ export function MessageList(): JSX.Element {
const dividerIndex = dividerTs > 0 ? messages.findIndex((m) => m.ts > dividerTs) : -1
return (
<div className="messagelist">
// Whose conversation this is, for everything drawn inside it: a strictly
// routed account's media goes through its route (#257). The attribute is
// for code outside React that loads pictures - the emote fallback.
<RouteAccount.Provider value={accountId || undefined}>
<div className="messagelist" data-route={strictRoute ? 'strict' : undefined}>
{/* First, because it explains everything under it: a conversation that
is not receiving looks like a quiet one otherwise. */}
<ServiceBanner bufferId={bufferId} />
@@ -783,6 +789,7 @@ export function MessageList(): JSX.Element {
</button>
)}
</div>
</RouteAccount.Provider>
)
}
+12 -7
View File
@@ -12,6 +12,7 @@ import { UploadMeter } from './UploadMeter'
import { RichText } from '../lib/richtext'
import { useChat, usePref, useStore } from '../state/hooks'
import { useSniffedTypes, sniffUrl } from '../lib/sniff'
import { useMediaUrl, useStrictRoute } from '../lib/route'
import type { ChatMessage } from '../state/store'
import type {
CustomEmoji,
@@ -47,8 +48,7 @@ import {
isNotice,
isReward,
isWhisper,
nickColor,
resolveMediaUrl
nickColor
} from '../lib/util'
/**
@@ -312,6 +312,10 @@ function MessageRowBody({
inThread,
shared
}: Props): JSX.Element {
// A strictly routed account's media and link checks go through its route
// (#257); the conversation around this row says whose it is.
const media = useMediaUrl()
const strictRoute = useStrictRoute()
const store = useStore()
const { menu, open, close } = useContextMenu()
const [editing, setEditing] = useState(false)
@@ -387,7 +391,7 @@ function MessageRowBody({
const media = extractMedia(normalized, {
contentSniffing,
sniffed,
onNeedSniff: sniffUrl,
onNeedSniff: (url) => sniffUrl(url, strictRoute),
// Read from the un-normalised body: the pairing lives in the BBCode,
// and normalising unwraps both tags into two unrelated bare URLs.
thumbnails: thumbnailLinks(raw)
@@ -424,7 +428,7 @@ function MessageRowBody({
codeBlocks,
quoteBlocks
}
}, [message.body, message.html, contentSniffing, sniffed, revealed, isSneedchat, smilieIndex, channels, service, ircColours])
}, [message.body, message.html, contentSniffing, sniffed, revealed, isSneedchat, smilieIndex, channels, service, ircColours, strictRoute])
const entries: MenuEntry[] = [
// First, above what to do about the message: the question a right click
@@ -692,7 +696,7 @@ function MessageRowBody({
{!grouped &&
(message.avatarUrl ? (
<img
src={resolveMediaUrl(message.avatarUrl)}
src={media(message.avatarUrl)}
alt=""
loading="lazy"
decoding="async"
@@ -895,7 +899,7 @@ function MessageRowBody({
{embed.imageUrl && !embedMedia.claimed.has(i) && (
<img
className="rich-embed-image"
src={resolveMediaUrl(embed.imageUrl)}
src={media(embed.imageUrl)}
alt=""
loading="lazy"
onClick={() => embed.url && void window.moho.openExternal(embed.url)}
@@ -1136,9 +1140,10 @@ function MessageRowBody({
* the count.
*/
function ReactionEmoji({ emoji }: { emoji: string; animated?: boolean }): JSX.Element {
const media = useMediaUrl()
const custom = emoji.match(/^<?a?:?([A-Za-z0-9_~]{2,32}):(\d+)>?$/)
if (!custom) return <span>{emoji}</span>
return <img className="reaction-emoji" src={discordEmojiUrl(custom[2])} alt={custom[1]} />
return <img className="reaction-emoji" src={media(discordEmojiUrl(custom[2]))} alt={custom[1]} />
}
function moderationEntries(
+1 -1
View File
@@ -46,7 +46,7 @@ export function ProfileCard(): JSX.Element | null {
<div className="modal-scrim" onClick={() => store.closeProfile()}>
<div className="profile-card" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
<div className="profile-head">
<Avatar name={profile.name} url={profile.avatarUrl} size={40} status={profile.status} />
<Avatar name={profile.name} url={profile.avatarUrl} size={40} status={profile.status} accountId={profile.accountId} />
<div className="profile-title">
<span className="profile-name ellipsis">{profile.name}</span>
<span className="small muted">
+4 -2
View File
@@ -1,3 +1,4 @@
import { useMediaUrl } from '../lib/route'
import { useRef, useState } from 'react'
import { ContextMenu, useContextMenu } from './ContextMenu'
import { railConnecting } from '../lib/bufferlink'
@@ -89,6 +90,7 @@ interface TileProps {
*/
function GroupFace({ group, customIcon }: { group: RailGroup; customIcon?: string }): JSX.Element {
const service = serviceIcon(group.service)
const media = useMediaUrl(group.accountId)
// draggable={false} throughout: a picture is draggable on its own by
// default, so taking hold of a tile by its icon dragged the icon rather
// than the tile - which is a different gesture with different data behind
@@ -100,7 +102,7 @@ function GroupFace({ group, customIcon }: { group: RailGroup; customIcon?: strin
// usually has no picture and often no name, and initials taken off a raw
// room id say nothing at all.
if (group.kind === 'invite') return <Icon name="mark_email_unread" size={22} />
if (group.iconUrl) return <img className="rail-icon" src={resolveMediaUrl(group.iconUrl)} alt="" draggable={false} />
if (group.iconUrl) return <img className="rail-icon" src={media(group.iconUrl)} alt="" draggable={false} />
if (group.kind === 'dms') return <Icon name="forum" size={22} />
if (group.kind === 'account' && service.mark && service.colour) {
// Artwork with colour worth keeping, shown as-is rather than flattened to
@@ -320,7 +322,7 @@ function DmTile(props: { buffer: BufferEntry; active: boolean; onSelect: () => v
>
<span className={`rail-pill${active ? ' active' : ' unread'}`} />
<span className="rail-face">
<Avatar name={name} url={buffer.avatarUrl} size={40} />
<Avatar name={name} url={buffer.avatarUrl} size={40} accountId={buffer.accountId} />
</span>
{/* A count rather than a dot: one message from someone and thirty of
them are different situations, and this column is where that gets
+1 -1
View File
@@ -74,7 +74,7 @@ export function UserFooter({ account }: { account?: Account }): JSX.Element {
onClick={open}
title={detail ? `${name} — ${label(status)}\n${detail}` : `${name} — ${label(status)}`}
>
<Avatar name={name} url={account.avatarUrl} size={28} status={status} />
<Avatar name={name} url={account.avatarUrl} size={28} status={status} accountId={account.id} />
<span className="user-identity">
<span className="ellipsis user-name">{name}</span>
<span className="ellipsis small muted">{label(status)}</span>
+3 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Icon } from './Icon'
import { resolveMediaUrl } from '../lib/util'
import { useMediaUrl } from '../lib/route'
import { useStore } from '../state/hooks'
import type { Attachment } from '../../../shared/wire'
@@ -66,7 +66,8 @@ export function VoiceMessage({ attachment }: { attachment: Attachment }): JSX.El
*/
const asked = useRef(false)
const src = resolveMediaUrl(attachment.path || attachment.url || '')
const media = useMediaUrl()
const src = media(attachment.path || attachment.url || '')
// The sender's figure where there is one. An audio element only knows the
// length once it has enough of the file to say, which is after the fetch
// this display exists to let somebody avoid.
+5 -1
View File
@@ -1,3 +1,4 @@
import { routedUrl } from './routedurl'
/**
* Small local copies of Kick emotes.
*
@@ -128,7 +129,10 @@ export function recoverMissingEmotes(): () => void {
const id = localEmoteId(img.src)
if (!id) return
local.delete(id)
img.src = `https://files.kick.com/emotes/${id}/fullsize`
const kick = `https://files.kick.com/emotes/${id}/fullsize`
// Through the route when this is a strictly routed account's
// conversation (#257) - the message list says so on itself.
img.src = img.closest('[data-route="strict"]') ? routedUrl(kick) : kick
requestLocalEmotes([id])
}
document.addEventListener('error', onError, true)
+11 -6
View File
@@ -1,5 +1,6 @@
import { useMediaUrl } from './route'
import { useState, type ReactNode } from 'react'
import { MEDIA_SCHEME, resolveMediaUrl } from './util'
import { MEDIA_SCHEME } from './util'
import { isDeepLink } from '../../../shared/deeplink'
/**
@@ -128,6 +129,9 @@ interface Handlers {
onOpenLink?: RichTextProps['onOpenLink']
/** Whether this markup is ours rather than the sender's - see safeImageSrc. */
ownMarkup?: boolean
/** Turns a picture's URL into the one to load - through the account's
* route when it is strictly routed (#257). */
media: (url: string) => string
}
export function RichText({
@@ -149,8 +153,9 @@ export function RichText({
// Which is a live surface rather than a theoretical one: `safeImageSrc`
// below passes any https image through, so a formatted body can name a URL
// its author controls and learn that the message was read - see #145.
const media = useMediaUrl()
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html')
return <>{walk(doc.body, { onRevealSpoiler, onOpenChannel, onOpenLink, ownMarkup }, 0)}</>
return <>{walk(doc.body, { onRevealSpoiler, onOpenChannel, onOpenLink, ownMarkup, media }, 0)}</>
}
function walk(node: Node, handlers: Handlers, depth: number): ReactNode[] {
@@ -186,7 +191,7 @@ function walk(node: Node, handlers: Handlers, depth: number): ReactNode[] {
case 'IMG': {
const src = el.getAttribute('src') || ''
const resolved = safeImageSrc(src, !!ownMarkup)
const resolved = safeImageSrc(src, !!ownMarkup, handlers.media)
const alt = el.getAttribute('alt') || ''
if (!resolved) {
// Refused rather than broken: what it was called is worth keeping
@@ -396,7 +401,7 @@ function classNameOf(el: Element): string | undefined {
* both go through the guarded moho-media scheme. Remote https images are
* allowed too (Discord CDN avatars and the like). Everything else is dropped.
*/
function safeImageSrc(src: string, remoteAllowed: boolean): string | null {
function safeImageSrc(src: string, remoteAllowed: boolean, media: (url: string) => string): string | null {
// A remote picture is a request to somebody else's server, made the moment
// the message is drawn. Where this client wrote the markup that is fine and
// wanted - a Discord emoji is a URL we built from an id. Where the *sender*
@@ -404,7 +409,7 @@ function safeImageSrc(src: string, remoteAllowed: boolean): string | null {
// from what address, and no chat message needs that. So the caller says
// which kind of markup this is, and the untrusted kind gets no remote
// images at all.
if (/^https:\/\//i.test(src)) return remoteAllowed ? src : null
if (/^https:\/\//i.test(src)) return remoteAllowed ? media(src) : null
// Nothing local in markup the sender wrote - not a file:// URL, not a bare
// path, and not a moho-media URL spelled out by hand, which would otherwise
// walk straight past every check before the main process's own (#247).
@@ -414,6 +419,6 @@ function safeImageSrc(src: string, remoteAllowed: boolean): string | null {
// Already routed (a smilie url built by the store) - passing it through
// resolveMediaUrl again would double-wrap it.
if (src.startsWith(`${MEDIA_SCHEME}://`)) return src
if (/^(file:\/\/|\/)/.test(src)) return resolveMediaUrl(src)
if (/^(file:\/\/|\/)/.test(src)) return media(src)
return null
}
+59
View File
@@ -0,0 +1,59 @@
import { createContext, useCallback, useContext } from 'react'
import { useChat } from '../state/hooks'
import { resolveMediaUrl } from './util'
import { routedUrl } from './routedurl'
/**
* Remote media for a strictly routed account goes through its route (#257).
*
* An account on Tor or a SOCKS5 proxy routes its connection, but anything the
* window loads for its conversations - a linked picture, an avatar, an emoji,
* the check that asks a link what it is - would otherwise go out straight
* from the user's own address, and anybody who can post a link learns it.
* At the "strict" level every such URL is rewritten to the main process's
* `moho-routed` scheme, which fetches it through the route and fails closed.
*
* Everything else is unchanged: local files still go through the media
* scheme, and with everything tunnelled the window is already routed whole.
*/
export { ROUTED_SCHEME, routedUrl } from './routedurl'
function isWeb(url: string): boolean {
return /^https?:\/\//i.test(url)
}
/** The account whose conversation this is - set around a conversation, its
* member list and its profile cards, so what is nested in them needs no
* account passed down to it. */
export const RouteAccount = createContext<string | undefined>(undefined)
/** The account a buffer belongs to: buffer ids are `<account>|<name>`. */
export function accountOfBuffer(bufferId: string): string {
const at = bufferId.indexOf('|')
return at === -1 ? bufferId : bufferId.slice(0, at)
}
/** Whether this account's media must go through its route. */
export function useStrictRoute(accountId?: string): boolean {
const fromContext = useContext(RouteAccount)
const id = accountId ?? fromContext
return useChat((s) => !s.tunnelAll && !!id && s.accounts.some((a) => a.id === id && a.routeLevel === 'strict'))
}
/**
* The URL to load for a picture, a video or a sound in this account's
* conversation: a local file through the media scheme, a web URL through the
* route when the account is strict, and anything else as it is.
*/
export function useMediaUrl(accountId?: string): (url: string) => string {
const strict = useStrictRoute(accountId)
return useCallback(
(url: string) => {
if (!url) return url
if (strict && isWeb(url)) return routedUrl(url)
return resolveMediaUrl(url)
},
[strict]
)
}
+11
View File
@@ -0,0 +1,11 @@
/**
* The main process's scheme for remote media fetched through an account's
* route (#257) - see main/routed.ts and lib/route.tsx. Kept apart from the
* React side so plain modules (the link check, the emote cache) can use it.
*/
export const ROUTED_SCHEME = 'moho-routed'
/** A web URL, fetched through the route instead of directly. */
export function routedUrl(url: string): string {
return `${ROUTED_SCHEME}://fetch/?u=${encodeURIComponent(url)}`
}
+8 -2
View File
@@ -1,4 +1,5 @@
import { useCallback, useSyncExternalStore } from 'react'
import { routedUrl } from './routedurl'
/**
* Content-Type probing for links that extension- and host-based detection
@@ -40,7 +41,12 @@ function notify(): void {
for (const l of listeners) l()
}
export function sniffUrl(url: string): void {
/**
* `viaRoute` for a strictly routed account's link (#257): the check is a
* request to whatever was linked, sent the moment the message arrives, so for
* such an account it goes through the route like everything else.
*/
export function sniffUrl(url: string, viaRoute = false): void {
if (resolved[url] !== undefined || inFlight.has(url)) return
inFlight.add(url)
@@ -59,7 +65,7 @@ export function sniffUrl(url: string): void {
// HEAD rather than GET: the Content-Type header is the whole answer, and a
// GET would pull the entire body of something that may not even be media.
fetch(url, { method: 'HEAD', signal: controller.signal })
fetch(viaRoute ? routedUrl(url) : url, { method: 'HEAD', signal: controller.signal })
.then((res) => {
const contentType = res.headers.get('content-type') || ''
if (contentType.startsWith('image/')) finish('image')
+6
View File
@@ -52,6 +52,12 @@ export interface Account {
torMode?: string
torProxy?: string
useTor: boolean
/**
* Where this account's traffic goes (#257): "clearnet"; "service", the
* connection and the media the daemon fetches for it; or "strict", that
* and everything the window loads for its conversations.
*/
routeLevel?: 'clearnet' | 'service' | 'strict'
hasKeyBackup: boolean
/**
* Matrix only: a media server for room calls, where the homeserver names