mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(types): truncate pattern path segments to 255 bytes
Truncate long path segments generated during upload pattern resolution and download filename generation to prevent ENAMETOOLONG errors. Keep the file extension and suffix of the segment, and use a deterministic hash of the original segment to ensure unique truncations. Closes #549
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
export const DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE = "<{authors:first}|Unknown Author>/<{series}/><{seriesIndex}. ><{title}|{originalFilename}>< ({year})>";
|
||||
export const DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE =
|
||||
"<{authors:first}|Unknown Author>/<{series}/><{seriesIndex}. ><{title}|{originalFilename}>< ({year})>";
|
||||
export const DEFAULT_UPLOAD_PATTERN_BOOK_PER_FOLDER =
|
||||
"<{authors:first}|Unknown Author>/<{series}/><{seriesIndex}. ><{title}|{originalFilename}>< ({year})>/<{seriesIndex}. ><{title}|{originalFilename}>< ({year})>";
|
||||
export const DEFAULT_DOWNLOAD_PATTERN = "{originalFilename}";
|
||||
@@ -37,6 +38,8 @@ export type PathResolverOptions = {
|
||||
replacementCharacter?: "_" | "-";
|
||||
};
|
||||
|
||||
export const MAX_PATH_SEGMENT_BYTES = 255;
|
||||
|
||||
const MODIFIER_PLACEHOLDER_REGEX = /\{([^}:]+)(?::([^}]+))?}/g;
|
||||
const WINDOWS_RESERVED_NAMES = new Set([
|
||||
"CON",
|
||||
@@ -183,6 +186,63 @@ function ensurePathLastSegmentExtension(path: string, dotExt: string): string {
|
||||
return segments.join("/");
|
||||
}
|
||||
|
||||
function utf8ByteLength(value: string): number {
|
||||
let bytes = 0;
|
||||
for (const char of value) {
|
||||
const codePoint = char.codePointAt(0) ?? 0;
|
||||
if (codePoint <= 0x7f) bytes += 1;
|
||||
else if (codePoint <= 0x7ff) bytes += 2;
|
||||
else if (codePoint <= 0xffff) bytes += 3;
|
||||
else bytes += 4;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function truncateUtf8(value: string, maxBytes: number): string {
|
||||
let bytes = 0;
|
||||
let result = "";
|
||||
|
||||
for (const char of value) {
|
||||
const charBytes = utf8ByteLength(char);
|
||||
if (bytes + charBytes > maxBytes) break;
|
||||
result += char;
|
||||
bytes += charBytes;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function hashSegment(value: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (const char of value) {
|
||||
hash = Math.imul(hash ^ (char.codePointAt(0) ?? 0), 0x01000193) >>> 0;
|
||||
}
|
||||
return hash.toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
function truncatePathSegment(segment: string, suffixToPreserve = ""): string {
|
||||
if (!segment || utf8ByteLength(segment) <= MAX_PATH_SEGMENT_BYTES) return segment;
|
||||
|
||||
const preservedSuffix =
|
||||
suffixToPreserve && segment.toLowerCase().endsWith(suffixToPreserve.toLowerCase()) ? segment.slice(-suffixToPreserve.length) : "";
|
||||
const stem = preservedSuffix ? segment.slice(0, -preservedSuffix.length) : segment;
|
||||
const truncationSuffix = `~${hashSegment(segment)}`;
|
||||
const suffixBytes = utf8ByteLength(truncationSuffix) + utf8ByteLength(preservedSuffix);
|
||||
const stemBudget = MAX_PATH_SEGMENT_BYTES - suffixBytes;
|
||||
|
||||
if (stemBudget <= 0) {
|
||||
return truncateUtf8(segment, MAX_PATH_SEGMENT_BYTES);
|
||||
}
|
||||
|
||||
return `${truncateUtf8(stem, stemBudget)}${truncationSuffix}${preservedSuffix}`;
|
||||
}
|
||||
|
||||
function limitPathSegmentBytes(path: string, dotExt: string): string {
|
||||
const segments = path.split("/");
|
||||
const lastIdx = segments.length - 1;
|
||||
return segments.map((segment, index) => truncatePathSegment(segment, index === lastIdx ? dotExt : "")).join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a file naming pattern to a relative path (no leading slash).
|
||||
* - Pattern ending with '/' → folder path; original filename is used as the file stem.
|
||||
@@ -200,10 +260,10 @@ export function resolveUploadPath(pattern: string, values: Record<string, string
|
||||
|
||||
if (resolved.endsWith("/")) {
|
||||
const filename = ensureTrailingExtension(resolvedValues["originalFilename"] ?? "upload", dotExt);
|
||||
return resolved + filename;
|
||||
return limitPathSegmentBytes(resolved + filename, dotExt);
|
||||
}
|
||||
|
||||
return ensurePathLastSegmentExtension(resolved, dotExt);
|
||||
return limitPathSegmentBytes(ensurePathLastSegmentExtension(resolved, dotExt), dotExt);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -225,5 +285,5 @@ export function resolveDownloadFilename(pattern: string, values: Record<string,
|
||||
stem = stem.trim();
|
||||
if (!stem) return null;
|
||||
|
||||
return ensureTrailingExtension(stem, dotExt);
|
||||
return truncatePathSegment(ensureTrailingExtension(stem, dotExt), dotExt);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user