Files desk: refuse files over the server's upload cap before sending #157
5 changed files with 180 additions and 12 deletions
|
|
@ -118,7 +118,7 @@ Under A the paused service is not built, and most of its list disappears:
|
|||
|
||||
## Cuts under A
|
||||
|
||||
Status 2026-11-13 (dev a078346e): built: folders by album title path with navigation and breadcrumb (#147), pagination (#148), version folding (#149), add-to-album with named refusals (#150), Stop for a send in flight, replaces loops shown, freshest last event on add (#151). **Not built: the Commons entry row** (item 1 below, found by `#commons:gamecult.org` or the carried `media.commons` id through `lookupMediaRoom`, behind a dark `filesDesk.enabled` flag; neither the row nor the flag exists in the code). It waits on Eureka's Commons room.
|
||||
Status 2026-11-14 (dev da822e9e plus the size-refusal cut): built: folders by album title path with navigation and breadcrumb (#147), pagination (#148), version folding (#149), add-to-album with named refusals (#150), Stop for a send in flight, replaces loops shown, freshest last event on add (#151), and the pre-send size refusal: a file larger than the server's `m.upload.size` is named and left unsent before any upload or album root, and a server stating no cap refuses nothing. **Not built: the Commons entry row** (item 1 below, found by `#commons:gamecult.org` or the carried `media.commons` id through `lookupMediaRoom`, behind a dark `filesDesk.enabled` flag; neither the row nor the flag exists in the code). It waits on Eureka's Commons room.
|
||||
|
||||
1. **Commons entry**: a Commons row in the navigator (like `MediaRow`, found by alias or carried id), opening the room's album view. Flagged, no new data layer.
|
||||
2. **Pagination** of albums and files in `useAlbums` (done, PR #148).
|
||||
|
|
|
|||
|
|
@ -3,18 +3,24 @@ import { renderToStaticMarkup } from 'react-dom/server';
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MatrixClientProvider } from '../../hooks/useMatrixClient';
|
||||
import { MediaConfig, MediaConfigProvider } from '../../hooks/useMediaConfig';
|
||||
|
||||
// ShareSet hands its folder to the title the send lands under. The controller is held still and its
|
||||
// `run` captured, and sendShareSet records the title it was given, so the path from the `folder`
|
||||
// prop to the album root's title is read without a homeserver or a DOM.
|
||||
const captured = vi.hoisted(() => ({
|
||||
run: undefined as undefined | ((files: unknown[], title: string, onFile: () => void) => unknown),
|
||||
maxBytes: undefined as undefined | (() => number | undefined),
|
||||
titles: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock('./shareController', () => ({
|
||||
createShareController: (deps: { run: NonNullable<typeof captured.run> }) => {
|
||||
createShareController: (deps: {
|
||||
run: NonNullable<typeof captured.run>;
|
||||
maxBytes: NonNullable<typeof captured.maxBytes>;
|
||||
}) => {
|
||||
captured.run = deps.run;
|
||||
captured.maxBytes = deps.maxBytes;
|
||||
return {
|
||||
subscribe: () => () => undefined,
|
||||
getState: () => ({ files: [], title: '', phase: { kind: 'idle' }, epoch: 0 }),
|
||||
|
|
@ -49,7 +55,11 @@ const ev = (id: string, ts: number, threadRootId?: string, status: string | null
|
|||
threadRootId,
|
||||
status,
|
||||
});
|
||||
const mount = (folder?: string[], into?: { albumId: string; lastId: string; lastTs: number }) => {
|
||||
const mount = (
|
||||
folder?: string[],
|
||||
into?: { albumId: string; lastId: string; lastTs: number },
|
||||
mediaConfig: MediaConfig = {}
|
||||
) => {
|
||||
const room = {
|
||||
roomId: '!r:x',
|
||||
hasEncryptionStateEvent: () => false,
|
||||
|
|
@ -58,7 +68,9 @@ const mount = (folder?: string[], into?: { albumId: string; lastId: string; last
|
|||
renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<MatrixClientProvider value={{ sendMessage: async () => ({ event_id: '$e' }) } as never}>
|
||||
<ShareSet room={room} folder={folder} into={into} />
|
||||
<MediaConfigProvider value={mediaConfig}>
|
||||
<ShareSet room={room} folder={folder} into={into} />
|
||||
</MediaConfigProvider>
|
||||
</MatrixClientProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
|
@ -112,3 +124,17 @@ describe('ShareSet: the folder reaches the album title', () => {
|
|||
expect(await send('x')).toBe('into:$alb@$sentNotEchoed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ShareSet: the homeserver upload cap reaches the controller', () => {
|
||||
it('is the server-stated m.upload.size, and none when the server states none', () => {
|
||||
mount([], undefined, { 'm.upload.size': 52428800 });
|
||||
expect(captured.maxBytes?.()).toBe(52428800);
|
||||
mount([], undefined, {});
|
||||
expect(captured.maxBytes?.()).toBeUndefined();
|
||||
// A cap that is not a positive number is no cap, never "refuse everything".
|
||||
[0, -1, Number.NaN, '50' as unknown as number].forEach((bad) => {
|
||||
mount([], undefined, { 'm.upload.size': bad });
|
||||
expect(captured.maxBytes?.()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
|||
import { EventStatus, Room } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useClientConfig } from '../../hooks/useClientConfig';
|
||||
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
||||
import { uploadContent } from '../../utils/matrix';
|
||||
import { safeFile } from '../../utils/mimeTypes';
|
||||
import { getMsgContent } from '../room/msgContent';
|
||||
|
|
@ -131,15 +132,20 @@ type ShareSetProps = {
|
|||
export function ShareSet({ room, folder = [], into }: ShareSetProps) {
|
||||
const mx = useMatrixClient();
|
||||
const queryClient = useQueryClient();
|
||||
const mediaConfig = useMediaConfig();
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
// Built once per mount (AlbumView keys the panel by room): the pick lives in the controller, so a
|
||||
// rerender with a new client or Room object must not build another. Live values go through `live`.
|
||||
const live = useRef({ mx, room, queryClient, folder, into });
|
||||
live.current = { mx, room, queryClient, folder, into };
|
||||
const live = useRef({ mx, room, queryClient, folder, into, mediaConfig });
|
||||
live.current = { mx, room, queryClient, folder, into, mediaConfig };
|
||||
const [controller]: [ShareController<File>, unknown] = useState(() =>
|
||||
createShareController<File>({
|
||||
roomId: room.roomId,
|
||||
isEncrypted: () => live.current.room.hasEncryptionStateEvent(),
|
||||
maxBytes: () => {
|
||||
const cap = live.current.mediaConfig['m.upload.size'];
|
||||
return typeof cap === 'number' && Number.isFinite(cap) && cap > 0 ? cap : undefined;
|
||||
},
|
||||
onSent: () =>
|
||||
live.current.queryClient.invalidateQueries({ queryKey: ['albums', room.roomId] }),
|
||||
run: (files, title, onFile, signal) =>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest';
|
|||
import { Picked, ShareDeps, createShareController } from './shareController';
|
||||
import { ShareResult } from './shareSet';
|
||||
|
||||
const file = (name: string): Picked => ({ name, type: 'audio/ogg' });
|
||||
const file = (name: string, size?: number): Picked => ({ name, type: 'audio/ogg', size });
|
||||
const MB = 1024 * 1024;
|
||||
const ok = (n: number): ShareResult => ({ albumId: '$r', sent: n, failed: [], failedAt: [] });
|
||||
|
||||
/** A run the test settles by hand, so "in flight" is a state the test can stand in. */
|
||||
|
|
@ -279,3 +280,101 @@ describe('Stop', () => {
|
|||
await again;
|
||||
});
|
||||
});
|
||||
|
||||
describe('files over the homeserver cap', () => {
|
||||
const cap = 50 * MB;
|
||||
const capped = () => make({ maxBytes: () => cap });
|
||||
|
||||
it('are named in the note and never sent; the rest go', async () => {
|
||||
const { c, h } = capped();
|
||||
c.pick([file('small.ogg', 3 * MB), file('film.mp4', 80 * MB), file('also.ogg', 1)]);
|
||||
const sent = c.send();
|
||||
expect(h.calls[0].files.map((f) => f.name)).toEqual(['small.ogg', 'also.ogg']);
|
||||
expect(c.getState().files.map((f) => f.name)).toEqual(['small.ogg', 'also.ogg']);
|
||||
h.release(ok(2));
|
||||
await sent;
|
||||
expect(c.getState().phase).toEqual({
|
||||
kind: 'done',
|
||||
text: 'Shared 2 files. Too large for this server (limit 50 MB): film.mp4',
|
||||
});
|
||||
expect(c.getState().files).toEqual([]);
|
||||
});
|
||||
|
||||
it('are not kept for a retry that cannot help them, while a failed small file is', async () => {
|
||||
const { c, h } = capped();
|
||||
c.pick([file('a.ogg', 1), file('big.wav', 60 * MB)]);
|
||||
const sent = c.send();
|
||||
h.release({ albumId: '$r', sent: 0, failed: ['a.ogg'], failedAt: [0] });
|
||||
await sent;
|
||||
expect(c.getState().files.map((f) => f.name)).toEqual(['a.ogg']);
|
||||
});
|
||||
|
||||
it('a pick that is all over the cap sends nothing: no run, so no album root, and the pick clears', async () => {
|
||||
const { c, h, sentTo } = capped();
|
||||
c.pick([file('a.mp4', 51 * MB), file('b.mp4', 99 * MB)]);
|
||||
const { epoch } = c.getState();
|
||||
await c.send();
|
||||
expect(h.calls).toHaveLength(0);
|
||||
expect(sentTo).toEqual([]);
|
||||
expect(c.getState()).toMatchObject({ files: [], epoch: epoch + 1 });
|
||||
expect(c.getState().phase).toEqual({
|
||||
kind: 'done',
|
||||
text: 'Nothing was shared. Too large for this server (limit 50 MB): a.mp4, b.mp4',
|
||||
});
|
||||
});
|
||||
|
||||
it('a file of exactly the cap goes: the server refuses only what is larger', async () => {
|
||||
const { c, h } = capped();
|
||||
c.pick([file('edge.bin', cap)]);
|
||||
const sent = c.send();
|
||||
expect(h.calls[0].files).toHaveLength(1);
|
||||
h.release(ok(1));
|
||||
await sent;
|
||||
});
|
||||
|
||||
it('the room is not held busy by an all-refused pick, so the next pick sends', async () => {
|
||||
const { c, h } = capped();
|
||||
c.pick([file('a.mp4', 99 * MB)]);
|
||||
await c.send();
|
||||
c.pick([file('b.ogg', 1)]);
|
||||
const sent = c.send();
|
||||
expect(h.calls).toHaveLength(1);
|
||||
h.release(ok(1));
|
||||
await sent;
|
||||
});
|
||||
|
||||
it('a server that states no cap, or a file with no size, refuses nothing', async () => {
|
||||
const none = make();
|
||||
none.c.pick([file('a', 10 ** 12)]);
|
||||
const s1 = none.c.send();
|
||||
expect(none.h.calls[0].files).toHaveLength(1);
|
||||
none.h.release(ok(1));
|
||||
await s1;
|
||||
const unsized = make({ maxBytes: () => cap }, '!other:x');
|
||||
unsized.c.pick([file('b')]);
|
||||
const s2 = unsized.c.send();
|
||||
expect(unsized.h.calls[0].files).toHaveLength(1);
|
||||
unsized.h.release(ok(1));
|
||||
await s2;
|
||||
});
|
||||
|
||||
it('a fractional cap reads with one decimal', async () => {
|
||||
const { c } = make({ maxBytes: () => 1.5 * MB });
|
||||
c.pick([file('a', 2 * MB)]);
|
||||
await c.send();
|
||||
expect(c.getState().phase).toMatchObject({ text: expect.stringContaining('limit 1.5 MB') });
|
||||
});
|
||||
|
||||
it('a run that throws still names the refused files, which are not kept', async () => {
|
||||
const { c, h } = capped();
|
||||
c.pick([file('a.ogg', 1), file('big.wav', 60 * MB)]);
|
||||
const sent = c.send();
|
||||
h.fail(new Error('network down'));
|
||||
await sent;
|
||||
expect(c.getState().phase).toEqual({
|
||||
kind: 'done',
|
||||
text: 'Could not share: network down. Too large for this server (limit 50 MB): big.wav',
|
||||
});
|
||||
expect(c.getState().files.map((f) => f.name)).toEqual(['a.ogg']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// send is in, and the rules that keep a room to one send at a time. ShareSet.tsx draws it.
|
||||
import { ShareResult, remainingAfter, resultNote } from './shareSet';
|
||||
|
||||
export type Picked = { name: string; type: string };
|
||||
export type Picked = { name: string; type: string; size?: number };
|
||||
|
||||
export type SharePhase =
|
||||
| { kind: 'idle' }
|
||||
|
|
@ -19,11 +19,28 @@ export type ShareState<F extends Picked> = {
|
|||
|
||||
export const ENCRYPTED_NOTE = 'Not shared: this room is encrypted.';
|
||||
|
||||
const MIB = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Files the homeserver would refuse for size (its `m.upload.size`, which Synapse enforces as a
|
||||
* limit, so a file of exactly that size goes), split from the rest before anything is sent. A
|
||||
* missing cap or size refuses nothing.
|
||||
*/
|
||||
export const splitBySize = <F extends Picked>(files: F[], cap: number | undefined) => {
|
||||
const over = (f: F) => cap !== undefined && f.size !== undefined && f.size > cap;
|
||||
return { sendable: files.filter((f) => !over(f)), tooLarge: files.filter(over) };
|
||||
};
|
||||
|
||||
const tooLargeNote = (names: string[], cap: number): string =>
|
||||
`Too large for this server (limit ${Math.round((cap / MIB) * 10) / 10} MB): ${names.join(', ')}`;
|
||||
|
||||
export type ShareDeps<F extends Picked> = {
|
||||
roomId: string;
|
||||
isEncrypted: () => boolean;
|
||||
/** Sends the files as one album; reports each settled file through onFile. */
|
||||
run: (files: F[], title: string, onFile: () => void, signal: AbortSignal) => Promise<ShareResult>;
|
||||
/** The homeserver's upload cap in bytes (`m.upload.size`), when it states one. */
|
||||
maxBytes?: () => number | undefined;
|
||||
/** Called after a send that reached the room, whatever it left unsent. */
|
||||
onSent?: () => void;
|
||||
};
|
||||
|
|
@ -48,11 +65,27 @@ export const createShareController = <F extends Picked>(deps: ShareDeps<F>) => {
|
|||
set({ phase: { kind: 'done', text: ENCRYPTED_NOTE } });
|
||||
return;
|
||||
}
|
||||
// Files over the cap are named and dropped from the pick before any upload or album root: a
|
||||
// retry cannot help them, and a root with nothing under it is a lone message nobody lists.
|
||||
const cap = deps.maxBytes?.();
|
||||
const { sendable, tooLarge } = splitBySize(state.files, cap);
|
||||
const sizeNote = tooLarge.length
|
||||
? tooLargeNote(
|
||||
tooLarge.map((f) => f.name),
|
||||
cap as number
|
||||
)
|
||||
: '';
|
||||
if (sendable.length === 0) {
|
||||
const phase: SharePhase = { kind: 'done', text: `Nothing was shared. ${sizeNote}` };
|
||||
set({ files: [], title: '', epoch: state.epoch + 1, phase });
|
||||
return;
|
||||
}
|
||||
sending.add(deps.roomId);
|
||||
const abort = new AbortController();
|
||||
running = abort;
|
||||
const { files, title } = state;
|
||||
set({ phase: { kind: 'sending', done: 0 } });
|
||||
const { title } = state;
|
||||
const files = sendable;
|
||||
set({ files, phase: { kind: 'sending', done: 0 } });
|
||||
let settled = 0;
|
||||
try {
|
||||
const res = await deps.run(
|
||||
|
|
@ -66,12 +99,16 @@ export const createShareController = <F extends Picked>(deps: ShareDeps<F>) => {
|
|||
);
|
||||
deps.onSent?.();
|
||||
const left = remainingAfter(files, res);
|
||||
const phase: SharePhase = { kind: 'done', text: resultNote(res) };
|
||||
const phase: SharePhase = {
|
||||
kind: 'done',
|
||||
text: sizeNote ? `${resultNote(res)} ${sizeNote}` : resultNote(res),
|
||||
};
|
||||
// One notification: no state is ever drawn with the pick emptied and the send still running.
|
||||
if (left.length === 0) set({ files: [], title: '', epoch: state.epoch + 1, phase });
|
||||
else set({ files: left, phase });
|
||||
} catch (e: any) {
|
||||
set({ phase: { kind: 'done', text: `Could not share: ${e?.message ?? 'send failed'}` } });
|
||||
const failure = `Could not share: ${e?.message ?? 'send failed'}`;
|
||||
set({ phase: { kind: 'done', text: sizeNote ? `${failure}. ${sizeNote}` : failure } });
|
||||
} finally {
|
||||
running = undefined;
|
||||
sending.delete(deps.roomId);
|
||||
|
|
|
|||
Loading…
Reference in a new issue