Look at a file without downloading it first
Files could only hand you the bytes: double-clicking a picture put it on disk and left you to find it. The viewer for this already existed -- images, PDFs and text, in the attachment preview in MessageView -- it was just wired to one screen. It is now a component both screens use. Two things it needed before it was any use on Files. The type detection falls back to the file name: an upload carries whatever the browser guessed, which for anything unusual is application/octet-stream, so the old exact-type check saw nothing to show in a .md that had just been uploaded. And text is read with fetch, which ignores Content-Disposition, so Markdown previews even though the server will not serve it inline. Whether we can show a file and whether the server will serve it inline are separate questions, and lib/preview.ts answers them separately: `openableInTab` mirrors the isInlineSafe allowlist in the blob route, because navigating to a blob the server will not inline just starts a download. SVG is left out of both -- it carries script, and how to show one safely is its own question, not a detail of a file lister. Printing goes with it. A picture or a text file prints from the dialog with everything else dropped; a PDF prints itself from its own iframe, since the page around it cannot paginate someone else's document. Hiding `.app` alone was not enough there -- `#root` kept its height and printed a blank first page, the same trap as the message card.
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { openableInTab, previewKind } from "@/lib/preview";
|
||||
|
||||
describe("previewKind", () => {
|
||||
it("goes by the declared type when there is one", () => {
|
||||
expect(previewKind("image/png", "photo.png")).toBe("image");
|
||||
expect(previewKind("application/pdf", "invoice.pdf")).toBe("pdf");
|
||||
expect(previewKind("text/plain", "notes.txt")).toBe("text");
|
||||
expect(previewKind("text/markdown", "README.md")).toBe("text");
|
||||
expect(previewKind("application/json", "data.json")).toBe("text");
|
||||
expect(previewKind("image/png; charset=binary", "photo.png")).toBe("image");
|
||||
});
|
||||
|
||||
it("falls back to the name when the type is a generic wrapper", () => {
|
||||
// What an upload gets when the browser cannot guess -- files.ts stores
|
||||
// `f.type || "application/octet-stream"`, so this is the common case for
|
||||
// anything unusual, and it is what made the old exact-type check useless
|
||||
// on real uploads.
|
||||
expect(previewKind("application/octet-stream", "README.md")).toBe("text");
|
||||
expect(previewKind("application/octet-stream", "shot.PNG")).toBe("image");
|
||||
expect(previewKind("application/octet-stream", "report.pdf")).toBe("pdf");
|
||||
expect(previewKind("", "notes.txt")).toBe("text");
|
||||
expect(previewKind(null, "deploy.sh")).toBe("text");
|
||||
expect(previewKind(undefined, undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not let the name override a type the server was specific about", () => {
|
||||
// A .txt served as a zip is a zip. Guessing from the name here would be
|
||||
// taking the sender's word for the extension over the server's for the
|
||||
// bytes.
|
||||
expect(previewKind("application/zip", "archive.txt")).toBeNull();
|
||||
expect(previewKind("video/mp4", "clip.txt")).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves SVG alone", () => {
|
||||
// It carries script and the server refuses to serve it inline; it stays a
|
||||
// download until that is decided deliberately.
|
||||
expect(previewKind("image/svg+xml", "logo.svg")).toBeNull();
|
||||
expect(previewKind("application/octet-stream", "logo.svg")).toBeNull();
|
||||
});
|
||||
|
||||
it("has nothing to show for the rest", () => {
|
||||
expect(previewKind("application/zip", "backup.zip")).toBeNull();
|
||||
expect(previewKind("application/vnd.openxmlformats-officedocument.wordprocessingml.document", "letter.docx")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("openableInTab", () => {
|
||||
/*
|
||||
* This mirrors `isInlineSafe` in server/src/app.ts. If the two drift, the
|
||||
* "open in a new tab" button silently starts downloading instead, because
|
||||
* the server sends Content-Disposition: attachment for anything not on its
|
||||
* list. These cases are the list.
|
||||
*/
|
||||
it("matches what the server will serve inline", () => {
|
||||
expect(openableInTab("image/png")).toBe(true);
|
||||
expect(openableInTab("video/mp4")).toBe(true);
|
||||
expect(openableInTab("audio/mpeg")).toBe(true);
|
||||
expect(openableInTab("application/pdf")).toBe(true);
|
||||
expect(openableInTab("text/plain; charset=utf-8")).toBe(true);
|
||||
expect(openableInTab("text/calendar")).toBe(true);
|
||||
expect(openableInTab("text/vcard")).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses what the server will not", () => {
|
||||
expect(openableInTab("image/svg+xml")).toBe(false);
|
||||
expect(openableInTab("text/html")).toBe(false);
|
||||
expect(openableInTab("text/markdown")).toBe(false);
|
||||
expect(openableInTab("application/json")).toBe(false);
|
||||
expect(openableInTab("application/octet-stream")).toBe(false);
|
||||
expect(openableInTab(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("is narrower than what we can show ourselves", () => {
|
||||
// Markdown is the case that proves the two questions are different: the
|
||||
// dialog reads it with fetch, which ignores Content-Disposition.
|
||||
expect(previewKind("text/markdown", "README.md")).toBe("text");
|
||||
expect(openableInTab("text/markdown")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* What, if anything, we can show of a file without downloading it.
|
||||
*
|
||||
* Two questions, deliberately kept apart:
|
||||
*
|
||||
* - `previewKind` — can the app render it in a dialog? Text is answered with
|
||||
* `fetch`, which ignores Content-Disposition, so this is free to say yes to
|
||||
* anything text-shaped.
|
||||
* - `openableInTab` — will the *server* hand it back inline? That mirrors
|
||||
* `isInlineSafe` in `server/src/app.ts`, which is the security boundary:
|
||||
* everything else is served as an attachment with a sandbox CSP. Navigating
|
||||
* to a blob the server will not inline just starts a download, so the
|
||||
* "open in a new tab" affordance has to ask this and not the other one.
|
||||
*
|
||||
* Keep the two in step by hand. They answer different questions and neither
|
||||
* can be derived from the other.
|
||||
*/
|
||||
|
||||
export type PreviewKind = "image" | "pdf" | "text";
|
||||
|
||||
/**
|
||||
* Uploads arrive with whatever type the browser guessed, which for anything
|
||||
* unusual is one of these -- `files.ts` stores `f.type || "application/octet-stream"`.
|
||||
* A generic type is not evidence about the file, so fall through to the name.
|
||||
*/
|
||||
const GENERIC = new Set(["", "application/octet-stream", "binary/octet-stream", "application/unknown", "unknown/unknown"]);
|
||||
|
||||
const BY_EXTENSION: Array<[RegExp, PreviewKind]> = [
|
||||
[/\.(png|jpe?g|gif|webp|avif|bmp|ico|heic|heif)$/i, "image"],
|
||||
[/\.pdf$/i, "pdf"],
|
||||
[/\.(txt|text|md|markdown|log|csv|tsv|json|ya?ml|toml|ini|cfg|conf|env|sh|bash|zsh|fish|ps1|bat|js|mjs|cjs|jsx|ts|tsx|css|scss|less|html?|xhtml|xml|sql|py|rb|rs|go|c|h|cc|cpp|hpp|java|kt|swift|php|pl|lua|r|diff|patch|gitignore|dockerfile|makefile)$/i, "text"],
|
||||
];
|
||||
|
||||
function textish(type: string): boolean {
|
||||
return (
|
||||
type.startsWith("text/") ||
|
||||
type.endsWith("+json") ||
|
||||
type.endsWith("+xml") ||
|
||||
/^application\/(json|xml|javascript|ecmascript|sql|toml|x-yaml|yaml|x-sh|x-shellscript|x-httpd-php)$/.test(type)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SVG is excluded on purpose, and stays excluded. It is a script carrier, the
|
||||
* server refuses to serve it inline, and deciding how to show one safely is a
|
||||
* question of its own rather than something to settle inside a file lister.
|
||||
* An SVG falls through to a download, which is what it did before.
|
||||
*/
|
||||
export function previewKind(type: string | null | undefined, name: string | null | undefined): PreviewKind | null {
|
||||
const t = (type ?? "").split(";")[0]!.trim().toLowerCase();
|
||||
if (t && !GENERIC.has(t)) {
|
||||
if (t === "image/svg+xml") return null;
|
||||
if (t.startsWith("image/")) return "image";
|
||||
if (t === "application/pdf") return "pdf";
|
||||
if (textish(t)) return "text";
|
||||
// The server was specific and it is not something we show. Guessing from
|
||||
// the extension here would override a type the sender actually declared.
|
||||
return null;
|
||||
}
|
||||
const n = name ?? "";
|
||||
for (const [re, kind] of BY_EXTENSION) if (re.test(n)) return kind;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Mirrors `isInlineSafe` in `server/src/app.ts`; see the note at the top. */
|
||||
export function openableInTab(type: string | null | undefined): boolean {
|
||||
const t = (type ?? "").split(";")[0]!.trim().toLowerCase();
|
||||
return (
|
||||
(t.startsWith("image/") && t !== "image/svg+xml") ||
|
||||
t.startsWith("video/") ||
|
||||
t.startsWith("audio/") ||
|
||||
t === "application/pdf" ||
|
||||
t === "text/plain" ||
|
||||
t === "text/calendar" ||
|
||||
t === "text/vcard"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Past this, a text file is not read in a dialog -- it is downloaded and opened
|
||||
* in something built for it. The number is about the browser, not the network:
|
||||
* laying out a few million characters in one `<pre>` locks the tab up.
|
||||
*/
|
||||
export const TEXT_PREVIEW_MAX = 2 * 1024 * 1024;
|
||||
|
||||
/** A second guard for when the size was not known ahead of the fetch. */
|
||||
export const TEXT_PREVIEW_CHARS = 400_000;
|
||||
Reference in New Issue
Block a user