Files
webmail/lib/file-name-rules.ts
T
Linus Rath 4d3a3db90b fix: handle the file names and types Stalwart refuses
From 0.16.6 Stalwart refuses FileNode names with `/<>:"\|?*` or a
reserved name such as `CON` ("Name contains a forbidden character."),
and publishes the rules as `forbiddenNameChars` / `forbiddenNodeNames`.
Bulwark did not read them: the error showed up only after an upload had
started, and it aborted the rest of a multi-file or folder upload.

- new folder, new text file and rename now say which characters or
  names the server does not allow, before sending anything
- uploads store an accepted variant instead: forbidden characters
  become `_` and a reserved name gets `_` appended

MIME types over 30 characters (most Office formats) were always stored
as `application/octet-stream`, because Stalwart before 0.16.6 refuses
them. From 0.16.6 the limit is 255, so the real type is kept there.
2026-09-22 20:54:01 +02:00

46 lines
2.1 KiB
TypeScript

/**
* FileNode naming rules a server publishes in its filenode capability
* (`forbiddenNameChars`, `forbiddenNodeNames`; Stalwart 0.16.6+). A name that
* breaks them is refused ("Name contains a forbidden character." / "Name is
* reserved and cannot be used."), which used to surface only after an upload
* had started and then aborted the rest of the batch.
*/
export interface FileNameRules {
forbiddenChars: string;
forbiddenNames: string[];
}
export type FileNameProblem = { kind: 'chars'; chars: string } | { kind: 'reserved' };
export function fileNameRulesFrom(capability: unknown): FileNameRules | null {
if (!capability || typeof capability !== 'object') return null;
const cap = capability as { forbiddenNameChars?: unknown; forbiddenNodeNames?: unknown };
const forbiddenChars = typeof cap.forbiddenNameChars === 'string' ? cap.forbiddenNameChars : '';
const forbiddenNames = Array.isArray(cap.forbiddenNodeNames)
? cap.forbiddenNodeNames.filter((n): n is string => typeof n === 'string')
: [];
if (!forbiddenChars && forbiddenNames.length === 0) return null;
return { forbiddenChars, forbiddenNames };
}
export function fileNameProblem(name: string, rules: FileNameRules | null): FileNameProblem | null {
if (!rules) return null;
const bad = [...new Set([...name].filter(c => rules.forbiddenChars.includes(c)))];
if (bad.length) return { kind: 'chars', chars: bad.join(' ') };
// Matched against the whole name, ignoring case: "CON" is reserved, "CON.txt" is not.
if (rules.forbiddenNames.some(n => n.toLowerCase() === name.toLowerCase())) return { kind: 'reserved' };
return null;
}
/**
* A variant of `name` the server accepts: forbidden characters become "_",
* a reserved name gets "_" appended. Used for uploads, where the name comes
* from the local file system and cannot be asked for again.
*/
export function acceptedFileName(name: string, rules: FileNameRules | null): string {
if (!rules) return name;
let out = [...name].map(c => (rules.forbiddenChars.includes(c) ? '_' : c)).join('');
if (rules.forbiddenNames.some(n => n.toLowerCase() === out.toLowerCase())) out = `${out}_`;
return out;
}