mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
Stalwart 1.0 checks names more strictly than its capability says: a reserved name may not be the stem either (`con.txt`, `Aux.tar.gz`), and control characters are refused (macOS `Icon\r`). Names over 255 bytes of UTF-8 were never accepted. Uploaded names are now made acceptable before they are sent: control characters become "_", a reserved stem gets "_" after it (`con.txt` becomes `con_.txt`), and a long name is shortened, keeping its extension. The rename and new-folder dialogs refuse such names with their own messages. The rules apply wherever the server publishes naming rules, so 0.16.6 to 0.16.23 get `con_.txt` too. One refused file used to abort a whole upload. Uploads now go on and report the files that failed at the end, and a folder upload skips the contents of a folder it could not create instead of dropping them into the folder the upload started in. What would sit deeper than 1.0's `maxFileNodeDepth` (64) is left out before it is uploaded, and a folder copy that would go past it fails before the first create. File errors keep their JMAP type, and the Files toasts now say why an action failed: too many files or folders (`overQuota`), folders nested too deeply, a refused name, missing rights, or a file too large. New and copied files get a bare lowercase media type, which 1.0 requires.
133 lines
5.3 KiB
TypeScript
133 lines
5.3 KiB
TypeScript
/**
|
|
* FileNode naming rules a server publishes in its filenode capability
|
|
* (`forbiddenNameChars`, `forbiddenNodeNames`, `maxSizeFileNodeName`;
|
|
* 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.
|
|
*
|
|
* Stalwart 1.0 checks more than its capability says (`validate_name`,
|
|
* jmap/src/file/node.rs), on every name set over JMAP:
|
|
* - no control characters (Rust's `char::is_control`: U+0000-001F, U+007F-009F);
|
|
* - a reserved name may not be the stem either, the text before the first
|
|
* "." (unless that is empty): "con.txt" and "Aux.tar.gz" are refused too.
|
|
* 1.0's rules take in 0.16's, so they are applied wherever the server
|
|
* publishes rules; on 0.16.6-0.16.23 "con.txt" is renamed although it would
|
|
* have been taken.
|
|
*/
|
|
export interface FileNameRules {
|
|
forbiddenChars: string;
|
|
forbiddenNames: string[];
|
|
/** Longest name, in bytes of UTF-8. */
|
|
maxNameBytes: number;
|
|
}
|
|
|
|
export type FileNameProblem =
|
|
| { kind: 'chars'; chars: string }
|
|
| { kind: 'control' }
|
|
| { kind: 'tooLong' }
|
|
| { kind: 'reserved' };
|
|
|
|
// Every Stalwart release refuses names over 255 bytes.
|
|
const DEFAULT_MAX_NAME_BYTES = 255;
|
|
|
|
export function fileNameRulesFrom(capability: unknown): FileNameRules | null {
|
|
if (!capability || typeof capability !== 'object') return null;
|
|
const cap = capability as { forbiddenNameChars?: unknown; forbiddenNodeNames?: unknown; maxSizeFileNodeName?: 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;
|
|
const maxNameBytes = typeof cap.maxSizeFileNodeName === 'number' && cap.maxSizeFileNodeName > 0
|
|
? cap.maxSizeFileNodeName
|
|
: DEFAULT_MAX_NAME_BYTES;
|
|
return { forbiddenChars, forbiddenNames, maxNameBytes };
|
|
}
|
|
|
|
function isControlChar(ch: string): boolean {
|
|
const code = ch.codePointAt(0) ?? 0;
|
|
return code <= 0x1f || (code >= 0x7f && code <= 0x9f);
|
|
}
|
|
|
|
function utf8Length(text: string): number {
|
|
return new TextEncoder().encode(text).length;
|
|
}
|
|
|
|
// Stalwart compares reserved names ignoring ASCII case only.
|
|
function asciiLower(text: string): string {
|
|
return text.replace(/[A-Z]/g, c => c.toLowerCase());
|
|
}
|
|
|
|
function nameStem(name: string): string {
|
|
const dot = name.indexOf('.');
|
|
return dot > 0 ? name.slice(0, dot) : name;
|
|
}
|
|
|
|
function isReservedName(name: string, reserved: string[]): boolean {
|
|
const whole = asciiLower(name);
|
|
const stem = asciiLower(nameStem(name));
|
|
return reserved.some(n => asciiLower(n) === whole || asciiLower(n) === stem);
|
|
}
|
|
|
|
export function fileNameProblem(name: string, rules: FileNameRules | null): FileNameProblem | null {
|
|
if (!rules) return null;
|
|
if (utf8Length(name) > rules.maxNameBytes) return { kind: 'tooLong' };
|
|
const bad = [...new Set([...name].filter(c => rules.forbiddenChars.includes(c)))];
|
|
if (bad.length) return { kind: 'chars', chars: bad.join(' ') };
|
|
if ([...name].some(isControlChar)) return { kind: 'control' };
|
|
if (isReservedName(name, rules.forbiddenNames)) return { kind: 'reserved' };
|
|
return null;
|
|
}
|
|
|
|
/** The longest start of `text` that fits in `maxBytes` of UTF-8, whole characters only. */
|
|
function cutToBytes(text: string, maxBytes: number): string {
|
|
let out = '';
|
|
let used = 0;
|
|
for (const ch of text) {
|
|
const bytes = utf8Length(ch);
|
|
if (used + bytes > maxBytes) break;
|
|
out += ch;
|
|
used += bytes;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** `name` cut to `maxBytes`, keeping the extension when it leaves room for a stem. */
|
|
function fitToBytes(name: string, maxBytes: number): string {
|
|
if (utf8Length(name) <= maxBytes) return name;
|
|
const dot = name.lastIndexOf('.');
|
|
const ext = dot > 0 ? name.slice(dot) : '';
|
|
const extBytes = utf8Length(ext);
|
|
if (!ext || extBytes >= maxBytes) return cutToBytes(name, maxBytes);
|
|
return cutToBytes(name.slice(0, dot), maxBytes - extBytes) + ext;
|
|
}
|
|
|
|
/**
|
|
* A reserved name made acceptable: "_" after the stem ("con.txt" becomes
|
|
* "con_.txt", "CON" becomes "CON_", ".." becomes ".._"). A name already at
|
|
* the byte limit has the stem's last character replaced instead.
|
|
*/
|
|
function unreserved(name: string, maxBytes: number): string {
|
|
const stemEnd = nameStem(name).length;
|
|
const marked = `${name.slice(0, stemEnd)}_${name.slice(stemEnd)}`;
|
|
if (utf8Length(marked) <= maxBytes) return marked;
|
|
const stem = [...name.slice(0, stemEnd)];
|
|
stem[stem.length - 1] = '_';
|
|
return stem.join('') + name.slice(stemEnd);
|
|
}
|
|
|
|
/**
|
|
* A variant of `name` the server accepts: forbidden and control characters
|
|
* become "_", a reserved name or stem gets "_" after the stem, and a name over
|
|
* the byte limit is shortened, keeping its extension. 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 => (isControlChar(c) || rules.forbiddenChars.includes(c) ? '_' : c)).join('');
|
|
out = fitToBytes(out, rules.maxNameBytes);
|
|
if (isReservedName(out, rules.forbiddenNames)) out = unreserved(out, rules.maxNameBytes);
|
|
return out;
|
|
}
|