92 lines
2.5 KiB
TypeScript
92 lines
2.5 KiB
TypeScript
import { assertBoundedText, assertPositiveSafeInteger } from "./limits";
|
|
|
|
export interface ObjectUrlLease {
|
|
readonly url: string;
|
|
readonly revoked: boolean;
|
|
revoke(): void;
|
|
}
|
|
|
|
export function sanitizeDownloadFilename(
|
|
input: string,
|
|
fallback = "download.bin",
|
|
maximumLength = 180,
|
|
): string {
|
|
assertPositiveSafeInteger(maximumLength, "Maximum filename length");
|
|
const cleaned = cleanFilename(input);
|
|
const fallbackName = cleanFilename(fallback) || "download.bin";
|
|
const safe = cleaned || fallbackName;
|
|
if (safe.length <= maximumLength) return safe;
|
|
const dot = safe.lastIndexOf(".");
|
|
const extension = dot > 0 && safe.length - dot <= 16 ? safe.slice(dot) : "";
|
|
let stem = safe.slice(0, Math.max(1, maximumLength - extension.length));
|
|
const finalCodeUnit = stem.charCodeAt(stem.length - 1);
|
|
if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff)
|
|
stem = stem.slice(0, -1);
|
|
return `${stem || "_"}${extension}`;
|
|
}
|
|
|
|
function cleanFilename(input: string): string {
|
|
const normalized = assertBoundedText(
|
|
input,
|
|
4096,
|
|
"Filename length",
|
|
).normalize("NFC");
|
|
const cleaned = Array.from(normalized, (character) => {
|
|
const codePoint = character.codePointAt(0) ?? 0;
|
|
return codePoint <= 31 ||
|
|
(codePoint >= 127 && codePoint <= 159) ||
|
|
(codePoint >= 0x202a && codePoint <= 0x202e) ||
|
|
(codePoint >= 0x2066 && codePoint <= 0x2069) ||
|
|
'/\\:*?"<>|'.includes(character)
|
|
? "_"
|
|
: character;
|
|
})
|
|
.join("")
|
|
.replace(/\s+/gu, " ")
|
|
.replace(/^\.+|[. ]+$/gu, "")
|
|
.trim();
|
|
return /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(cleaned)
|
|
? `_${cleaned}`
|
|
: cleaned;
|
|
}
|
|
|
|
export function createObjectUrlLease(
|
|
blob: Blob,
|
|
urlApi: Pick<typeof URL, "createObjectURL" | "revokeObjectURL"> = URL,
|
|
): ObjectUrlLease {
|
|
const url = urlApi.createObjectURL(blob);
|
|
let revoked = false;
|
|
return {
|
|
url,
|
|
get revoked() {
|
|
return revoked;
|
|
},
|
|
revoke() {
|
|
if (revoked) return;
|
|
revoked = true;
|
|
urlApi.revokeObjectURL(url);
|
|
},
|
|
};
|
|
}
|
|
|
|
export function triggerBlobDownload(
|
|
blob: Blob,
|
|
filename: string,
|
|
ownerDocument: Document = document,
|
|
): ObjectUrlLease {
|
|
const lease = createObjectUrlLease(blob);
|
|
const anchor = ownerDocument.createElement("a");
|
|
anchor.href = lease.url;
|
|
anchor.download = sanitizeDownloadFilename(filename);
|
|
anchor.rel = "noopener";
|
|
anchor.hidden = true;
|
|
ownerDocument.body.append(anchor);
|
|
try {
|
|
anchor.click();
|
|
} finally {
|
|
anchor.remove();
|
|
globalThis.setTimeout(() => lease.revoke(), 0);
|
|
}
|
|
return lease;
|
|
}
|