Ctrl K
ComunidadGuías…

src/lib/download.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import {
  downloadTicketsAPI,
  ticketedDownloadUrl,
} from "@/lib/api/download-tickets";
import { navigateToDownload } from "@/lib/navigation";

// How long an object URL is kept alive after the download has been triggered.
//
// Revoking synchronously after `link.click()` - which both download call sites used
// to do - races the browser. Chrome has taken its own reference by the time `click()`
// returns, so it looks fine there; Firefox and Safari start reading the blob
// asynchronously and simply cancel the download when the URL disappears from under
// them. The failure is silent: no error, no file.
//
// A minute is far longer than any download needs to *start* (which is all that
// matters - once the read has begun, revoking is harmless), and these blobs are
// bounded by the API's own 10 MB upload limit, so holding one briefly costs little.
//
// The export archive is the one blob that is *not* bounded by an upload limit - it is
// as big as the account is. It still only needs the URL to survive the start of the
// read, so the delay is unchanged; what it costs is the blob staying resident for a
// minute after saving. See DECISIONS.md on the export card for the memory trade.
const REVOKE_DELAY_MS = 60_000;

// Whether `userAgent` is Firefox for iOS, the one browser that cannot name a blob
// download. Its injected download script runs in an isolated content world, so the
// page cannot detect the behaviour itself - only the browser's own token in the UA.
// In desktop mode, an iPad's default, Firefox sends desktop Safari's UA verbatim, so
// there it cannot be told apart and stays on the blob path.
export function isFirefoxIOS(userAgent: string): boolean {
  return userAgent.includes("FxiOS/");
}

// Saves the file an API download route serves - an export, a dive's source file, a
// certification card's file.
//
// Every one of those routes is owner-only: it wants an `Authorization` header, and a
// plain `<a href>` cannot send one (the access token lives in memory, not in a
// cookie). So `fetchFile` fetches the bytes through the API client and `downloadBlob`
// hands them to the browser.
//
// Except in Firefox for iOS, which swallows every click on an `a[download]`, re-reads
// the URL itself and names the file from that response's `Content-Disposition` or the
// URL's last segment - never from the `download` attribute. A blob response carries no
// `Content-Disposition`, so a blob save comes out named after the object URL's uuid.
// There it mints a ticket for `path` instead and navigates to the route itself, whose
// `Content-Disposition` names the file. The ticket rides in the URL because Firefox
// fetches that URL a second time with none of the page's headers. A failed download
// therefore shows the API's error in place of the app (see DECISIONS.md on the export
// buffer); a hidden iframe would keep it off screen, at the price of widening
// `frame-src` and of turning a visible failure into silence.
//
// `path` is the route under the API base with no query string; it must be the route
// `fetchFile` reads.
//
// Resolves to how long, in milliseconds, the download may still be waiting on its
// response - a window in which leaving the page or ending the session loses it. The
// blob path has its bytes in hand, so 0. The navigation's response is not observable
// from the page and can take as long as the server needs to build an archive, so it is
// bounded only by the ticket's lifetime, after which the download fails regardless.
export async function saveApiDownload(
  path: string,
  fetchFile: () => Promise<{ blob: Blob; filename: string }>,
): Promise<number> {
  if (isFirefoxIOS(navigator.userAgent)) {
    const { ticket, expires_in } = await downloadTicketsAPI.create(path);
    navigateToDownload(ticketedDownloadUrl(path, ticket));
    return expires_in * 1000;
  }

  const { blob, filename } = await fetchFile();
  downloadBlob(blob, filename);
  return 0;
}

// Saves an in-memory blob to the user's downloads, via an object URL and a synthetic
// click. Reached through `saveApiDownload`, which says why the bytes are in memory.
export function downloadBlob(blob: Blob, filename: string): void {
  const url = URL.createObjectURL(blob);
  const link = document.createElement("a");
  link.href = url;
  link.download = filename;
  // Firefox only dispatches the download for an anchor that is actually in the
  // document; a detached one is silently ignored.
  link.style.display = "none";
  document.body.appendChild(link);
  link.click();
  link.remove();

  setTimeout(() => URL.revokeObjectURL(url), REVOKE_DELAY_MS);
}

// Pulls the filename out of a `Content-Disposition` header, or returns null when the
// header is missing, unparseable, or names nothing usable.
//
// **On a split-origin deployment this returns null far more often than the header being
// present suggests.** `Content-Disposition` is not one of the seven CORS-safelisted
// response headers, so unless the server sends `Access-Control-Expose-Headers` the
// browser hands JS `null` for a header that is plainly there in the network tab - and
// local dev is exactly that shape (`localhost:3000` -> `localhost:8000`). A deployed
// instance is same-origin behind `app/api/v1/[...path]/route.ts`, where CORS never
// applies and the header arrives intact. Every caller still needs its own fallback name:
// parsing is the improvement, not the mechanism.
//
// Both RFC 6266 forms are handled. A **UTF-8** `filename*=UTF-8''...` wins where
// present, because that is the one that can carry non-ASCII, and a server sending both
// sends the plain `filename=` as the lossy fallback for clients that cannot read the
// other.
//
// The charset is checked rather than assumed. `decodeURIComponent` only speaks UTF-8, so
// an `ISO-8859-1''caf%E9.zip` is not something this can decode - `%E9` is not valid
// UTF-8 and the call throws. Falling through to the plain `filename=` is the right
// answer either way, but it should be the answer this function *chose* rather than one
// an exception happened to produce.
export function filenameFromContentDisposition(
  header: string | null | undefined,
): string | null {
  if (!header) return null;

  // Groups: charset, language (RFC 8187 allows it to be empty, and nothing here
  // needs it), then the percent-encoded name.
  const extended = /filename\*\s*=\s*([^']*)'[^']*'([^;]+)/i.exec(header);
  if (extended && /^(utf-8|us-ascii)?$/i.test(extended[1].trim())) {
    try {
      const safe = basename(decodeURIComponent(extended[2].trim()));
      if (safe) return safe;
    } catch {
      // A malformed percent-escape. Fall through to the plain `filename=` below
      // rather than throwing: a wrong-but-present name beats failing the download.
    }
  }

  const plain = /filename\s*=\s*("([^"]*)"|[^;]+)/i.exec(header);
  if (plain) {
    const safe = basename((plain[2] ?? plain[1]).trim());
    if (safe) return safe;
  }

  return null;
}

// Strips any directory part a header might carry. `link.download` already refuses to
// write outside the downloads directory, so this is not the last line of defence - but
// a server-supplied string ends up in a filesystem path, and stripping separators here
// costs a line.
function basename(value: string): string {
  return value.split(/[/\\]/).pop()?.trim() ?? "";
}