Skip to content
File

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

typescript176 lines
1import { readPath } from "@/worker/git";
2import {
3 isValidRef,
4 isValidPath,
5 formatSize,
6 detectBinary,
7 bytesToText,
8 getHighlightLangsForBlobSmart,
9} from "@/shared/web";
10import { handleError } from "@/client/server/error";
11import { badRequest, isRequestPrivate, resolveUiRepoAccess } from "./helpers";
12import type { AppContext } from "../hono";
13import { renderUiDocumentResponse } from "../uiResponse";
14 
15export async function handleBlob(c: AppContext<"/:owner/:repo/blob">) {
16 const env = c.env;
17 const owner = c.req.param("owner");
18 const repo = c.req.param("repo");
19 const access = await resolveUiRepoAccess(c, owner, repo);
20 if (access.kind === "response") return access.response;
21 const { route, cacheCtx } = access;
22 const repoId = route.doName;
23 const isPrivate = isRequestPrivate(cacheCtx);
24 const u = new URL(c.req.url);
25 const ref = u.searchParams.get("ref") || "main";
26 const path = u.searchParams.get("path") || "";
27 if (!isValidRef(ref)) {
28 return badRequest(env, "Invalid ref", "Ref format not allowed", {
29 owner,
30 repo,
31 refEnc: encodeURIComponent(ref),
32 path,
33 });
34 }
35 if (path && !isValidPath(path)) {
36 return badRequest(env, "Invalid path", "Path contains invalid characters or is too long", {
37 owner,
38 repo,
39 refEnc: encodeURIComponent(ref),
40 path,
41 });
42 }
43 try {
44 const result = await readPath(env, repoId, ref, path, cacheCtx);
45 if (result.type !== "blob") return new Response("Not a blob\n", { status: 400 });
46 const fileName = path || result.oid;
47 const viewer = access.viewer;
48 
49 // Generate breadcrumbs and parent link (same pattern as tree.ts)
50 const parts = (path || "").split("/").filter(Boolean);
51 const refDisplay = ref.length > 20 ? ref.slice(0, 7) + "..." : ref;
52 const breadcrumbs = [
53 {
54 name: refDisplay,
55 href: parts.length > 0 ? `/${owner}/${repo}/tree?ref=${encodeURIComponent(ref)}` : null,
56 },
57 ...parts.map((part: string, i: number) => {
58 const subPath = parts.slice(0, i + 1).join("/");
59 const isLast = i === parts.length - 1;
60 return {
61 name: part,
62 href: isLast
63 ? null
64 : `/${owner}/${repo}/tree?ref=${encodeURIComponent(ref)}&path=${encodeURIComponent(subPath)}`,
65 };
66 }),
67 ];
68 const parentHref =
69 parts.length > 1
70 ? `/${owner}/${repo}/tree?ref=${encodeURIComponent(ref)}&path=${encodeURIComponent(parts.slice(0, -1).join("/"))}`
71 : parts.length === 1
72 ? `/${owner}/${repo}/tree?ref=${encodeURIComponent(ref)}`
73 : null;
74 
75 // Too large to render inline
76 if (result.tooLarge) {
77 const sizeStr = formatSize(result.size || 0);
78 const viewRawHref = `/${owner}/${repo}/raw?oid=${encodeURIComponent(result.oid)}&view=1&name=${encodeURIComponent(fileName)}`;
79 const rawHref = `/${owner}/${repo}/raw?oid=${encodeURIComponent(result.oid)}&download=1&name=${encodeURIComponent(fileName)}`;
80 return renderUiDocumentResponse(
81 env,
82 "blob",
83 {
84 title: `${fileName} · ${owner}/${repo}`,
85 owner,
86 repo,
87 refEnc: encodeURIComponent(ref),
88 fileName,
89 tooLarge: true,
90 sizeStr,
91 viewRawHref,
92 rawHref,
93 breadcrumbs,
94 parentHref,
95 },
96 {
97 cacheControl: isPrivate ? "no-store" : undefined,
98 failureBody: "Failed to render view",
99 viewer,
100 }
101 );
102 }
103 
104 // Binary vs text
105 const isBinary = detectBinary(result.content);
106 const size = result.content.byteLength;
107 const viewRawHref = `/${owner}/${repo}/raw?oid=${encodeURIComponent(result.oid)}&view=1&name=${encodeURIComponent(fileName)}`;
108 const rawHref = `/${owner}/${repo}/raw?oid=${encodeURIComponent(result.oid)}&download=1&name=${encodeURIComponent(fileName)}`;
109 const templateData: Record<string, unknown> = {
110 title: `${fileName} · ${owner}/${repo}`,
111 owner,
112 repo,
113 refEnc: encodeURIComponent(ref),
114 fileName,
115 viewRawHref,
116 breadcrumbs,
117 parentHref,
118 rawHref,
119 };
120 
121 if (isBinary) {
122 const ext = (fileName.split(".").pop() || "").toLowerCase();
123 const isImage = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "svg"].includes(ext);
124 const isPdf = ext === "pdf";
125 if ((isImage || isPdf) && path) {
126 const name = encodeURIComponent(fileName);
127 const mediaSrc = `/${owner}/${repo}/rawpath?ref=${encodeURIComponent(ref)}&path=${encodeURIComponent(path)}&name=${name}`;
128 templateData.isImage = isImage;
129 templateData.isPdf = isPdf;
130 templateData.mediaSrc = mediaSrc;
131 templateData.sizeStr = formatSize(size);
132 } else {
133 templateData.isBinary = true;
134 templateData.sizeStr = formatSize(size);
135 }
136 } else {
137 const text = bytesToText(result.content);
138 const lineCount = text === "" ? 0 : text.split(/\r?\n/).length;
139 const isMd =
140 fileName.toLowerCase().endsWith(".md") || fileName.toLowerCase().endsWith(".markdown");
141 if (isMd) {
142 const baseDir = (path || "").split("/").filter(Boolean).slice(0, -1).join("/");
143 templateData.isMarkdown = true;
144 templateData.markdownRaw = text;
145 templateData.lineCount = lineCount;
146 templateData.mdOwner = owner;
147 templateData.mdRepo = repo;
148 templateData.mdRef = ref;
149 templateData.mdBase = baseDir;
150 } else {
151 const langs = getHighlightLangsForBlobSmart(fileName, text);
152 const codeLang = langs[0] || null;
153 templateData.codeText = text;
154 templateData.codeLang = codeLang;
155 templateData.lineCount = lineCount;
156 if (!codeLang) {
157 templateData.sizeStr = formatSize(size);
158 }
159 }
160 }
161 
162 return renderUiDocumentResponse(env, "blob", templateData, {
163 cacheControl: isPrivate ? "no-store" : undefined,
164 failureBody: "Failed to render view",
165 viewer,
166 });
167 } catch (e) {
168 return handleError(env, e, `Error · ${owner}/${repo}`, {
169 owner,
170 repo,
171 refEnc: encodeURIComponent(ref),
172 path,
173 });
174 }
175}