feat(portal): reuse unlock code for protected downloads
This commit is contained in:
1 parent
475dd3379b
commit
596246f20a
9 files changed
+268
-13
No files matched your search
@@ -25,6 +25,7 @@ const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c')
|
||||
<noscript><p>JavaScript is required to unlock the employee portal locally. Please contact AIA for access assistance.</p></noscript>
|
||||
</div>
|
||||
<div class="employee-portal__content" tabindex="-1" role="region" aria-label="Unlocked employee portal" hidden data-portal-content></div>
|
||||
<p class="employee-portal__download-feedback" role="status" aria-live="polite" hidden data-portal-download-feedback></p>
|
||||
<script is:inline type="application/json" data-portal-payload set:html={serialized}></script>
|
||||
</section>
|
||||
|
||||
@@ -35,6 +36,7 @@ const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c')
|
||||
input { background: #fcf9f5; border: 1px solid #edeef3; border-radius: 4px; font: inherit; padding: .65rem; max-width: 100%; min-width: 0; }
|
||||
input:focus-visible { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 3px; }
|
||||
.employee-portal__content:focus { outline: none; }
|
||||
.employee-portal__download-feedback { margin-block: 1rem; font-weight: 600; }
|
||||
[hidden] { display: none; }
|
||||
.employee-portal__content :global(a) { color: var(--color-primary); }
|
||||
.employee-portal__content :global(a:focus-visible) { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 3px; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { decryptPortalStream, validatePortalPayload } from '../utils/portal-crypto.mjs';
|
||||
import { fetchProtectedPortalResource, portalDownloadErrorMessage, portalRoleForStreamId, protectedPortalResource } from '../utils/portal-download.mjs';
|
||||
import { portalLinkPresentation } from '../utils/portal-presentation';
|
||||
|
||||
/** Build a passive DOM, never inject executable decrypted HTML. Remote media,
|
||||
@@ -24,6 +25,8 @@ 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);
|
||||
if (resource) element.dataset.portalResourceRole = resource.role;
|
||||
}
|
||||
} catch { /* Invalid destination remains visible text without navigation. */ }
|
||||
}
|
||||
@@ -99,6 +102,9 @@ function initialize() {
|
||||
const feedback = root.querySelector<HTMLElement>('[data-portal-feedback]')!;
|
||||
const prompt = root.querySelector<HTMLElement>('[data-portal-prompt]')!;
|
||||
const content = root.querySelector<HTMLElement>('[data-portal-content]')!;
|
||||
const downloadFeedback = root.querySelector<HTMLElement>('[data-portal-download-feedback]')!;
|
||||
let credential: { role: 'staff' | 'management'; code: string } | null = null;
|
||||
let downloading = false;
|
||||
let data: ReturnType<typeof validatePortalPayload>;
|
||||
try {
|
||||
if (!crypto.subtle) throw new Error();
|
||||
@@ -106,6 +112,45 @@ function initialize() {
|
||||
input.disabled = false; button.disabled = false;
|
||||
feedback.textContent = '';
|
||||
} catch { feedback.textContent = 'Portal access is unavailable in this preview. Please contact AIA.'; return; }
|
||||
const clearSession = () => {
|
||||
credential = null;
|
||||
input.value = '';
|
||||
content.replaceChildren();
|
||||
content.hidden = true;
|
||||
prompt.hidden = false;
|
||||
downloadFeedback.hidden = true;
|
||||
downloadFeedback.textContent = '';
|
||||
};
|
||||
const saveDownload = (blob: Blob, filename: string) => {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
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);
|
||||
};
|
||||
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);
|
||||
if (!resource || !credential) {
|
||||
downloadFeedback.textContent = portalDownloadErrorMessage(undefined);
|
||||
downloadFeedback.hidden = false;
|
||||
return;
|
||||
}
|
||||
downloading = true;
|
||||
target.setAttribute('aria-disabled', 'true');
|
||||
downloadFeedback.textContent = 'Downloading document…';
|
||||
downloadFeedback.hidden = false;
|
||||
void fetchProtectedPortalResource(resource, credential)
|
||||
.then(({ blob, filename }) => {
|
||||
saveDownload(blob, filename);
|
||||
downloadFeedback.textContent = `Downloaded ${filename}.`;
|
||||
})
|
||||
.catch((error) => { downloadFeedback.textContent = portalDownloadErrorMessage(error); })
|
||||
.finally(() => { downloading = false; target.removeAttribute('aria-disabled'); });
|
||||
});
|
||||
const unlock = async () => {
|
||||
if (button.disabled) return;
|
||||
button.disabled = true;
|
||||
@@ -116,8 +161,12 @@ function initialize() {
|
||||
for (const stream of data.streams) {
|
||||
const plain = await decryptPortalStream(stream, pass);
|
||||
if (plain !== null) {
|
||||
const role = portalRoleForStreamId(stream.id);
|
||||
if (!role) throw new Error('Unknown portal stream');
|
||||
credential = { role, code: pass };
|
||||
content.replaceChildren(safePortalContent(plain));
|
||||
prompt.hidden = true; content.hidden = false;
|
||||
downloadFeedback.hidden = true; downloadFeedback.textContent = '';
|
||||
input.value = ''; content.focus(); return;
|
||||
}
|
||||
}
|
||||
@@ -127,8 +176,9 @@ function initialize() {
|
||||
};
|
||||
button.addEventListener('click', unlock);
|
||||
input.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); void unlock(); } });
|
||||
// BFCache must not restore an already unlocked view.
|
||||
addEventListener('pagehide', () => { input.value = ''; content.replaceChildren(); content.hidden = true; prompt.hidden = false; });
|
||||
// Credentials exist only in this page closure and are discarded on exit/swap.
|
||||
addEventListener('pagehide', clearSession, { once: true });
|
||||
document.addEventListener('astro:before-swap', clearSession, { once: true });
|
||||
}
|
||||
initialize();
|
||||
document.addEventListener('astro:page-load', initialize);
|
||||
@@ -0,0 +1,70 @@
|
||||
const roles = new Set(['staff', 'management']);
|
||||
const resourcePath = /^\/(staff|management)-resources\/([A-Za-z0-9][A-Za-z0-9._-]{0,240})$/;
|
||||
|
||||
export function portalRoleForStreamId(id) {
|
||||
if (id === 'b') return 'staff';
|
||||
if (id === 'a') return 'management';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function protectedPortalResource(href, origin) {
|
||||
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 };
|
||||
}
|
||||
|
||||
function basicAuthorization(role, code) {
|
||||
if (!roles.has(role) || typeof code !== 'string' || !code || code.length > 1024 || /[\r\n\0]/.test(code)) {
|
||||
throw new PortalDownloadError('session');
|
||||
}
|
||||
const bytes = new TextEncoder().encode(`${role}:${code}`);
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
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; }
|
||||
}
|
||||
|
||||
export async function fetchProtectedPortalResource(resource, credential, fetcher = fetch) {
|
||||
if (!resource || !credential || resource.role !== credential.role) throw new PortalDownloadError('role');
|
||||
const response = await fetcher(resource.url, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: basicAuthorization(credential.role, credential.code) },
|
||||
credentials: 'omit',
|
||||
cache: 'no-store',
|
||||
redirect: 'error',
|
||||
referrerPolicy: 'no-referrer',
|
||||
});
|
||||
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) };
|
||||
}
|
||||
|
||||
export function portalDownloadErrorMessage(error) {
|
||||
if (error instanceof PortalDownloadError && error.code === 'role') return 'This document is not available from this portal.';
|
||||
if (error instanceof PortalDownloadError && (error.code === 'unauthorized' || error.code === 'session')) {
|
||||
return 'Your portal session could not authorize this download. Reload the page and enter the access code again.';
|
||||
}
|
||||
return 'The document could not be downloaded. Please try again or contact AIA.';
|
||||
}
|
||||
Reference in new issue
Block a user