fix(portal): preserve readable download filenames

This commit is contained in:
DeCentN2Madness authored and AIAMarketing committed 2026-11-05 19:21:26 -07:00
1 parent 596246f20a
commit 89cdb00765
5 files changed
+76 -45

No files matched your search

+5 -3
View File
@@ -25,7 +25,7 @@ export function safePortalContent(html: string): DocumentFragment {
if (url.origin !== location.origin && url.protocol === 'https:') element.setAttribute('target', '_blank');
const presentation = portalLinkPresentation([...source.classList], source.style.backgroundColor, url.protocol, source.textContent ?? '');
if (presentation) element.className = presentation;
const resource = protectedPortalResource(url.href, location.origin);
const resource = protectedPortalResource(url.href, location.origin, source.textContent ?? '');
if (resource) element.dataset.portalResourceRole = resource.role;
}
} catch { /* Invalid destination remains visible text without navigation. */ }
@@ -126,14 +126,16 @@ function initialize() {
const link = document.createElement('a');
link.href = objectUrl; link.download = filename; link.hidden = true;
document.body.append(link); link.click(); link.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
// Both supported browsers have ample time to hand the Blob to their
// download manager before its temporary URL is released.
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
};
content.addEventListener('click', (event) => {
const target = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>('a[data-portal-resource-role]') : null;
if (!target) return;
event.preventDefault();
if (downloading) return;
const resource = protectedPortalResource(target.href, location.origin);
const resource = protectedPortalResource(target.href, location.origin, target.textContent ?? '');
if (!resource || !credential) {
downloadFeedback.textContent = portalDownloadErrorMessage(undefined);
downloadFeedback.hidden = false;
+23 -19
View File
@@ -1,5 +1,6 @@
const roles = new Set(['staff', 'management']);
const resourcePath = /^\/(staff|management)-resources\/([A-Za-z0-9][A-Za-z0-9._-]{0,240})$/;
const serverExtension = /([.][a-z0-9]{1,10})$/i;
export function portalRoleForStreamId(id) {
if (id === 'b') return 'staff';
@@ -7,13 +8,32 @@ export function portalRoleForStreamId(id) {
return undefined;
}
export function protectedPortalResource(href, origin) {
export function recognizableDownloadFilename(label, serverFilename) {
const extension = serverExtension.exec(serverFilename)?.[1]?.toLowerCase();
if (!extension) return undefined;
let title = typeof label === 'string' ? label.trim() : '';
if (title.toLowerCase().endsWith(extension)) title = title.slice(0, -extension.length);
const cleaned = title
.normalize('NFKD')
.replace(/\p{M}/gu, '')
.replace(/[’']/g, '')
.replace(/&/g, ' and ')
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/^-+|-+$/g, '');
const base = [...cleaned].slice(0, 180).join('').replace(/-+$/g, '');
if (!base || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(base)) return `AIA-document${extension}`;
return `${base}${extension}`;
}
export function protectedPortalResource(href, origin, label = '') {
let url;
try { url = new URL(href, origin); } catch { return undefined; }
if (url.origin !== origin || url.username || url.password || url.search || url.hash) return undefined;
const match = resourcePath.exec(url.pathname);
if (!match) return undefined;
return { role: match[1], filename: match[2], url: url.href };
const downloadFilename = recognizableDownloadFilename(label, match[2]);
if (!downloadFilename) return undefined;
return { role: match[1], filename: match[2], downloadFilename, url: url.href };
}
function basicAuthorization(role, code) {
@@ -26,22 +46,6 @@ function basicAuthorization(role, code) {
return `Basic ${btoa(binary)}`;
}
function safeFilename(value, fallback) {
if (typeof value !== 'string') return fallback;
const name = value.trim();
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,240}$/.test(name) ? name : fallback;
}
function responseFilename(response, fallback) {
const disposition = response.headers.get('content-disposition') ?? '';
const encoded = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
if (encoded) {
try { return safeFilename(decodeURIComponent(encoded), fallback); } catch { /* Use URL filename. */ }
}
const quoted = disposition.match(/filename="([^"]+)"/i)?.[1];
return safeFilename(quoted, fallback);
}
export class PortalDownloadError extends Error {
constructor(code) { super(code); this.name = 'PortalDownloadError'; this.code = code; }
}
@@ -58,7 +62,7 @@ export async function fetchProtectedPortalResource(resource, credential, fetcher
});
if (response.status === 401 || response.status === 403) throw new PortalDownloadError('unauthorized');
if (!response.ok) throw new PortalDownloadError('unavailable');
return { blob: await response.blob(), filename: responseFilename(response, resource.filename) };
return { blob: await response.blob(), filename: resource.downloadFilename };
}
export function portalDownloadErrorMessage(error) {