/*! * JDI Justice42 Portal - Download button client * ----------------------------------------------------------------------------- * Binds click handlers to any element with class `.jdi-download-document`, * creates a `jdi_downloadrequest` row via the Power Pages Web API, polls the * row until a sync plug-in flips `jdi_status` off Pending, then downloads the * file using one of two delivery modes: * * - `jdi_deliverymode = File` (SharePoint-backed, up to 25 MB): * GET /_api/jdi_downloadrequests({id})/jdi_file/$value * -> the browser streams the bytes straight out of Dataverse. * * - `jdi_deliverymode = SasUrl` (blob-backed, any size): * The plug-in writes a short-lived read SAS (10-60 min, tiered on file * size) to `jdi_sasurl`; the client navigates to it through an invisible * anchor so the bytes stream directly from Azure. * * After the download is initiated, the staging row is DELETEd so content never * persists on the trigger table. * * Why a trigger record? * Power Pages' Web API (/_api) does not support invoking bound Custom API * actions on custom activity tables. The trigger-record pattern keeps the * same server-side pipeline (whitelist + Graph/Blob) while using only portal- * supported CRUD verbs from the browser. * * Portal configuration prereqs (see docs/PortalDownload_TriggerTable_Setup.md * and docs/PortalDownload_AuditLog_Setup.md): * - Site Settings `Webapi/jdi_downloadrequest/enabled = true` * - Site Settings `Webapi/jdi_downloadrequest/fields` = `jdi_documentid,` * `jdi_contactid,jdi_status,jdi_filename,jdi_mimetype,jdi_filesize,` * `jdi_deliverymode,jdi_sasurl,jdi_sasexpiresat,jdi_file,` * `jdi_errormessage,jdi_ipaddress,jdi_useragent` * - Table Permission (Contact scope) granting Create/Read/Write/Delete on * `jdi_downloadrequest` to the caller's web role. * - Plug-in `CreatePortalDownloadRequestPlugin` registered Sync/PostOperation * on Create of `jdi_downloadrequest`. * - Plug-in `LogPortalDownloadRequestDeletePlugin` registered Sync/ * PostOperation on Delete of `jdi_downloadrequest` with a `PreImage` image * that exposes the columns listed in PortalDownload_AuditLog_Setup.md. * * Global lookups: * - Contact id - window.jdiPortalUser.contactId (required). * * Toast host: * - The script creates a
on first use. * - Errors also render into any element with id="jdi-download-error". * =========================================================================== */ (function () { "use strict"; /** Public constants (kept on window for debugging). */ var PORTAL_DOWNLOAD_NAMESPACE = "jdi.portal.download"; var DOWNLOAD_BUTTON_SELECTOR = ".jdi-download-document"; var INLINE_ERROR_HOST_ID = "jdi-download-error"; var TOAST_REGION_ID = "jdi-toast-region"; var TOAST_AUTO_DISMISS_MS = 5000; /** Trigger-record endpoints. */ var REQUEST_COLLECTION_URL = "/_api/jdi_downloadrequests"; var REQUEST_ITEM_URL_TEMPLATE = "/_api/jdi_downloadrequests({id})"; var REQUEST_FILE_VALUE_URL_TEMPLATE = "/_api/jdi_downloadrequests({id})/jdi_file/$value"; var REQUEST_SELECT_COLUMNS = "jdi_status,jdi_filename,jdi_mimetype,jdi_filesize,jdi_deliverymode," + "jdi_sasurl,jdi_sasexpiresat,jdi_errormessage"; /** jdi_status option-set values (keep in sync with Dataverse/Schema.cs). */ var STATUS_PENDING = 100000000; var STATUS_SUCCEEDED = 100000001; var STATUS_FAILED = 100000002; /** jdi_deliverymode option-set values (keep in sync with Dataverse/Schema.cs). */ var DELIVERY_MODE_FILE = 100000000; var DELIVERY_MODE_SASURL = 100000001; /** Poll configuration: ~30 s total with gentle backoff. */ var POLL_INITIAL_DELAY_MS = 400; var POLL_MAX_DELAY_MS = 2500; var POLL_MAX_ELAPSED_MS = 30000; /* --------------------------------------------------------------------- * Small DOM + compatibility helpers * ------------------------------------------------------------------- */ function qs(selector, root) { return (root || document).querySelector(selector); } function createEl(tag, attrs, children) { var el = document.createElement(tag); if (attrs) { Object.keys(attrs).forEach(function (key) { if (key === "class") { el.className = attrs[key]; } else if (key === "text") { el.textContent = attrs[key]; } else if (key === "html") { el.innerHTML = attrs[key]; } else { el.setAttribute(key, attrs[key]); } }); } if (children) { children.forEach(function (child) { if (child) { el.appendChild(child); } }); } return el; } function closest(element, selector) { if (!element) { return null; } if (element.closest) { return element.closest(selector); } var node = element; while (node && node.nodeType === 1) { if (node.matches && node.matches(selector)) { return node; } node = node.parentNode; } return null; } function delay(ms) { return new Promise(function (resolve) { window.setTimeout(resolve, ms); }); } /* --------------------------------------------------------------------- * Audit capture (client IP + user agent) * * These fields ride through on the Create payload and are never read by the * server-side download pipeline. LogPortalDownloadRequestDeletePlugin copies * them onto the jdi_downloadlog row when the trigger record is DELETEd. * * IP is fetched best-effort from an external echo. We cache per session * (including the empty string) so blocked or slow echoes are only retried * once per page load, and we never let the capture block the download more * than 2 seconds. * ------------------------------------------------------------------- */ var IP_ECHO_URL = "https://api.ipify.org?format=json"; var IP_ECHO_TIMEOUT_MS = 2000; var IP_MAX_LENGTH = 45; var USER_AGENT_MAX_LENGTH = 500; var _cachedClientIp = null; function fetchClientIp() { if (_cachedClientIp !== null) { return Promise.resolve(_cachedClientIp); } var controller = ("AbortController" in window) ? new AbortController() : null; var timer = null; if (controller) { timer = window.setTimeout(function () { controller.abort(); }, IP_ECHO_TIMEOUT_MS); } return fetch(IP_ECHO_URL, { signal: controller ? controller.signal : undefined, credentials: "omit", cache: "no-store" }) .then(function (response) { return response && response.ok ? response.json() : null; }) .then(function (json) { var ip = json && json.ip ? String(json.ip).trim() : ""; _cachedClientIp = ip ? ip.slice(0, IP_MAX_LENGTH) : ""; return _cachedClientIp; }) .catch(function () { // Aborted / offline / blocked host / JSON parse error - all best-effort misses. _cachedClientIp = ""; return _cachedClientIp; }) .then(function (value) { if (timer) { window.clearTimeout(timer); } return value; }); } function captureUserAgent() { try { var ua = navigator && navigator.userAgent ? String(navigator.userAgent) : ""; return ua.length > USER_AGENT_MAX_LENGTH ? ua.slice(0, USER_AGENT_MAX_LENGTH) : ua; } catch (_) { return ""; } } /* --------------------------------------------------------------------- * Caller identity resolution * ------------------------------------------------------------------- */ function resolveContactId() { if (window.jdiPortalUser && window.jdiPortalUser.contactId) { return String(window.jdiPortalUser.contactId).trim(); } return ""; } function buildItemUrl(requestId) { return REQUEST_ITEM_URL_TEMPLATE.replace("{id}", encodeURIComponent(requestId)); } function buildFileValueUrl(requestId) { return REQUEST_FILE_VALUE_URL_TEMPLATE.replace("{id}", encodeURIComponent(requestId)); } /* --------------------------------------------------------------------- * Error surface (inline alert + toast) * ------------------------------------------------------------------- */ function ensureToastRegion() { var region = qs("#" + TOAST_REGION_ID); if (region) { return region; } region = createEl("div", { id: TOAST_REGION_ID, "class": "jdi-toast-region", role: "region", "aria-live": "polite" }); document.body.appendChild(region); return region; } function pushToast(type, title, message) { var typeClass = "jdi-toast-" + (type || "info"); var region = ensureToastRegion(); var titleEl = createEl("div", { "class": "jdi-toast-title", text: title || "" }); var messageEl = message ? createEl("div", { "class": "jdi-toast-message", text: message }) : null; var body = createEl("div", { "class": "jdi-toast-body" }, [titleEl, messageEl]); var dismiss = createEl("button", { type: "button", "class": "jdi-toast-dismiss", "aria-label": "Dismiss" }); dismiss.innerHTML = "×"; var toast = createEl("div", { "class": "jdi-toast " + typeClass, role: type === "danger" ? "alert" : "status" }, [body, dismiss]); function remove() { if (toast.parentNode) { toast.parentNode.removeChild(toast); } } dismiss.addEventListener("click", remove); window.setTimeout(remove, TOAST_AUTO_DISMISS_MS); region.appendChild(toast); } function showInlineError(message) { var host = document.getElementById(INLINE_ERROR_HOST_ID); if (host) { host.textContent = message; host.hidden = false; } } function clearInlineError() { var host = document.getElementById(INLINE_ERROR_HOST_ID); if (host) { host.textContent = ""; host.hidden = true; } } function raiseError(message) { var text = message || "Download failed."; showInlineError(text); pushToast("danger", "Download failed", text); } function raiseSuccess(fileName) { pushToast("success", "Download started", fileName || ""); } /* --------------------------------------------------------------------- * Download triggers * ------------------------------------------------------------------- */ function downloadBlob(blob, fileName) { var url = window.URL.createObjectURL(blob); var anchor = createEl("a", { href: url, download: fileName || "download", style: "display:none;" }); document.body.appendChild(anchor); anchor.click(); window.setTimeout(function () { if (anchor.parentNode) { anchor.parentNode.removeChild(anchor); } window.URL.revokeObjectURL(url); }, 0); } /** * Triggers a direct download from an absolute URL (the Azure blob SAS). * The `download` attribute gives the file a suggested name; browsers honor * it cross-origin only when the server emits a compatible * `Content-Disposition` (or falls back to the anchor text). Azure blobs * uploaded with our pipeline set Content-Disposition so this works today. */ function downloadViaAnchor(absoluteUrl, fileName) { var anchor = createEl("a", { href: absoluteUrl, download: fileName || "download", style: "display:none;", rel: "noopener" }); document.body.appendChild(anchor); anchor.click(); window.setTimeout(function () { if (anchor.parentNode) { anchor.parentNode.removeChild(anchor); } }, 0); } /* --------------------------------------------------------------------- * Network helpers: CSRF token + portal fetch wrappers * ------------------------------------------------------------------- */ function fetchCsrfToken() { return fetch("/_layout/tokenhtml", { credentials: "same-origin" }) .then(function (response) { return response.text(); }) .then(function (html) { var doc = new DOMParser().parseFromString(html, "text/html"); var input = doc.querySelector('input[name="__RequestVerificationToken"]'); return input ? input.value : ""; }); } function readEntityIdFromHeader(response) { var header = response.headers.get("OData-EntityId") || response.headers.get("odata-entityid"); if (!header) { return ""; } // Shape: https://{host}/_api/jdi_downloadrequests() var match = /\(([^)]+)\)\s*$/.exec(header); return match ? match[1] : ""; } function extractErrorMessage(response, fallback) { return response.text().then(function (text) { if (!text) { return fallback || ("HTTP " + response.status); } try { var json = JSON.parse(text); if (json && json.error && json.error.message) { return json.error.message; } return text; } catch (e) { return text; } }); } /* --------------------------------------------------------------------- * Trigger-record operations (create / get / fetch file / delete) * ------------------------------------------------------------------- */ function createDownloadRequest(documentId, contactId) { // Fetch the CSRF token and the client IP in parallel so the audit capture // never serializes against the actual POST. captureUserAgent is synchronous. return Promise.all([fetchCsrfToken(), fetchClientIp()]).then(function (pair) { var token = pair[0]; var ipAddress = pair[1]; var userAgent = captureUserAgent(); var body = {}; // jdi_DocumentId is the single-valued navigation property name for the // document lookup on jdi_downloadrequest (PascalCase schema name, // verified via Dataverse REST Builder). Using the lowercase attribute // logical name instead returns 400 / 0x80048d19 from the Power Pages // Web API because the payload bind can't be resolved to a nav property. body["jdi_DocumentId@odata.bind"] = "/jdi_documents(" + documentId + ")"; body["jdi_contactid@odata.bind"] = "/contacts(" + contactId + ")"; // Audit fields. Server-side pipeline ignores them; the Delete plug-in // copies them into jdi_downloadlog when the trigger row is cleaned up. // Only include when populated so Dataverse never receives empty strings. if (ipAddress) { body["jdi_ipaddress"] = ipAddress; } if (userAgent) { body["jdi_useragent"] = userAgent; } return fetch(REQUEST_COLLECTION_URL, { method: "POST", credentials: "same-origin", headers: { "__RequestVerificationToken": token, "Content-Type": "application/json", "Accept": "application/json", "X-Requested-With": "XMLHttpRequest" }, body: JSON.stringify(body) }).then(function (response) { if (!response.ok) { return extractErrorMessage(response, "Unable to create download request.") .then(function (message) { var err = new Error(message); err.status = response.status; throw err; }); } var requestId = readEntityIdFromHeader(response); if (!requestId) { throw new Error("Download request created but its id could not be determined."); } return requestId; }); }); } function getDownloadRequest(requestId) { var url = buildItemUrl(requestId) + "?$select=" + REQUEST_SELECT_COLUMNS; return fetch(url, { method: "GET", credentials: "same-origin", headers: { "Accept": "application/json", "X-Requested-With": "XMLHttpRequest" } }).then(function (response) { if (!response.ok) { return extractErrorMessage(response, "Unable to read download request.") .then(function (message) { var err = new Error(message); err.status = response.status; throw err; }); } return response.json(); }); } function fetchFileColumn(requestId) { return fetch(buildFileValueUrl(requestId), { method: "GET", credentials: "same-origin", headers: { "X-Requested-With": "XMLHttpRequest" } }).then(function (response) { if (!response.ok) { return extractErrorMessage(response, "Unable to download file bytes.") .then(function (message) { var err = new Error(message); err.status = response.status; throw err; }); } return response.blob(); }); } function deleteDownloadRequest(requestId) { if (!requestId) { return Promise.resolve(); } return fetchCsrfToken().then(function (token) { return fetch(buildItemUrl(requestId), { method: "DELETE", credentials: "same-origin", headers: { "__RequestVerificationToken": token, "X-Requested-With": "XMLHttpRequest" } }); // We intentionally ignore the result: DELETE is best-effort cleanup. }).catch(function () { /* swallow */ }); } /* --------------------------------------------------------------------- * Polling loop * ------------------------------------------------------------------- */ function pollDownloadRequest(requestId) { var deadline = Date.now() + POLL_MAX_ELAPSED_MS; var nextDelay = POLL_INITIAL_DELAY_MS; function attempt() { return getDownloadRequest(requestId).then(function (row) { var status = row && row.jdi_status; if (status === STATUS_SUCCEEDED) { return row; } if (status === STATUS_FAILED) { var err = new Error(row.jdi_errormessage || "Download failed."); err.isDownloadFailure = true; throw err; } if (Date.now() > deadline) { throw new Error("Download request timed out. Please try again."); } return delay(nextDelay).then(function () { nextDelay = Math.min(Math.floor(nextDelay * 1.5), POLL_MAX_DELAY_MS); return attempt(); }); }); } return attempt(); } /* --------------------------------------------------------------------- * Button busy-state management * ------------------------------------------------------------------- */ function setBusy(button, busy) { if (!button) { return; } button.disabled = busy; button.setAttribute("aria-busy", busy ? "true" : "false"); if (busy) { button.classList.add("is-loading"); } else { button.classList.remove("is-loading"); } } /* --------------------------------------------------------------------- * Main click handler * ------------------------------------------------------------------- */ function handleDownloadClick(event) { var button = closest(event.target, DOWNLOAD_BUTTON_SELECTOR); if (!button) { return; } event.preventDefault(); clearInlineError(); var documentId = button.getAttribute("data-document-id"); var fileNameHint = button.getAttribute("data-file-name"); var contactId = resolveContactId(); if (!documentId) { raiseError("Document id is missing."); return; } if (!contactId) { raiseError("You must be signed in to download documents."); return; } setBusy(button, true); var createdRequestId = null; createDownloadRequest(documentId, contactId) .then(function (requestId) { createdRequestId = requestId; return pollDownloadRequest(requestId); }) .then(function (row) { var effectiveFileName = row.jdi_filename || fileNameHint || "download"; if (row.jdi_deliverymode === DELIVERY_MODE_SASURL) { if (!row.jdi_sasurl) { throw new Error("Download URL is missing."); } downloadViaAnchor(row.jdi_sasurl, effectiveFileName); raiseSuccess(effectiveFileName); return; } // Default to File delivery (covers DELIVERY_MODE_FILE and any unexpected/absent // mode values, which should not occur once the plug-in is deployed). return fetchFileColumn(createdRequestId).then(function (blob) { if (!blob || (typeof blob.size === "number" && blob.size === 0)) { throw new Error("The download response did not include file content."); } downloadBlob(blob, effectiveFileName); raiseSuccess(effectiveFileName); }); }) .catch(function (err) { var message = (err && err.message) || "Download request failed."; if (err && err.status === 403) { message = "You do not have permission to download this document."; } else if (err && err.status === 404) { message = "Download endpoint not found. Verify Webapi/jdi_downloadrequest/enabled is configured."; } raiseError(message); }) .then(function () { // Clean up the staging row regardless of outcome; keeps the file column out of // long-term storage and avoids growing the table indefinitely. The file fetch // above is fully resolved (blob materialized in memory) before this runs, so // deleting the row does not race the SharePoint-path download. if (createdRequestId) { deleteDownloadRequest(createdRequestId); } setBusy(button, false); }); } /* --------------------------------------------------------------------- * Bootstrap * ------------------------------------------------------------------- */ function bootstrap() { if (window[PORTAL_DOWNLOAD_NAMESPACE + ".bound"]) { return; } window[PORTAL_DOWNLOAD_NAMESPACE + ".bound"] = true; document.addEventListener("click", handleDownloadClick); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", bootstrap); } else { bootstrap(); } /* Expose a handful of helpers for diagnostics or bespoke pages. */ window.jdiPortalDownload = { trigger: function (documentId, fileName) { var fakeButton = createEl("button", { "class": "jdi-download-document jdi-btn", "data-document-id": documentId, "data-file-name": fileName || "" }); document.body.appendChild(fakeButton); fakeButton.click(); document.body.removeChild(fakeButton); }, toast: pushToast, version: "4.2.0-download-log" }; }());