check / check (push) Canceled after 0s
The default names of `quak get`, `quak get-thumb`, `downloadFile` and `downloadThumbnail`, and the album folders of `backup-metadata`, could be over 255 bytes, which a file system refuses. `defaultFileName` and `shortenKeepingExtension` in `src/filename.ts` now cut such a name short, keeping its extension when it fits; a live photo's image and video get their own extension before the cut. A cut name never starts with a dot and is never a Windows device name. `--out` and `outPath` are kept as given. A `backup-metadata` folder keeps its `<id>-`. `safeExtension` gives `.bin` for an extension of more than 16 letters and digits, so a save path's name always fits. Model: opus-5-5
112 lines
5.2 KiB
TypeScript
112 lines
5.2 KiB
TypeScript
// File names built from server-supplied metadata.
|
|
//
|
|
// A file's title and a collection's name are decrypted from data the server
|
|
// hands us, and quak does not trust the server. Any name taken from them and
|
|
// used on disk goes through here, so it can only ever name one file inside the
|
|
// directory the caller chose: never a path, never `..`, never hidden, never a
|
|
// Windows device name. Such a name can still be too long for a file name, and
|
|
// `shortenToBytes` or `shortenKeepingExtension` cuts one short.
|
|
// `defaultFileName` does both for the name a file is saved under when the
|
|
// caller gives no path.
|
|
//
|
|
// A path the user typed (`--out`, `outPath`) is not passed through here: the
|
|
// caller is trusted, the server is not.
|
|
|
|
import { extname } from "node:path";
|
|
|
|
// Path separators, characters Windows forbids in file names, and control
|
|
// characters (NUL included).
|
|
// eslint-disable-next-line no-control-regex
|
|
const UNSAFE_CHARACTERS = /[/\\:*?"<>|\x00-\x1f\x7f]/g;
|
|
|
|
// Names Windows reserves for devices, with or without an extension.
|
|
const RESERVED_DEVICE_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i;
|
|
|
|
// `name` made safe to use as a single file name. Each unsafe character becomes
|
|
// `_`, a leading run of dots becomes one `_`, and a device name gets a leading
|
|
// `_`. A name with none of these comes back unchanged. An empty name becomes
|
|
// `fallback`, which the caller derives from the record's ID.
|
|
export const sanitizeFileName = (name: string, fallback: string): string => {
|
|
if (name === "") return fallback;
|
|
const cleaned = name.replace(UNSAFE_CHARACTERS, "_").replace(/^\.+/, "_");
|
|
return RESERVED_DEVICE_NAME.test(cleaned) ? `_${cleaned}` : cleaned;
|
|
};
|
|
|
|
// The extension of `title` (".jpg"), or ".bin" when it has none, it holds
|
|
// anything but letters and digits, or it is more than 16 of them long.
|
|
export const safeExtension = (title: string): string => {
|
|
const ext = extname(title);
|
|
return /^\.[A-Za-z0-9]{1,16}$/.test(ext) ? ext : ".bin";
|
|
};
|
|
|
|
// `name` with its extension, if it has one, replaced by `ext` (".mov").
|
|
export const withExtension = (name: string, ext: string): string =>
|
|
name.slice(0, name.length - extname(name).length) + ext;
|
|
|
|
// The longest file name, in bytes, that ext4 and most other file systems allow.
|
|
export const MAX_NAME_BYTES = 255;
|
|
|
|
// `name` cut short to at most `maxBytes` bytes of UTF-8, the encoding a file
|
|
// name is written to disk in, between two characters, never inside one. A name
|
|
// that fits comes back unchanged.
|
|
export const shortenToBytes = (name: string, maxBytes: number): string => {
|
|
let bytes = 0;
|
|
let end = 0;
|
|
for (const character of name) {
|
|
bytes += Buffer.byteLength(character);
|
|
if (bytes > maxBytes) break;
|
|
end += character.length;
|
|
}
|
|
return name.slice(0, end);
|
|
};
|
|
|
|
// `name` cut short to fit in `maxBytes` bytes with `insert` put right before
|
|
// its extension (`IMG_0001 (12).jpg`), each cut made as `shortenToBytes`
|
|
// makes it. The part before the extension is cut first, so the extension is
|
|
// kept whenever it fits, but that part always keeps its first character:
|
|
// when the extension leaves no room for `insert` and that character, the
|
|
// extension is cut short too, and the part before it keeps the bytes that cut
|
|
// leaves over. So a name that does not start with a dot never comes back
|
|
// starting with one, which would hide it. `insert` is never cut. With no
|
|
// `insert`, a name that fits comes back unchanged.
|
|
export const shortenKeepingExtension = (
|
|
name: string,
|
|
maxBytes: number,
|
|
insert = "",
|
|
): string => {
|
|
const fullExt = extname(name);
|
|
const stem = name.slice(0, name.length - fullExt.length);
|
|
const first = Array.from(stem)[0] ?? "";
|
|
const ext = shortenToBytes(
|
|
fullExt,
|
|
maxBytes - Buffer.byteLength(insert + first),
|
|
);
|
|
const room = maxBytes - Buffer.byteLength(insert + ext);
|
|
return shortenToBytes(stem, room) + insert + ext;
|
|
};
|
|
|
|
// The name a file is saved under in the working directory when the caller
|
|
// gives no path (`quak get` without `--out`, `downloadFile` without
|
|
// `outPath`): `prefix` (`thumb_` for a thumbnail), then the file's title with
|
|
// unsafe characters replaced, or `file-<id>` when the title is empty, cut
|
|
// short so that the whole fits in a file name, keeping the title's extension
|
|
// whenever it fits. `ext` (".mov"), when given, takes the place of the
|
|
// title's extension before the name is cut short; a live photo's image and
|
|
// video are each named with their own. The cut can leave a device name
|
|
// (`CONSOLE.<long extension>` becomes `CON.<long extension>`); that name gets
|
|
// a leading `_`, as `sanitizeFileName` gives one, and is cut a byte shorter to
|
|
// leave room for it.
|
|
export const defaultFileName = (
|
|
title: string,
|
|
fileID: number,
|
|
opts: { prefix?: string; ext?: string } = {},
|
|
): string => {
|
|
const { prefix = "", ext } = opts;
|
|
const safe = sanitizeFileName(title, `file-${fileID}`);
|
|
const name = ext === undefined ? safe : withExtension(safe, ext);
|
|
const room = MAX_NAME_BYTES - Buffer.byteLength(prefix);
|
|
const cut = prefix + shortenKeepingExtension(name, room);
|
|
if (!RESERVED_DEVICE_NAME.test(cut)) return cut;
|
|
return `_${prefix}${shortenKeepingExtension(name, room - 1)}`;
|
|
};
|