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
@@ -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);
|
||||
Reference in new issue
Block a user