import { createContext, use, useCallback, useEffect, useState } from "react"; import { useToast } from "@/hooks/use-toast"; import { downloadBlob } from "@/lib/download-blob"; /** * Authenticated fetch — same shape as the standard `fetch`, but carries * the Bearer token and DPoP proof required by the Tailor platform. * Provided by App.tsx from `authClient.fetch` so page modules don't * import the auth-client singleton themselves: adding a second importer * of `@/lib/auth-client` shifts the Vite pre-bundling order of * `@tailor-platform/app-shell` enough to trigger a var-hoist race * inside that chunk where `createCallbackStatusManager` is referenced * before its initializer runs (the `xi is not a function` symptom). */ export type AuthFetch = typeof fetch; export const AuthFetchContext = createContext(null); export const useAuthFetch = (): AuthFetch => { const authFetch = use(AuthFetchContext); if (!authFetch) { throw new Error("useAuthFetch must be used inside "); } return authFetch; }; /** * Parse the filename from a `Content-Disposition` header, preferring * the RFC 5987 extended form (`filename*=UTF-8''invoice%20Jan.pdf`) * over the basic form (`filename="invoice Jan.pdf"`) per RFC 6266. * Returns null if no filename is parseable so the caller can fall back * to a sensible default. */ export function parseContentDispositionFilename(cd: string): string | null { // Extended form: filename*=''. // The language segment is optional content but the two single quotes // are required. RFC 5987 mandates support for UTF-8 and ISO-8859-1; // we only decode UTF-8 (the dominant convention and what // decodeURIComponent assumes) and fall back to the basic form for // anything exotic. const extMatch = /filename\*\s*=\s*([\w-]+)'[\w-]*'([^;,\s]+)/i.exec(cd); if (extMatch?.[1].toUpperCase() === "UTF-8") { try { return decodeURIComponent(extMatch[2]); } catch { // Malformed percent-encoding — fall through to the basic form. } } // Basic form: filename="quoted-value" or filename=token const basicMatch = /filename\s*=\s*(?:"([^"]*)"|([^;,\s]+))/i.exec(cd); return basicMatch?.[1] ?? basicMatch?.[2] ?? null; } /** * Reduce a filename (potentially user-influenced — Content-Disposition * is set by the upload client) to a safe value for the `` * attribute: strip any path components and reject empty / `.` / `..` * results. Returns null when nothing usable remains so the caller can * fall through to a default. */ export function sanitizeFilename(name: string | null): string | null { if (!name) return null; // POSIX `/` and Windows `\` — keep only the last path segment. const lastSegment = name.split(/[\\/]/).pop() ?? ""; const trimmed = lastSegment.trim(); if (trimmed === "" || trimmed === "." || trimmed === "..") return null; return trimmed; } /** * Three-state result for `useAuthenticatedBlobUrl`. Consumers can * distinguish "still fetching" from "fetch failed" — important for the * viewer route that previously rendered an indefinite loading spinner * when the auth fetch failed (e.g. 401/403, network error). */ export type AuthenticatedBlobUrlState = | { status: "loading"; blobUrl: null } | { status: "ready"; blobUrl: string } | { status: "error"; blobUrl: null }; const LOADING_STATE: AuthenticatedBlobUrlState = { status: "loading", blobUrl: null, }; const ERROR_STATE: AuthenticatedBlobUrlState = { status: "error", blobUrl: null, }; /** * Hook that resolves a protected URL (e.g. `/files/tailordb/...`) into * a `blob:` URL the browser can navigate to without an Authorization * header. Used by the SPA file viewer route to render an iframe * against an auth-protected file endpoint. * * Trade-off vs. a lazy on-click flow: this hook fetches eagerly on * mount, so a file gets downloaded on every page visit even if the * user closes the tab quickly. Acceptable for invoice PDFs (typically * sub-MB) and for a route whose entire purpose is viewing the file. * * Returns a discriminated state so consumers can render distinct UI * for the three outcomes: * - `loading`: no input yet, or fetch in flight * - `ready`: blob URL is resolved and safe to navigate * - `error`: non-OK response, network failure, or thrown exception * * The blob URL is revoked when the input URL changes or the component * unmounts; the cache is keyed by source URL so stale blobs aren't * surfaced after navigation. */ export const useAuthenticatedBlobUrl = (url: string | null): AuthenticatedBlobUrlState => { const authFetch = useAuthFetch(); const [cache, setCache] = useState<{ source: string; state: AuthenticatedBlobUrlState; } | null>(null); useEffect(() => { if (!url) return; const controller = new AbortController(); let createdUrl: string | null = null; void (async () => { try { const res = await authFetch(url, { signal: controller.signal }); if (controller.signal.aborted) return; if (!res.ok) { setCache({ source: url, state: ERROR_STATE }); return; } const blob = await res.blob(); if (controller.signal.aborted) return; createdUrl = URL.createObjectURL(blob); setCache({ source: url, state: { status: "ready", blobUrl: createdUrl }, }); } catch (err) { if (controller.signal.aborted) return; console.error("Failed to load authenticated file:", err); setCache({ source: url, state: ERROR_STATE }); } })(); return () => { controller.abort(); if (createdUrl) URL.revokeObjectURL(createdUrl); }; }, [url, authFetch]); // Only surface the cached state when its source matches the current // input — avoids exposing a stale (revoked) URL after the input // changes and lets us derive the default `loading` without a // synchronous setState in the effect. return url && cache?.source === url ? cache.state : LOADING_STATE; }; /** * Hook that returns a callback to download a protected file via the * auth-fetch context, plus an `isDownloading` flag suitable for wiring * into an `ActionPanel` row's `loading` prop. * * The download flow: * - Authenticated fetch against the supplied URL. * - Wrap the response bytes in a Blob and trigger a synthetic * `` click. * - Resolve the saved filename from the `Content-Disposition` header * (with RFC 5987 extended-form decoding), falling back to * `fallbackFilename`. * - Surface failures through toast; log a developer message via * `console.error` when an exception is caught. */ export const useDownloadAuthenticatedFile = () => { const authFetch = useAuthFetch(); const toast = useToast(); const [isDownloading, setIsDownloading] = useState(false); const download = useCallback( async (url: string, fallbackFilename = "download"): Promise => { setIsDownloading(true); try { const res = await authFetch(url); if (!res.ok) { toast.error("File is not available for download."); return; } const blob = await res.blob(); const contentDisposition = res.headers.get("content-disposition") ?? ""; // Sanitize both candidates — the parsed value is user-influenced // (set by whoever uploaded the file), and the fallback may also // contain path-like characters if it came from a record field. const filename = sanitizeFilename(parseContentDispositionFilename(contentDisposition)) ?? sanitizeFilename(fallbackFilename) ?? "download"; downloadBlob(blob, filename); } catch (err) { console.error("Failed to download file:", err); toast.error("Failed to download file. Please try again."); } finally { setIsDownloading(false); } }, [authFetch, toast], ); return { download, isDownloading }; };