Ask before taking a file somebody offers over IRC

The prompt, the progress, and the settings for nobilis 1fdc8a5.

Floats over everything the way an incoming call does, and for the same
reason: a file is offered while you are reading something else, often in
a conversation you are not. It shows the three things that make an offer
obviously wrong - who it is from, what it will be called, and how big -
and where the name had to be changed, it says what they had called it,
so a substitution is never silent. Declining takes the focus, so a stray
keypress gives the safe answer, and a file that would run rather than
open says so.

safeName had the same job as the daemon's new sanitiser and the same
gaps - Windows device names, and the trailing dots and spaces Windows
drops on its own - so the two are brought into step.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-30 22:04:06 -04:00
co-authored by Claude Opus 5
parent 81a4da421a
commit 3dc89f6b38
8 changed files with 477 additions and 5 deletions
+1 -1
Submodule nobilis updated: 9f17ff229e...1fdc8a5424
+29 -3
View File
@@ -27,6 +27,12 @@ export function uniquePath(target: string): string {
return path.join(dir, `${stem}-${Date.now()}${ext}`)
}
/**
* Windows keeps these reserved whatever extension follows them, and opening
* one talks to a device rather than to a file.
*/
const RESERVED = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i
/**
* A filename safe to join onto the download directory.
*
@@ -34,12 +40,32 @@ export function uniquePath(target: string): string {
* basename strips any directory part (including "../" traversal), and the
* remaining separators and Windows-reserved characters are replaced so the
* result can only ever name a file directly inside the chosen folder.
*
* The Windows-specific rules are applied on every platform, not only there.
* A name that is harmless on Linux can be a device on Windows, and a trailing
* dot or space is silently dropped there - so "evil.exe." and "evil.exe" are
* one file on one platform and two on the other. Deciding that per-platform
* would mean the same message produced a different file depending on where it
* was read, which is the kind of difference nobody tests.
*
* Kept in step with `safe_file_name` in nobilis's backend/irc_dcc.rs, which
* does this same job for files arriving over IRC.
*/
export function safeName(filename: string | undefined, source: string): string {
const raw = filename || source.split('?')[0] || 'download'
const base = path.basename(raw)
const cleaned = base.replace(/[/\\:*?"<>|]/g, '_').replace(/^\.+/, '').slice(0, 120)
return cleaned || 'download'
// Both separators regardless of platform: the name came off the network,
// not off this filesystem. path.basename only knows about this one.
const base = raw.split(/[/\\]/).pop() ?? ''
// Drops a drive letter, and an alternate data stream with it.
const withoutDrive = base.split(':').pop() ?? ''
const cleaned = withoutDrive
// eslint-disable-next-line no-control-regex
.replace(/[/\\:*?"<>|\u0000-\u001f\u007f]/g, '_')
.replace(/^\.+/, '')
.replace(/[. ]+$/, '')
.slice(0, 120)
if (!cleaned) return 'download'
return RESERVED.test(cleaned.split('.')[0]) ? `_${cleaned}` : cleaned
}
/** A local path if this source is already on disk, else null. */
+4
View File
@@ -16,6 +16,7 @@ import { JoinPanel } from './components/JoinPanel'
import { Toasts } from './components/Toasts'
import { IncomingCallPanel } from './components/IncomingCallPanel'
import { FileDrop } from './components/FileDrop'
import { TransferPanel } from './components/TransferPanel'
import { Icon, IconButton } from './components/Icon'
import { Avatar } from './components/Avatar'
import { dmStatus } from './components/BufferList'
@@ -169,6 +170,9 @@ export default function App(): JSX.Element {
{/* Over everything: a call arrives while you are looking elsewhere. */}
<IncomingCallPanel />
{/* Over everything for the same reason: a file is offered while you are
reading something else, and often in a conversation you are not. */}
<TransferPanel />
<FileDrop />
<Toasts />
</div>
+81
View File
@@ -3562,3 +3562,84 @@ video.lightbox-media {
font-weight: 500;
color: var(--surface-text);
}
/* --- File transfers --------------------------------------------------- */
/* Sits above the calls rather than beside them: both float bottom-left, and
two stacks in the same corner would overlap. A ringing call is the more
urgent of the two, so it keeps the corner. */
.transfer-panel {
position: fixed;
right: var(--spacing-m);
bottom: var(--spacing-m);
display: flex;
flex-direction: column-reverse;
gap: var(--spacing-s);
z-index: 2100;
width: 340px;
max-width: calc(100vw - var(--spacing-xl));
}
.transfer-card {
display: flex;
flex-direction: column;
gap: var(--spacing-s);
padding: var(--spacing-m);
border-radius: var(--corner-radius);
background: var(--surface-container-highest);
border: 1px solid var(--outline-medium);
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.55);
}
.transfer-head {
display: flex;
align-items: center;
gap: var(--spacing-s);
}
/* The name takes the slack so the close button stays at the right edge. */
.transfer-head > .ellipsis {
flex: 1;
min-width: 0;
}
.transfer-file {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--spacing-s);
border-radius: var(--corner-radius-small);
background: var(--surface-container-low);
min-width: 0;
}
.transfer-warning {
display: flex;
align-items: flex-start;
gap: var(--spacing-xs);
margin: 0;
color: var(--warning);
}
.transfer-actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-s);
}
.transfer-bar {
height: 6px;
border-radius: 3px;
background: var(--surface-container-low);
overflow: hidden;
}
.transfer-bar-fill {
height: 100%;
background: var(--primary);
transition: width 0.2s linear;
}
.transfer-panel p {
margin: 0;
}
@@ -0,0 +1,155 @@
import { Icon, IconButton } from './Icon'
import { useChat, useStore } from '../state/hooks'
import type { DccTransfer } from '../../../shared/wire'
/**
* Files offered over IRC: the asking, and then the watching.
*
* Floats over everything for the same reason the call panel does - an offer
* arrives while you are looking somewhere else, often in a conversation you
* are not reading, and a prompt buried in a channel you have scrolled away
* from is a prompt nobody answers.
*
* It asks first. Somebody you have never spoken to can send an offer, and
* accepting one puts their file on your disk; that is worth a sentence and a
* button rather than a surprise in your downloads folder. What it shows is
* chosen for the decision being made: who it is from, what it will be called,
* and how big - the three things that make an offer obviously wrong.
*/
export function TransferPanel(): JSX.Element | null {
const store = useStore()
const transfers = useChat((s) => s.transfers)
// Finished ones are history rather than something to answer, and belong in
// the list this panel is not - they leave once they are done.
const showing = transfers.filter((t) => t.state === 'offered' || t.state === 'receiving')
if (showing.length === 0) return null
return (
<div className="transfer-panel">
{showing.map((t) => (
<div key={t.id} className="transfer-card">
{t.state === 'offered' ? (
<Offer transfer={t} onAccept={() => void store.acceptTransfer(t.id)} onDecline={() => void store.cancelTransfer(t.id)} />
) : (
<Running transfer={t} onCancel={() => void store.cancelTransfer(t.id)} />
)}
</div>
))}
</div>
)
}
function Offer({
transfer,
onAccept,
onDecline
}: {
transfer: DccTransfer
onAccept: () => void
onDecline: () => void
}): JSX.Element {
const risky = looksExecutable(transfer.fileName)
return (
<>
<div className="transfer-head">
<Icon name="save" size={18} />
<span className="ellipsis">
<b>{transfer.from}</b> is offering you a file
</span>
</div>
<FileLine transfer={transfer} />
{/* Not a block on anything - it is their disk and their decision - but
the one property of a file that changes what accepting it means. */}
{risky && (
<p className="transfer-warning small">
<Icon name="warning" size={14} />
<span>This is a program. Opening it would run whatever they sent.</span>
</p>
)}
<div className="transfer-actions">
{/* Declining is the default focus: the safe answer should be the one
a stray keypress gives. */}
<button type="button" className="button" onClick={onDecline} autoFocus>
Decline
</button>
<button type="button" className="button primary" onClick={onAccept}>
Accept
</button>
</div>
</>
)
}
function Running({ transfer, onCancel }: { transfer: DccTransfer; onCancel: () => void }): JSX.Element {
const pct = transfer.size > 0 ? Math.min(100, (transfer.received / transfer.size) * 100) : 0
return (
<>
<div className="transfer-head">
<Icon name="download" size={18} />
<span className="ellipsis">
Receiving from <b>{transfer.from}</b>
</span>
<IconButton name="close" title="Stop this transfer" onClick={onCancel} />
</div>
<FileLine transfer={transfer} />
<div className="transfer-bar" role="progressbar" aria-valuenow={Math.round(pct)} aria-valuemin={0} aria-valuemax={100}>
<div className="transfer-bar-fill" style={{ width: `${pct}%` }} />
</div>
<p className="small muted">
{humanSize(transfer.received)} of {humanSize(transfer.size)}
</p>
</>
)
}
function FileLine({ transfer }: { transfer: DccTransfer }): JSX.Element {
return (
<div className="transfer-file">
<span className="ellipsis" title={transfer.fileName}>
{transfer.fileName}
</span>
<span className="small muted">{humanSize(transfer.size)}</span>
{/* Shown only where the name had to be changed, so a file arriving
under a different name than was offered is never a silent
substitution - that difference is usually the interesting part. */}
{transfer.rawName !== transfer.fileName && (
<span className="small muted ellipsis" title={transfer.rawName}>
they called it “{transfer.rawName}”
</span>
)}
</div>
)
}
/**
* Whether opening this would run it rather than show it.
*
* Extension-based, which is exactly as reliable as the extension - the point
* is to catch the ordinary case where somebody is handed a .exe they were not
* expecting, not to be a verdict on the file.
*/
export function looksExecutable(name: string): boolean {
const ext = name.toLowerCase().split('.').pop() ?? ''
return [
'exe', 'scr', 'com', 'pif', 'bat', 'cmd', 'msi', 'msp', 'jar', 'app',
'ps1', 'psm1', 'vbs', 'vbe', 'js', 'jse', 'wsf', 'wsh', 'hta', 'lnk',
'dll', 'sys', 'sh', 'run', 'deb', 'rpm', 'apk', 'dmg', 'pkg'
].includes(ext)
}
export function humanSize(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = bytes
let unit = 0
while (size >= 1024 && unit < units.length - 1) {
size /= 1024
unit += 1
}
return unit === 0 ? `${bytes} B` : `${size.toFixed(1)} ${units[unit]}`
}
@@ -9,7 +9,7 @@ import {
} from './controls'
import { useChat, usePref, useStore } from '../../state/hooks'
import { Icon } from '../Icon'
import type { Account } from '../../../../shared/wire'
import type { Account, DccPrefs } from '../../../../shared/wire'
/**
* Structured by service, so adding a protocol later means adding a rail entry
@@ -327,6 +327,8 @@ function IrcSettings(): JSX.Element {
<UploadHostSetting service="irc" kind="media" />
</SettingsSection>
<TransferSettings />
<SettingsSection
title="Connection defaults for new accounts"
description="Applied when you connect a new IRC account. SASL, autojoin and NickServ auto-identify are per-account instead, from that account's own entry in the Accounts pane."
@@ -338,6 +340,127 @@ function IrcSettings(): JSX.Element {
)
}
/**
* Receiving files people send over IRC.
*
* Backed by the daemon rather than by a stored pref, because the daemon is
* what writes the files: a download folder held out here would mean a
* network-driven write aimed by whichever window happened to ask last.
*/
function TransferSettings(): JSX.Element {
const [prefs, setPrefs] = useState<DccPrefs | null>(null)
useEffect(() => {
void window.moho
.rpc<DccPrefs>('getDccPrefs')
.then(setPrefs)
.catch(() => undefined)
}, [])
const save = (patch: Partial<DccPrefs>): void => {
void window.moho
.rpc<DccPrefs>('setDccPrefs', patch)
.then(setPrefs)
.catch(() => undefined)
}
return (
<SettingsSection
title="Receiving files"
description="XDCC bots and other people on IRC can offer you a file directly. moho asks before taking one, saves it under a name of its own choosing rather than theirs, and never listens for a connection - it only ever dials out, which is also what lets this work on an account routed through Tor."
>
<div className="setting-row">
<div className="setting-text">
<div>Save files to</div>
<div className="small muted ellipsis">{prefs?.resolvedDirectory ?? '…'}</div>
</div>
<div className="setting-actions">
<button
type="button"
className="button"
onClick={() => void window.moho.pickDirectory().then((dir) => dir && save({ directory: dir }))}
>
Browse…
</button>
{prefs?.directory && (
<button type="button" className="button" onClick={() => save({ directory: '' })}>
Reset
</button>
)}
</div>
</div>
<div className="setting-row">
<div className="setting-text">
<div>Largest file to accept</div>
<div className="small muted">
Anything offered above this is turned down without asking. A sender that goes past what it offered is cut
off at the same limit.
</div>
</div>
<div className="setting-actions">
<select
className="text-field"
value={String(prefs?.maxBytes ?? 0)}
onChange={(e) => save({ maxBytes: Number(e.target.value) })}
>
{[
[100 * 1024 * 1024, '100 MB'],
[1024 * 1024 * 1024, '1 GB'],
[4 * 1024 * 1024 * 1024, '4 GB'],
[16 * 1024 * 1024 * 1024, '16 GB'],
[0, 'No limit']
].map(([bytes, label]) => (
<option key={String(bytes)} value={String(bytes)}>
{label}
</option>
))}
</select>
</div>
</div>
<div className="setting-row">
<div className="setting-text">
<div>At most this many at once</div>
<div className="small muted">Further offers are turned down while this many are already going.</div>
</div>
<div className="setting-actions">
<select
className="text-field"
value={String(prefs?.maxTransfers ?? 3)}
onChange={(e) => save({ maxTransfers: Number(e.target.value) })}
>
{[1, 2, 3, 5, 10].map((n) => (
<option key={n} value={String(n)}>
{n}
</option>
))}
</select>
</div>
</div>
<div className="setting-row">
<div className="setting-text">
<div>Accept offers without asking</div>
{/* Worded so what it gives up is plain rather than implied. The
limits above still apply - this only removes the question. */}
<div className="small muted">
Off by default, and worth leaving off: anyone who can message you can offer you a file, and this takes them
all. The size and count limits still apply.
</div>
</div>
<div className="setting-actions">
<input
type="checkbox"
checked={prefs?.autoAccept ?? false}
onChange={(e) => save({ autoAccept: e.target.checked })}
/>
</div>
</div>
</SettingsSection>
)
}
function useSockchatAccount(): Account | undefined {
return useChat((s) => s.accounts).find((a) => a.service === 'sockchat')
}
+40
View File
@@ -5,6 +5,7 @@ import type {
BufferGroup,
CustomEmoji,
IncomingCall,
DccTransfer,
MatrixVerification,
Member,
Message,
@@ -82,6 +83,8 @@ export interface ChatState {
voiceSessions: VoiceSession[]
/** Conversations ringing right now, newest last. */
incomingCalls: IncomingCall[]
/** Files offered over IRC, newest first, offers and transfers together. */
transfers: DccTransfer[]
/**
* Everything that has mentioned you, newest first, across every service.
*
@@ -164,6 +167,7 @@ const INITIAL: ChatState = {
voiceGuildId: '',
voiceSessions: [],
incomingCalls: [],
transfers: [],
mentions: [],
lastReadTs: {},
jumpTarget: '',
@@ -571,6 +575,9 @@ export class ChatStore {
async refreshIncomingCalls(): Promise<void> {
try {
this.set({ incomingCalls: await window.moho.rpc<IncomingCall[]>('getIncomingCalls') })
// A window opened while a transfer is already running should show it,
// not start from empty and only notice the next one.
this.set({ transfers: await window.moho.rpc<DccTransfer[]>('listTransfers') })
} catch {
// An older daemon has no such method; no calls is the honest answer.
this.set({ incomingCalls: [] })
@@ -657,6 +664,29 @@ export class ChatStore {
}
}
/** Takes a file that has been offered. */
async acceptTransfer(id: string): Promise<void> {
try {
await window.moho.rpc('acceptTransfer', { id })
} catch (e) {
this.toast('error', `Couldn't accept that file: ${(e as Error).message}`)
}
}
/**
* Turns down an offer, or stops one already running.
*
* The same call for both: from where the person is sitting these are one
* request - they want it to stop - and the daemon knows which it was.
*/
async cancelTransfer(id: string): Promise<void> {
try {
await window.moho.rpc('cancelTransfer', { id })
} catch (e) {
this.toast('error', `Couldn't stop that transfer: ${(e as Error).message}`)
}
}
async leaveVoice(accountId: string): Promise<void> {
try {
await window.moho.rpc('leaveVoiceChannel', { accountId })
@@ -837,6 +867,16 @@ export class ChatStore {
this.setRinging(data as IncomingCall)
break
// One event carries every state a transfer passes through, so the list
// is updated in place rather than re-fetched on each step of a progress
// bar that ticks several times a second.
case 'dccTransfer': {
const t = data as unknown as DccTransfer
const rest = this.state.transfers.filter((x) => x.id !== t.id)
this.set({ transfers: [t, ...rest] })
break
}
// Somebody joined or left a voice channel in a guild we may be showing.
case 'voiceMembershipChanged':
if (data.guildId === this.state.voiceGuildId) {
+43
View File
@@ -134,6 +134,49 @@ export interface VoiceMember {
deafened?: boolean
}
/**
* A file somebody has offered over IRC, from the offer through to it being
* on disk.
*
* One type for both because they are one thing to whoever is watching - a
* file arriving - and being offered is its first state rather than a
* different kind of object.
*/
export interface DccTransfer {
id: string
accountId: string
/** Who offered it. */
from: string
/**
* What it will be called on disk. Derived by the daemon from what they
* said, never taken from it: a name off the network cannot be allowed to
* decide where a file lands.
*/
fileName: string
/**
* What they called it. Shown beside `fileName` where the two differ, so a
* name that had to be changed is visible rather than quietly substituted.
*/
rawName: string
size: number
received: number
state: 'offered' | 'receiving' | 'done' | 'declined' | 'failed'
/** Where it landed, once it has. */
path?: string | null
/** Why it failed, or why it was turned down. */
error?: string | null
}
/** What receiving files is allowed to do. Lives with the daemon, which is what writes them. */
export interface DccPrefs {
directory?: string | null
maxBytes: number
maxTransfers: number
autoAccept: boolean
/** Where files will actually land, with the platform default filled in. */
resolvedDirectory?: string
}
export interface VoiceChannel {
id: string
name: string