Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/file-page-text-preview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@buildinternet/uploads": patch
---

Public file page shows a proper file card (name, type, size, download link) instead of a generic "Preview unavailable" fallback for non-media files, and inline-previews small text/markdown/csv/json files as server-rendered plain text.
81 changes: 78 additions & 3 deletions apps/web/src/components/MediaStage.astro
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import ImagePreview from "./ImagePreview.astro";
import MediaFallback from "./MediaFallback.astro";
import type { BeforeAfterState } from "../lib/before-after-view";
import { mediaPreviewFailedBody, MEDIA_PREVIEW_FAILED_TITLE } from "../lib/media-load";
import { fileTypeLabel, formatBytes } from "../lib/public-file";
import type { MediaKind } from "../lib/public-gallery";

interface Props {
Expand All @@ -33,6 +34,18 @@ interface Props {
compare?: { src: string; ownState: BeforeAfterState } | null;
/** id for the rail figcaption, referenced by the video's aria-describedby. */
railId?: string;
/** Object content type, for the file-card TYPE label and text-preview gating; omitted when unknown. */
contentType?: string | null;
/** Object byte size, for the file-card size label; omitted when unknown. */
size?: number | null;
/** Forced-download URL for the file card's link; falls back to `url` when omitted. */
downloadUrl?: string | null;
/**
* Pre-fetched, UTF-8-decoded text body for small text/markdown/csv/json
* files (issue #946) — SSR-only, fetched by the page from `url` before
* render. Null/omitted falls back to the plain file card.
*/
textPreview?: string | null;
}

const {
Expand All @@ -44,7 +57,16 @@ const {
videoDimensions = null,
compare = null,
railId,
contentType = null,
size = null,
downloadUrl = null,
textPreview = null,
} = Astro.props;

const fileHref = downloadUrl ?? url;
const fileCardBody = `${fileTypeLabel(filename, contentType ?? "")} · ${
size != null ? formatBytes(size) : "—"
}`;
---

<figure class="stage" style="margin:0" data-media-stage>
Expand Down Expand Up @@ -86,9 +108,28 @@ const {
/>
</div>
)}
{kind === "file" && url && (
<MediaFallback title="Preview unavailable" href={url} filename={filename} />
)}
{kind === "file" &&
url &&
(textPreview != null ? (
<div class="text-preview-wrap">
<pre class="text-preview">{textPreview}</pre>
<div class="text-preview-info">
<span>{fileCardBody}</span>
{fileHref && (
<a href={fileHref} rel="noopener noreferrer">
Download
</a>
)}
</div>
</div>
) : (
<MediaFallback
title={filename}
body={fileCardBody}
href={fileHref}
filename={filename}
/>
))}
{kind === "missing" && (
<MediaFallback
title="Removed or expired"
Expand Down Expand Up @@ -119,6 +160,40 @@ const {
place-items: center;
overflow: hidden;
}
/* Inline text preview (issue #946) — server-rendered, no client JS. */
.text-preview-wrap {
width: 100%;
max-width: 100%;
align-self: stretch;
justify-self: stretch;
padding: 16px;
box-sizing: border-box;
}
.text-preview {
margin: 0;
max-height: 70vh;
overflow: auto;
white-space: pre;
font: var(--text-meta) var(--mono);
line-height: 1.5;
color: var(--body);
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius-md);
padding: 14px 16px;
}
.text-preview-info {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
color: var(--muted);
font: var(--text-micro) var(--sans);
}
.text-preview-info a {
color: var(--fg);
}
.media video {
max-width: 100%;
max-height: 78vh;
Expand Down
85 changes: 85 additions & 0 deletions apps/web/src/lib/public-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,21 @@ import {
applyPublicFileHeaders,
authRequiredFileCsp,
fetchPublicFile,
fetchTextPreview,
fileDownloadUrl,
fileKind,
filePath,
fileTypeLabel,
formatBytes,
formatFileDate,
isPublicFile,
isSafeKey,
isTextPreviewable,
PUBLIC_FILE_CSP,
publicFileCsp,
sameUtcDay,
shouldShowModified,
TEXT_PREVIEW_MAX_BYTES,
} from "./public-file";

const file = {
Expand Down Expand Up @@ -256,6 +260,87 @@ describe("formatBytes", () => {
});
});

describe("fileTypeLabel", () => {
it("uses the uppercase filename extension when present", () => {
expect(fileTypeLabel("notes.txt", "text/plain")).toBe("TXT");
expect(fileTypeLabel("data.CSV", "text/csv")).toBe("CSV");
expect(fileTypeLabel("nested/path/report.json", "application/json")).toBe("JSON");
});

it("falls back to the content-type subtype when there is no extension", () => {
expect(fileTypeLabel("README", "text/markdown")).toBe("MARKDOWN");
expect(fileTypeLabel("archive", "application/zip")).toBe("ZIP");
});

it("does not treat a leading dot (dotfile) as an extension", () => {
expect(fileTypeLabel(".gitignore", "text/plain")).toBe("PLAIN");
});
});

describe("isTextPreviewable", () => {
it("accepts the four allowlisted content types under the size cap", () => {
expect(isTextPreviewable("text/plain", 100)).toBe(true);
expect(isTextPreviewable("text/markdown", TEXT_PREVIEW_MAX_BYTES)).toBe(true);
expect(isTextPreviewable("text/csv", 0)).toBe(true);
expect(isTextPreviewable("application/json", TEXT_PREVIEW_MAX_BYTES - 1)).toBe(true);
});

it("rejects other content types and oversized files", () => {
expect(isTextPreviewable("text/html", 100)).toBe(false);
expect(isTextPreviewable("image/svg+xml", 100)).toBe(false);
expect(isTextPreviewable("application/xml", 100)).toBe(false);
expect(isTextPreviewable("text/plain", TEXT_PREVIEW_MAX_BYTES + 1)).toBe(false);
});
});

describe("fetchTextPreview", () => {
it("decodes a small UTF-8 body from a successful response", async () => {
const fetcher = vi.fn(async () => new Response("hello world"));
const result = await fetchTextPreview("https://storage.uploads.sh/acme/notes.txt", {
fetch: fetcher,
});
expect(result).toBe("hello world");
});

it("returns null on a non-2xx response", async () => {
const fetcher = vi.fn(async () => new Response("nope", { status: 500 }));
const result = await fetchTextPreview("https://storage.uploads.sh/acme/notes.txt", {
fetch: fetcher,
});
expect(result).toBeNull();
});

it("returns null when content-length exceeds the cap", async () => {
const fetcher = vi.fn(
async () =>
new Response("x", { headers: { "content-length": String(TEXT_PREVIEW_MAX_BYTES + 1) } }),
);
const result = await fetchTextPreview("https://storage.uploads.sh/acme/notes.txt", {
fetch: fetcher,
});
expect(result).toBeNull();
});

it("returns null when the actual body exceeds maxBytes despite no content-length", async () => {
const fetcher = vi.fn(async () => new Response("x".repeat(20)));
const result = await fetchTextPreview("https://storage.uploads.sh/acme/notes.txt", {
fetch: fetcher,
maxBytes: 10,
});
expect(result).toBeNull();
});

it("returns null when the fetch throws or aborts", async () => {
const fetcher = vi.fn(async () => {
throw new Error("network down");
});
const result = await fetchTextPreview("https://storage.uploads.sh/acme/notes.txt", {
fetch: fetcher,
});
expect(result).toBeNull();
});
});

describe("fetchPublicFile", () => {
it("returns ok for a valid DTO and calls the single-object endpoint", async () => {
const fetcher = vi.fn(async (_input: RequestInfo | URL) => Response.json(file));
Expand Down
62 changes: 62 additions & 0 deletions apps/web/src/lib/public-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,68 @@ export async function fetchPublicFile(
}
}

/** Derive the file-card's `<TYPE>` label: uppercase filename extension, falling back to the content-type subtype. */
export function fileTypeLabel(filename: string, contentType: string): string {
const base = filename.split("/").pop() ?? filename;
const dot = base.lastIndexOf(".");
const ext = dot > 0 && dot < base.length - 1 ? base.slice(dot + 1) : "";
if (ext) return ext.toUpperCase();
const subtype = (contentType.split("/").pop() ?? contentType).split(";")[0]?.trim() ?? "";
return subtype.toUpperCase();
}

/** Content types eligible for the inline server-rendered text preview (issue #946). Markdown renders as source, never rendered HTML. */
export const TEXT_PREVIEW_CONTENT_TYPES: ReadonlySet<string> = new Set([
"text/plain",
"text/markdown",
"text/csv",
"application/json",
]);

/** Cap on the object's reported size for the inline text preview — 256 KiB. */
export const TEXT_PREVIEW_MAX_BYTES = 256 * 1024;

/** Whether a file qualifies for the inline text preview based on its reported content type and size. */
export function isTextPreviewable(contentType: string, size: number): boolean {
return (
TEXT_PREVIEW_CONTENT_TYPES.has(contentType) &&
Number.isFinite(size) &&
size >= 0 &&
size <= TEXT_PREVIEW_MAX_BYTES
);
}

/**
* Fetch a text-previewable object's body server-side (storage host, no CORS
* concern) and decode it as UTF-8. Returns null on any failure, timeout,
* non-2xx response, or a body over `maxBytes` — callers fall back to the
* plain file card in that case.
*/
export async function fetchTextPreview(
url: string,
options: { fetch?: typeof globalThis.fetch; timeoutMs?: number; maxBytes?: number } = {},
): Promise<string | null> {
const maxBytes = options.maxBytes ?? TEXT_PREVIEW_MAX_BYTES;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 5000);
try {
const response = await (options.fetch ?? globalThis.fetch)(url, {
signal: controller.signal,
cache: "no-store",
});
if (!response.ok) return null;
const contentLength = response.headers.get("content-length");
if (contentLength && Number(contentLength) > maxBytes) return null;
const buffer = await response.arrayBuffer();
if (buffer.byteLength > maxBytes) return null;
return new TextDecoder("utf-8", { fatal: false }).decode(buffer);
} catch {
return null;
} finally {
clearTimeout(timer);
}
}

/**
* Human-readable byte size for metadata / file lists (decimal SI).
* Matches account/billing meters so a 250 MB free cap never reads as 238 MB.
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/pages/f/[workspace]/[...key].astro
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@
applyPublicFileHeaders,
publicFileCsp,
fetchPublicFile,
fetchTextPreview,
fileDownloadUrl,
fileKind as kind,
filePath,
formatBytes,
formatFileDate,
isTextPreviewable,
shouldShowModified,
sameUtcDay,
} from "../../../lib/public-file";
Expand Down Expand Up @@ -54,7 +56,7 @@
else if (result.status === "auth_required") Astro.response.status = 401;
else if (!file) Astro.response.status = 404;

const filename = key.split("/").filter(Boolean).pop() ?? key;

Check warning on line 59 in apps/web/src/pages/f/[workspace]/[...key].astro

View workflow job for this annotation

GitHub Actions / Lint & Format

unicorn(prefer-array-find)

Prefer `find` over filtering and accessing the first result.
const uploadedIso = file?.uploaded ?? null;
const modifiedIso = file?.modified ?? null;
const showModified = shouldShowModified(uploadedIso, modifiedIso);
Expand Down Expand Up @@ -100,6 +102,13 @@
})
: [];
const downloadUrl = file ? fileDownloadUrl(origin, workspace, key) : null;
// Inline text preview (issue #946): SSR-fetch the object body from storage
// (server-to-server, no CORS concern) for small text/markdown/csv/json
// files; any failure/timeout/oversized body falls back to the file card.
const textPreview =
file && isTextPreviewable(file.contentType, file.size)
? await fetchTextPreview(file.url, { timeoutMs: 5000 })
: null;
const title = file
? `${filename} · uploads.sh`
: result.status === "auth_required"
Expand Down Expand Up @@ -556,6 +565,10 @@
posterUrl={file.posterUrl}
videoDimensions={file.videoDimensions}
compare={compare}
contentType={file.contentType}
size={file.size}
downloadUrl={downloadUrl}
textPreview={textPreview}
>
<CopyAsControls formats={embedFormats} downloadUrl={downloadUrl}>
<a class="original" href={file.url} rel="noopener noreferrer">
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/pages/g/[id]/[item].astro
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,9 @@ const footnoteLine = footnoteParts.join(" · ");
posterUrl={item.posterUrl}
videoDimensions={item.videoDimensions}
railId="item-caption"
contentType={item.contentType}
size={item.size}
downloadUrl={downloadUrl}
>
{item.caption && <div class="caption">{item.caption}</div>}
{item.status === "available" && (
Expand Down
Loading