Skip to content
File

Blob: src/worker/routes/ui/raw.ts

typescript95 lines
1import { readBlobStream, readPath } from "@/worker/git";
2import { isValidRef, isValidPath, OID_RE, getContentTypeFromName } from "@/shared/web";
3import { isRequestPrivate, resolveUiRepoAccess } from "./helpers";
4import type { AppContext } from "../hono";
5 
6export async function handleRaw(c: AppContext<"/:owner/:repo/raw">) {
7 const env = c.env;
8 const owner = c.req.param("owner");
9 const repo = c.req.param("repo");
10 const access = await resolveUiRepoAccess(c, owner, repo);
11 if (access.kind === "response") return access.response;
12 const { route, cacheCtx } = access;
13 const isPrivate = isRequestPrivate(cacheCtx);
14 const url = new URL(c.req.url);
15 const oid = url.searchParams.get("oid") || "";
16 if (!OID_RE.test(oid)) return new Response("Bad Request\n", { status: 400 });
17 const fileName = url.searchParams.get("name") || oid;
18 const download = url.searchParams.get("download") === "1";
19 
20 if (!oid) return new Response("Missing oid\n", { status: 400 });
21 
22 // This route still avoids whole-pack buffering, but a packed blob may be
23 // materialized before the response body is streamed to the client.
24 const streamResponse = await readBlobStream(env, route.doName, oid, cacheCtx);
25 if (!streamResponse) return new Response("Not found\n", { status: 404 });
26 
27 // Use text/plain for all files (like GitHub's raw view) to prevent
28 // browsers from executing HTML/JS and ensure consistent display.
29 const headers = new Headers(streamResponse.headers);
30 headers.set("Content-Type", "text/plain; charset=utf-8");
31 if (isPrivate) headers.set("Cache-Control", "no-store");
32 
33 if (download) {
34 headers.set("Content-Disposition", `attachment; filename="${fileName}"`);
35 } else {
36 headers.set("Content-Disposition", `inline; filename="${fileName}"`);
37 }
38 
39 return new Response(streamResponse.body, {
40 status: streamResponse.status,
41 headers,
42 });
43}
44 
45export async function handleRawPath(c: AppContext<"/:owner/:repo/rawpath">) {
46 const env = c.env;
47 const owner = c.req.param("owner");
48 const repo = c.req.param("repo");
49 const access = await resolveUiRepoAccess(c, owner, repo);
50 if (access.kind === "response") return access.response;
51 const { route, cacheCtx } = access;
52 const isPrivate = isRequestPrivate(cacheCtx);
53 const url = new URL(c.req.url);
54 const ref = url.searchParams.get("ref") || "main";
55 const path = url.searchParams.get("path") || "";
56 const name = url.searchParams.get("name") || path.split("/").pop() || "file";
57 const download = url.searchParams.get("download") === "1";
58 if (!isValidRef(ref) || !isValidPath(path)) {
59 return new Response("Bad Request\n", { status: 400 });
60 }
61 
62 // Basic hotlink protection: require same-origin Referer
63 try {
64 const referer = c.req.raw.headers.get("referer") || "";
65 const allowed = (() => {
66 try {
67 const r = new URL(referer);
68 return r.host === url.host;
69 } catch {
70 return false;
71 }
72 })();
73 if (!allowed) {
74 return new Response("Hotlinking not allowed\n", { status: 403 });
75 }
76 } catch {}
77 
78 try {
79 const repoId = route.doName;
80 const result = await readPath(env, repoId, ref, path, cacheCtx);
81 if (result.type !== "blob") return new Response("Not a blob\n", { status: 400 });
82 const streamResponse = await readBlobStream(env, repoId, result.oid, cacheCtx);
83 if (!streamResponse) return new Response("Not found\n", { status: 404 });
84 
85 const headers = new Headers(streamResponse.headers);
86 headers.set("Content-Type", getContentTypeFromName(name));
87 if (isPrivate) headers.set("Cache-Control", "no-store");
88 if (download) headers.set("Content-Disposition", `attachment; filename="${name}"`);
89 else headers.set("Content-Disposition", `inline; filename="${name}"`);
90 return new Response(streamResponse.body, { status: streamResponse.status, headers });
91 } catch {
92 return new Response("Not found\n", { status: 404 });
93 }
94}