diff --git a/nobilis b/nobilis index 7976452..a3897ba 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit 7976452332f059ebeff77dcb0271ddd51af299c7 +Subproject commit a3897bab95cbb2f4ef4880876e935ee4b7880ac2 diff --git a/src/main/index.ts b/src/main/index.ts index 47056fe..872ab8e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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) }) diff --git a/src/main/media-protocol.ts b/src/main/media-protocol.ts index 1cbcf91..0b03b6d 100644 --- a/src/main/media-protocol.ts +++ b/src/main/media-protocol.ts @@ -252,6 +252,13 @@ export function registerMediaScheme(): void { // this scheme cross-origin, and an 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 } } ]) } diff --git a/src/main/routed.ts b/src/main/routed.ts new file mode 100644 index 0000000..eae2180 --- /dev/null +++ b/src/main/routed.ts @@ -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=`, 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) => Promise + +/** 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 | 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 { + 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 }) + } + }) +} diff --git a/src/renderer/index.html b/src/renderer/index.html index 0e91a12..d720093 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -12,7 +12,7 @@ moho diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css index d94ae05..f312dfe 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -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; + } +} diff --git a/src/renderer/src/components/AccountsPanel.tsx b/src/renderer/src/components/AccountsPanel.tsx index 5044c0e..03836f8 100644 --- a/src/renderer/src/components/AccountsPanel.tsx +++ b/src/renderer/src/components/AccountsPanel.tsx @@ -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 {
{account.avatarUrl ? ( - + ) : icon.mark ? ( ) : ( @@ -437,11 +439,7 @@ function AccountRow({ account }: { account: Account }): JSX.Element { )} {account.service !== 'sneedchat' && ( - call('setAccountRouted', { accountId: account.id, enabled: useTor })} - /> + call('setAccountRoute', { accountId: account.id, level })} /> )} {account.service === 'discord' && } @@ -450,11 +448,7 @@ function AccountRow({ account }: { account: Account }): JSX.Element { {account.service === 'sneedchat' && ( <> - call('setAccountRouted', { accountId: account.id, enabled: useTor })} - /> + call('setAccountRoute', { accountId: account.id, level })} /> )} @@ -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 = { + 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 ( +
+
+ Routing +
+
+
+

{describe[level]}

+ {level !== 'clearnet' && } +
+ ) +} + function TorSwitch({ service, on, diff --git a/src/renderer/src/components/AddToConversation.tsx b/src/renderer/src/components/AddToConversation.tsx index 27915fa..8ef6159 100644 --- a/src/renderer/src/components/AddToConversation.tsx +++ b/src/renderer/src/components/AddToConversation.tsx @@ -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 ? ( - + ) : ( {(f.globalName || f.username).slice(0, 1)} diff --git a/src/renderer/src/components/Avatar.tsx b/src/renderer/src/components/Avatar.tsx index 7a309f3..368fd2b 100644 --- a/src/renderer/src/components/Avatar.tsx +++ b/src/renderer/src/components/Avatar.tsx @@ -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({ {url && !broken ? ( - + ) } diff --git a/src/renderer/src/components/BufferList.tsx b/src/renderer/src/components/BufferList.tsx index 5f60b05..3792f14 100644 --- a/src/renderer/src/components/BufferList.tsx +++ b/src/renderer/src/components/BufferList.tsx @@ -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. - + ) : buffer.avatarUrl ? ( - + ) : 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 diff --git a/src/renderer/src/components/CallView.tsx b/src/renderer/src/components/CallView.tsx index af89746..4d73c79 100644 --- a/src/renderer/src/components/CallView.tsx +++ b/src/renderer/src/components/CallView.tsx @@ -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(null) const [query, setQuery] = useState('') @@ -581,7 +583,7 @@ function Soundboard({ accountId }: { accountId: string }): JSX.Element { onClick={() => play(s)} > {s.emojiId ? ( - + ) : s.emojiName ? ( {s.emojiName} ) : ( diff --git a/src/renderer/src/components/Composer.tsx b/src/renderer/src/components/Composer.tsx index 747ae2f..6a69748 100644 --- a/src/renderer/src/components/Composer.tsx +++ b/src/renderer/src/components/Composer.tsx @@ -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 diff --git a/src/renderer/src/components/ConversationMenu.tsx b/src/renderer/src/components/ConversationMenu.tsx index cf031f9..1a559e1 100644 --- a/src/renderer/src/components/ConversationMenu.tsx +++ b/src/renderer/src/components/ConversationMenu.tsx @@ -149,6 +149,7 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme > {buffer.kind === 'dm' || buffer.avatarUrl ? ( s.eventsPane) + const media = useMediaUrl(pane?.accountId) const counts = useChat((s) => s.discordEventCounts) const [events, setEvents] = useState(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) => (
- {event.image && } + {event.image && }
{event.live ? 'Happening now' : eventWhen(event.start)} - {event.creatorName && } + {event.creatorName && } {event.userCount} @@ -536,7 +538,7 @@ function ShareEvent({ const state = sent[t.userId] return (
- + {t.globalName || t.username} {t.username} diff --git a/src/renderer/src/components/EmojiPicker.tsx b/src/renderer/src/components/EmojiPicker.tsx index 7cc6fc3..565fc4e 100644 --- a/src/renderer/src/components/EmojiPicker.tsx +++ b/src/renderer/src/components/EmojiPicker.tsx @@ -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(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 ? ( - {sticker.name} + {sticker.name} ) : sticker.lottie && sticker.id && accountId ? ( ) : ( @@ -502,7 +505,7 @@ export function EmojiPicker({ id: src.id, name: src.name, icon: src.iconUrl ? ( - + ) : ( {src.name.slice(0, 1).toUpperCase()} ) @@ -630,7 +633,7 @@ export function EmojiPicker({ onClick={() => pick(r)} > {preview ? ( - {preview.label} + {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 ? {e.name} : e.name} + {art ? {e.name} : e.name} ) })} @@ -747,7 +750,7 @@ export function EmojiPicker({ // renders it and it goes out as this literal text. onClick={() => pick(emojiToken(e))} > - {e.name} + {e.name} ))} @@ -763,7 +766,7 @@ export function EmojiPicker({ title={s.label} onClick={() => pick(s.aliases[0] || s.label)} > - {s.url ? {s.label} : s.aliases[0]} + {s.url ? {s.label} : s.aliases[0]} ))} @@ -790,7 +793,7 @@ export function EmojiPicker({ {hovered ? ( <> {hovered.src ? ( - + ) : hovered.text ? ( {hovered.text} ) : null} diff --git a/src/renderer/src/components/ForwardPicker.tsx b/src/renderer/src/components/ForwardPicker.tsx index c03a7e8..66433dc 100644 --- a/src/renderer/src/components/ForwardPicker.tsx +++ b/src/renderer/src/components/ForwardPicker.tsx @@ -93,7 +93,7 @@ export function ForwardPicker({ disabled={!!sending} onClick={() => send(buffer.id)} > - + {bufferDisplayName(buffer.name)} {account?.displayName ?? ''} diff --git a/src/renderer/src/components/IncomingCallPanel.tsx b/src/renderer/src/components/IncomingCallPanel.tsx index 4fb4d7f..938655a 100644 --- a/src/renderer/src/components/IncomingCallPanel.tsx +++ b/src/renderer/src/components/IncomingCallPanel.tsx @@ -48,7 +48,7 @@ export function IncomingCallPanel(): JSX.Element | null { const name = buffer ? bufferDisplayName(buffer.name) : 'Someone' return (
- +
{name} diff --git a/src/renderer/src/components/JoinPanel.tsx b/src/renderer/src/components/JoinPanel.tsx index f51c1cc..cebf523 100644 --- a/src/renderer/src/components/JoinPanel.tsx +++ b/src/renderer/src/components/JoinPanel.tsx @@ -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([]) const [onlineOnly, setOnlineOnly] = useState(true) @@ -761,7 +763,7 @@ function DiscordJoin({ account }: { account: Account }): JSX.Element { {waiting.map((f) => (
{f.avatarUrl ? ( - + ) : ( {(f.globalName || f.username).slice(0, 1)} )} @@ -885,7 +887,7 @@ function DiscordJoin({ account }: { account: Account }): JSX.Element { )} {f.avatarUrl ? ( - + ) : ( {(f.globalName || f.username).slice(0, 1)} )} diff --git a/src/renderer/src/components/MediaEmbed.tsx b/src/renderer/src/components/MediaEmbed.tsx index be98732..4fff2eb 100644 --- a/src/renderer/src/components/MediaEmbed.tsx +++ b/src/renderer/src/components/MediaEmbed.tsx @@ -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} > - {attachment?.filename + {attachment?.filename {refreshing ? 'Reloading…' : refreshError || 'Click to reload'} @@ -318,7 +320,7 @@ export function MediaEmbed({ {attachment?.filename {attachment?.filename || 'audio'} -
) } @@ -447,7 +449,7 @@ export function MediaEmbed({ return ( )}
+ ) } diff --git a/src/renderer/src/components/MessageRow.tsx b/src/renderer/src/components/MessageRow.tsx index 16ac5d1..809ffc5 100644 --- a/src/renderer/src/components/MessageRow.tsx +++ b/src/renderer/src/components/MessageRow.tsx @@ -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 ? ( 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(/^?$/) if (!custom) return {emoji} - return {custom[1]} + return {custom[1]} } function moderationEntries( diff --git a/src/renderer/src/components/ProfileCard.tsx b/src/renderer/src/components/ProfileCard.tsx index 084faca..3cfb6d3 100644 --- a/src/renderer/src/components/ProfileCard.tsx +++ b/src/renderer/src/components/ProfileCard.tsx @@ -46,7 +46,7 @@ export function ProfileCard(): JSX.Element | null {
store.closeProfile()}>
e.stopPropagation()}>
- +
{profile.name} diff --git a/src/renderer/src/components/ServerRail.tsx b/src/renderer/src/components/ServerRail.tsx index f790e4c..c6fa3a8 100644 --- a/src/renderer/src/components/ServerRail.tsx +++ b/src/renderer/src/components/ServerRail.tsx @@ -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 - if (group.iconUrl) return + if (group.iconUrl) return if (group.kind === 'dms') return 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 > - + {/* A count rather than a dot: one message from someone and thirty of them are different situations, and this column is where that gets diff --git a/src/renderer/src/components/UserFooter.tsx b/src/renderer/src/components/UserFooter.tsx index 541417e..1303026 100644 --- a/src/renderer/src/components/UserFooter.tsx +++ b/src/renderer/src/components/UserFooter.tsx @@ -74,7 +74,7 @@ export function UserFooter({ account }: { account?: Account }): JSX.Element { onClick={open} title={detail ? `${name} — ${label(status)}\n${detail}` : `${name} — ${label(status)}`} > - + {name} {label(status)} diff --git a/src/renderer/src/components/VoiceMessage.tsx b/src/renderer/src/components/VoiceMessage.tsx index 31402c0..2a2e315 100644 --- a/src/renderer/src/components/VoiceMessage.tsx +++ b/src/renderer/src/components/VoiceMessage.tsx @@ -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. diff --git a/src/renderer/src/lib/emotecache.ts b/src/renderer/src/lib/emotecache.ts index b96b730..7575051 100644 --- a/src/renderer/src/lib/emotecache.ts +++ b/src/renderer/src/lib/emotecache.ts @@ -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) diff --git a/src/renderer/src/lib/richtext.tsx b/src/renderer/src/lib/richtext.tsx index 4a036d1..cd55acc 100644 --- a/src/renderer/src/lib/richtext.tsx +++ b/src/renderer/src/lib/richtext.tsx @@ -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(`${html}`, '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 } diff --git a/src/renderer/src/lib/route.tsx b/src/renderer/src/lib/route.tsx new file mode 100644 index 0000000..a815121 --- /dev/null +++ b/src/renderer/src/lib/route.tsx @@ -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(undefined) + +/** The account a buffer belongs to: buffer ids are `|`. */ +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] + ) +} diff --git a/src/renderer/src/lib/routedurl.ts b/src/renderer/src/lib/routedurl.ts new file mode 100644 index 0000000..19dee67 --- /dev/null +++ b/src/renderer/src/lib/routedurl.ts @@ -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)}` +} diff --git a/src/renderer/src/lib/sniff.ts b/src/renderer/src/lib/sniff.ts index a3c3e07..33f106a 100644 --- a/src/renderer/src/lib/sniff.ts +++ b/src/renderer/src/lib/sniff.ts @@ -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') diff --git a/src/shared/wire.ts b/src/shared/wire.ts index 54978f3..8082efa 100644 --- a/src/shared/wire.ts +++ b/src/shared/wire.ts @@ -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