diff options
| -rw-r--r-- | public/static/style.css | 7 | ||||
| -rw-r--r-- | src/app.test.ts | 68 | ||||
| -rw-r--r-- | src/app.ts | 26 | ||||
| -rw-r--r-- | src/scraper.test.ts | 32 | ||||
| -rw-r--r-- | src/scraper.ts | 28 | ||||
| -rw-r--r-- | views/blob.eta | 7 |
6 files changed, 161 insertions, 7 deletions
diff --git a/public/static/style.css b/public/static/style.css index ed64bc6..157cc15 100644 --- a/public/static/style.css +++ b/public/static/style.css @@ -276,6 +276,13 @@ body > footer { } } +object.pdf { + display: block; + width: 100%; + height: 80vh; + padding: 0; +} + pre.box { /* Instead of scrolling overflow for long lines, grow the box. */ width: fit-content; diff --git a/src/app.test.ts b/src/app.test.ts index 94bf205..e28c375 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert"; import path from "node:path"; -import { describe, it } from "node:test"; +import { describe, it, type TestContext } from "node:test"; import { Eta } from "eta"; import { createApp } from "./app.ts"; @@ -100,12 +100,30 @@ describe("blob view", () => { language: null, size: "815 KB", image: false, + renderFileType: null, textLines: null, htmlLines: null, htmlContent: null, }; - // GitHub previews neither the text nor the bytes of most binary files. + it("should embed a PDF", () => { + const html = eta.render("blob.eta", { + ...blob, + path: "test/pdfs/basicapi.pdf", + renderFileType: "pdf", + }); + + assert.match(html, /<object[^>]*type="application\/pdf"/); + assert.match(html, /data="\/justrajdeep\/fonts\/embed\/pdf\/master\/test\/pdfs\/basicapi.pdf"/); + }); + + it("should not embed a file type it has no viewer for", () => { + const html = eta.render("blob.eta", { ...blob, renderFileType: "stl" }); + + assert.doesNotMatch(html, /<object/); + assert.match(html, /This file cannot be displayed\./); + }); + it("should offer the raw file when there is nothing to display", () => { const html = eta.render("blob.eta", blob); @@ -114,6 +132,52 @@ describe("blob view", () => { }); }); +describe("embedded files", () => { + function stubGitHub(t: TestContext, body: string, contentType: string, status = 200) { + t.mock.method( + globalThis, + "fetch", + async () => new Response(body, { status, headers: { "Content-Type": contentType } }), + ); + } + + it("should retype a PDF GitHub sends as a download", async (t) => { + stubGitHub(t, "%PDF-1.6", "application/octet-stream"); + + const res = await app.request("http://cgithub.example/mozilla/pdf.js/embed/pdf/master/doc.pdf"); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.headers.get("Content-Type"), "application/pdf"); + assert.strictEqual(await res.text(), "%PDF-1.6"); + }); + + it("should pass on a type GitHub already got right", async (t) => { + stubGitHub(t, "%PDF-1.6", "application/pdf"); + + const res = await app.request("http://cgithub.example/mozilla/pdf.js/embed/pdf/master/doc.pdf"); + + assert.strictEqual(res.headers.get("Content-Type"), "application/pdf"); + }); + + it("should not serve a file type it has no viewer for", async (t) => { + stubGitHub(t, "<script>", "text/html"); + + const res = await app.request("http://cgithub.example/mozilla/pdf.js/embed/html/master/x.html"); + + assert.strictEqual(res.status, 404); + }); + + it("should pass on the status of a file GitHub does not have", async (t) => { + stubGitHub(t, "404: Not Found", "text/plain", 404); + + const res = await app.request( + "http://cgithub.example/mozilla/pdf.js/embed/pdf/master/nope.pdf", + ); + + assert.strictEqual(res.status, 404); + }); +}); + describe("redirects to GitHub", () => { // An HTTP redirect would be caught by the extension's declarativeNetRequest // rules and bounced straight back here. @@ -10,6 +10,7 @@ import { getGitHubLatestCommit, getGitHubOwner, getGitHubPulls, + getGitHubRaw, getGitHubRefs, getGitHubRelease, getGitHubReleases, @@ -120,6 +121,31 @@ export function createApp(eta: Eta) { ); }); + // This is not a native GitHub route, but instead it lets us render blob + // content whose raw headers prevent hotlinking. + app.get("/:owner/:repo/embed/:type/:branch/:path{.*}", async (c) => { + const { owner, repo, type, branch, path } = c.req.param(); + + if (type !== "pdf") { + return c.notFound(); + } + + let response: Response; + try { + response = await getGitHubRaw(owner, repo, branch, path); + } catch (e) { + c.status(e instanceof GitHubHTTPError ? (e.status as StatusCode) : 500); + return c.body(null); + } + + let contentType = response.headers.get("Content-Type"); + if (contentType === null || contentType === "application/octet-stream") { + contentType = "application/pdf"; + } + + return new Response(response.body, { headers: { "Content-Type": contentType } }); + }); + app.get("/:owner/:repo/issues", async (c) => { const { owner, repo } = c.req.param(); const q = c.req.query("q"); diff --git a/src/scraper.test.ts b/src/scraper.test.ts index 836235e..88e9271 100644 --- a/src/scraper.test.ts +++ b/src/scraper.test.ts @@ -7,6 +7,7 @@ import { getGitHubSidebar, getGitHubLatestCommit, getGitHubPulls, + getGitHubRaw, getGitHubRefs, getGitHubRelease, getGitHubReleases, @@ -328,11 +329,41 @@ describe("GitHub scraper", () => { assert.strictEqual(data.size, "1.26 KB"); assert.strictEqual(data.language, null); assert.strictEqual(data.image, true); + assert.strictEqual(data.renderFileType, null); assert.strictEqual(data.textLines, null); assert.strictEqual(data.htmlLines, null); assert.strictEqual(data.htmlContent, null); }); + // GitHub renders a PDF with a viewer of its own, so the page carries no + // content for it beyond the file type. + it("should support PDF", async () => { + const data = await getGitHubBlob("mozilla", "pdf.js", "master", "test/pdfs/basicapi.pdf"); + + assert.strictEqual(data.repo.owner, "mozilla"); + assert.strictEqual(data.repo.name, "pdf.js"); + assert.strictEqual(data.branch, "master"); + assert.strictEqual(data.path, "test/pdfs/basicapi.pdf"); + + assert.strictEqual(data.size, "103 KB"); + assert.strictEqual(data.language, null); + assert.strictEqual(data.image, false); + assert.strictEqual(data.renderFileType, "pdf"); + assert.strictEqual(data.textLines, null); + assert.strictEqual(data.htmlLines, null); + assert.strictEqual(data.htmlContent, null); + }); + + // Why the embed route exists rather than the blob view pointing a viewer + // at GitHub: as application/octet-stream a browser downloads the file + // instead of displaying it. + it("should fetch a raw PDF that GitHub types as a download", async () => { + const response = await getGitHubRaw("mozilla", "pdf.js", "master", "test/pdfs/basicapi.pdf"); + + assert.strictEqual(response.headers.get("Content-Type"), "application/octet-stream"); + assert.ok((await response.text()).startsWith("%PDF-")); + }); + // A binary file GitHub has no preview for: no content of any kind, and the // path exercises escaping of a space. it("should support an unpreviewable binary file", async () => { @@ -346,6 +377,7 @@ describe("GitHub scraper", () => { assert.strictEqual(data.size, "815 KB"); assert.strictEqual(data.language, null); assert.strictEqual(data.image, false); + assert.strictEqual(data.renderFileType, null); assert.strictEqual(data.textLines, null); assert.strictEqual(data.htmlLines, null); assert.strictEqual(data.htmlContent, null); diff --git a/src/scraper.ts b/src/scraper.ts index 4220dc4..1a67d05 100644 --- a/src/scraper.ts +++ b/src/scraper.ts @@ -141,6 +141,7 @@ interface CodeViewBlobLayoutRouteStyledBlob { interface CodeViewBlobRoute { richText: string | null; + renderedFileInfo?: { renderFileType: string | null } | null; } interface SidebarAbout { @@ -250,6 +251,7 @@ export interface GitHubBlob extends GitHubNav { language: string | null; size: string; image: boolean; + renderFileType: string | null; textLines: string[] | null; htmlLines: string[] | null; htmlContent: string | null; @@ -378,15 +380,19 @@ export interface GitHubOwner { repos: OwnerRepo[]; } -async function fetchGitHubResponse(path: string, accept: string): Promise<Response> { - // GitHub throttles/blocks requests without realistic browser headers. - // These headers make the request appear as a standard browser visit. - const headers = { +// GitHub throttles/blocks requests without realistic browser headers. +// These headers make the request appear as a standard browser visit. +function browserHeaders(accept: string): Record<string, string> { + return { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", Accept: accept, "Accept-Language": "en-US,en;q=0.5", }; +} + +async function fetchGitHubResponse(path: string, accept: string): Promise<Response> { + const headers = browserHeaders(accept); const response = await fetch(`https://github.com/${path}`, { headers, redirect: "manual" }); if (response.status === 301 || response.status === 302) { const location = response.headers.get("location"); @@ -546,12 +552,26 @@ export async function getGitHubBlob( language: blob.language || null, size, image: blob.image, + renderFileType: payload?.codeViewBlobRoute?.renderedFileInfo?.renderFileType ?? null, textLines: styledBlob?.rawLines ?? null, htmlLines, htmlContent: payload?.codeViewBlobRoute?.richText ?? null, }); } +// For proxying embeds. +export async function getGitHubRaw( + owner: string, + repo: string, + branch: string, + path: string, +): Promise<Response> { + const url = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`; + const response = await fetch(url, { headers: browserHeaders("*/*") }); + if (!response.ok) throw new GitHubHTTPError(response.status, response.statusText); + return response; +} + export async function getGitHubIssues( owner: string, repo: string, diff --git a/views/blob.eta b/views/blob.eta index c7315f6..444508e 100644 --- a/views/blob.eta +++ b/views/blob.eta @@ -14,6 +14,11 @@ <% if (it.image) { %> <div class="box"><img src="/<%= it.repo.owner %>/<%= it.repo.name %>/raw/<%= it.branch %>/<%= it.path %>"></div> +<% } else if (it.renderFileType === "pdf") { %> + <object class="box pdf" type="application/pdf" + data="/<%= it.repo.owner %>/<%= it.repo.name %>/embed/<%= it.renderFileType %>/<%= it.branch %>/<%= it.path %>"> + This file cannot be displayed. + </object> <% } else if (it.htmlContent) { %> <div class="box"><%~ it.htmlContent %></div> <% } else if (it.htmlLines) { %> @@ -21,5 +26,5 @@ <% } else if (it.textLines) { %> <pre class="box"><code><%= it.textLines.join("\n") %></code></pre> <% } else { %> - <div class="box">This file cannot be displayed</div> + <div class="box">This file cannot be displayed.</div> <% } %> |
