+ // 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.
+
+
{/* First, because it explains everything under it: a conversation that
is not receiving looks like a quiet one otherwise. */}
@@ -783,6 +789,7 @@ export function MessageList(): JSX.Element {
)}
+
)
}
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]}]({discordEmojiUrl(custom[2])})
+ return
![{custom[1]}]({media(discordEmojiUrl(custom[2]))})
}
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