Skip to content
This repository was archived by the owner on Jul 3, 2026. It is now read-only.

Commit 5705500

Browse files
authored
2.1.0
【修复】 - 任意 URL 代理 / 令牌外带 - 新仓库环境下本地开发阻塞 - 强制刷新有可能仍拿到旧数据 - 多扩展名搜索在 fallback 路径下只生效第一个 【优化】 - chunk 体积 - 项目编译性能 - 点击标题时浏览器硬跳转 - 降低大文件下载时的内存占用 - 分支搜索与文本文件预览的性能问题
1 parent 25d6065 commit 5705500

52 files changed

Lines changed: 2958 additions & 530 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ SEARCH_INDEX_EXTENSIONS = # 覆盖默认
7575
# 开发者模式 - 控制调试信息显示
7676
DEVELOPER_MODE = false # 是否启用开发者模式 | 默认关闭
7777

78+
# 前端部署基路径(子路径部署时使用,例如 /repo-viewer/ | 根路径部署留空)
79+
VITE_BASE_PATH =
80+
7881
# 开发者模式启用时提供以下功能:
7982
# - 控制台详细日志输出(API请求、文件操作、组件生命周期等)
8083
# - 分组调试信息(应用初始化、API请求流程等)

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,7 @@ coverage
1919
.tmp
2020
.docfind
2121
report
22-
src/generated/
22+
src/generated/*.generated.ts
2323
public/search-index/
24+
public/initial-content/
25+
/.serena

AGENTS.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Repository Guidelines
2+
3+
## Project Structure & Module Organization
4+
5+
`src/` contains the application code. UI lives in `src/components/`, reusable logic in `src/hooks/`, GitHub data access in `src/services/github/`, shared helpers in `src/utils/`, and app-wide state in `src/contexts/` and `src/providers/`. Theme tokens and component styling live under `src/theme/`. Static assets and generated search index files are served from `public/`. Serverless handlers are in `api/`, while build-time generators such as `generateInitialContent.ts` and `generateDocfindIndex.ts` live in `scripts/`. Project docs and screenshots are kept in `docs/`.
6+
7+
## Build, Test, and Development Commands
8+
9+
This repo uses Vite+ (`vp`) instead of `npm run` scripts.
10+
11+
- `vp install` - install dependencies.
12+
- `vp dev` - start the local development server.
13+
- `vp build` - create a production build; also generates initial content and docfind artifacts.
14+
- `vp check` - run the unified validation pipeline before opening a PR.
15+
- `vp test` - run the Vitest suite.
16+
- `vp run generate:index` - rebuild the static search index in `public/search-index/` when index-related code changes.
17+
18+
Copy `.env.example` to `.env` before local work.
19+
20+
## Coding Style & Naming Conventions
21+
22+
Follow `.editorconfig`: UTF-8, LF, spaces, and 2-space indentation. Keep JS/TS/TSX lines near the 100-character limit. Prefer TypeScript, functional React components, and small focused modules. Use `PascalCase` for components (`FilePreviewPage.tsx`), `camelCase` for hooks and utilities (`useRepoSearch.ts`, `hashUtils.ts`), and descriptive folder names grouped by feature. Keep comments brief and only where intent is not obvious.
23+
24+
## Testing Guidelines
25+
26+
Vitest is configured in `vite.config.ts` and currently discovers `src/**/*.test.ts` with a Node environment. Place tests next to the code they cover, mirroring the source name, for example `src/utils/sorting/contentSorting.test.ts`. Add tests for new parsing, caching, indexing, or data transformation logic; for UI-heavy changes, include manual verification notes in the PR if automated coverage is not practical.
27+
28+
## Commit & Pull Request Guidelines
29+
30+
Recent history uses short release-style subjects such as `2.0.0` and `1.4.1`. For normal contributions, prefer concise imperative commit messages and keep unrelated changes separate. Open PRs against `dev`, not `master`. Include a clear description, link related issues, list verification steps (for example `vp check` and `vp test`), and attach screenshots for visible UI changes.
31+
32+
## Configuration & Search Index Notes
33+
34+
Review `.env.example` before changing GitHub API, proxy, or search-index behavior. Search index output under `public/search-index/` is generated content; update it only when the indexing pipeline or indexed branches change.

api/github.test.ts

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const { mockedAxiosGet } = vi.hoisted(() => ({
4+
mockedAxiosGet: vi.fn(),
5+
}));
6+
7+
vi.mock("axios", () => ({
8+
default: {
9+
get: mockedAxiosGet,
10+
},
11+
}));
12+
13+
interface MockResponseState {
14+
headers: Record<string, string | number>;
15+
jsonBody: unknown;
16+
sentBody: unknown;
17+
statusCode: number;
18+
}
19+
20+
const originalEnv = { ...process.env };
21+
const baseEnv = Object.fromEntries(
22+
Object.entries(originalEnv).filter(
23+
([key]) => !key.startsWith("GITHUB_PAT") && !key.startsWith("VITE_GITHUB_PAT"),
24+
),
25+
);
26+
27+
const createMockRes = (): {
28+
res: {
29+
status: (code: number) => unknown;
30+
json: (data: unknown) => unknown;
31+
send: (data: unknown) => unknown;
32+
setHeader: (name: string, value: string | number) => unknown;
33+
};
34+
state: MockResponseState;
35+
} => {
36+
const state: MockResponseState = {
37+
headers: {},
38+
jsonBody: null,
39+
sentBody: null,
40+
statusCode: 200,
41+
};
42+
43+
const res = {
44+
status(code: number) {
45+
state.statusCode = code;
46+
return res;
47+
},
48+
json(data: unknown) {
49+
state.jsonBody = data;
50+
return res;
51+
},
52+
send(data: unknown) {
53+
state.sentBody = data;
54+
return res;
55+
},
56+
setHeader(name: string, value: string | number) {
57+
state.headers[name] = value;
58+
return res;
59+
},
60+
};
61+
62+
return { res, state };
63+
};
64+
65+
const loadHandler = async (): Promise<(req: unknown, res: unknown) => Promise<void>> => {
66+
vi.resetModules();
67+
const mod = await import("./github");
68+
return mod.default as (req: unknown, res: unknown) => Promise<void>;
69+
};
70+
71+
describe("api/github handler security hardening", () => {
72+
beforeEach(() => {
73+
mockedAxiosGet.mockReset();
74+
process.env = {
75+
...baseEnv,
76+
GITHUB_REPO_OWNER: "test-owner",
77+
GITHUB_REPO_NAME: "test-repo",
78+
GITHUB_REPO_BRANCH: "main",
79+
GITHUB_PAT1: "",
80+
};
81+
});
82+
83+
afterEach(() => {
84+
process.env = { ...originalEnv };
85+
});
86+
87+
it("rejects deprecated getFileContent url parameter", async () => {
88+
const handler = await loadHandler();
89+
const { res, state } = createMockRes();
90+
91+
await handler(
92+
{
93+
query: {
94+
action: "getFileContent",
95+
url: "https://example.com/test.txt",
96+
},
97+
},
98+
res,
99+
);
100+
101+
expect(state.statusCode).toBe(400);
102+
expect(state.jsonBody).toEqual({
103+
error: "The url parameter is deprecated. Use path and optional branch instead.",
104+
});
105+
expect(mockedAxiosGet).not.toHaveBeenCalled();
106+
});
107+
108+
it("rejects getFileContent without path", async () => {
109+
const handler = await loadHandler();
110+
const { res, state } = createMockRes();
111+
112+
await handler(
113+
{
114+
query: {
115+
action: "getFileContent",
116+
},
117+
},
118+
res,
119+
);
120+
121+
expect(state.statusCode).toBe(400);
122+
expect(state.jsonBody).toEqual({ error: "Missing path parameter" });
123+
expect(mockedAxiosGet).not.toHaveBeenCalled();
124+
});
125+
126+
it("fetches repo files with composed raw URL and auth header", async () => {
127+
process.env.GITHUB_PAT1 = "secret-token";
128+
const handler = await loadHandler();
129+
const { res, state } = createMockRes();
130+
131+
mockedAxiosGet.mockResolvedValueOnce({
132+
data: new Uint8Array([65, 66, 67]).buffer,
133+
headers: {
134+
"content-type": "text/plain; charset=utf-8",
135+
},
136+
} as never);
137+
138+
await handler(
139+
{
140+
query: {
141+
action: "getFileContent",
142+
path: "docs/readme.md",
143+
branch: "main",
144+
},
145+
},
146+
res,
147+
);
148+
149+
expect(mockedAxiosGet).toHaveBeenCalledTimes(1);
150+
const [calledUrl, calledConfig] = mockedAxiosGet.mock.calls[0] ?? [];
151+
expect(calledUrl).toBe(
152+
"https://raw.githubusercontent.com/test-owner/test-repo/main/docs/readme.md",
153+
);
154+
expect(calledConfig?.maxRedirects).toBe(0);
155+
expect(calledConfig?.headers?.Authorization).toBe("token secret-token");
156+
expect(state.statusCode).toBe(200);
157+
expect(Buffer.isBuffer(state.sentBody)).toBe(true);
158+
});
159+
160+
it("rejects getGitHubAsset non-https url", async () => {
161+
const handler = await loadHandler();
162+
const { res, state } = createMockRes();
163+
164+
await handler(
165+
{
166+
query: {
167+
action: "getGitHubAsset",
168+
url: "http://raw.githubusercontent.com/test-owner/test-repo/main/a.md",
169+
},
170+
},
171+
res,
172+
);
173+
174+
expect(state.statusCode).toBe(400);
175+
expect(state.jsonBody).toEqual({ error: "Only https protocol is allowed" });
176+
expect(mockedAxiosGet).not.toHaveBeenCalled();
177+
});
178+
179+
it("rejects getGitHubAsset non-allowlisted host", async () => {
180+
const handler = await loadHandler();
181+
const { res, state } = createMockRes();
182+
183+
await handler(
184+
{
185+
query: {
186+
action: "getGitHubAsset",
187+
url: "https://example.com/assets/a.png",
188+
},
189+
},
190+
res,
191+
);
192+
193+
expect(state.statusCode).toBe(400);
194+
expect(state.jsonBody).toEqual({ error: "Host is not allowed" });
195+
expect(mockedAxiosGet).not.toHaveBeenCalled();
196+
});
197+
198+
it("fetches allowlisted GitHub assets without Authorization", async () => {
199+
process.env.GITHUB_PAT1 = "secret-token";
200+
const handler = await loadHandler();
201+
const { res, state } = createMockRes();
202+
203+
mockedAxiosGet.mockResolvedValueOnce({
204+
data: new Uint8Array([1, 2, 3]).buffer,
205+
headers: {
206+
"content-type": "image/png",
207+
},
208+
} as never);
209+
210+
await handler(
211+
{
212+
query: {
213+
action: "getGitHubAsset",
214+
url: "https://user-images.githubusercontent.com/123/abc.png",
215+
},
216+
},
217+
res,
218+
);
219+
220+
expect(mockedAxiosGet).toHaveBeenCalledTimes(1);
221+
const [, calledConfig] = mockedAxiosGet.mock.calls[0] ?? [];
222+
expect(calledConfig?.maxRedirects).toBe(0);
223+
expect(calledConfig?.headers?.Authorization).toBeUndefined();
224+
expect(state.statusCode).toBe(200);
225+
});
226+
227+
it("does not follow getGitHubAsset redirects", async () => {
228+
const handler = await loadHandler();
229+
const { res, state } = createMockRes();
230+
231+
mockedAxiosGet.mockRejectedValueOnce({
232+
response: {
233+
status: 302,
234+
},
235+
message: "Found",
236+
});
237+
238+
await handler(
239+
{
240+
query: {
241+
action: "getGitHubAsset",
242+
url: "https://raw.githubusercontent.com/test-owner/test-repo/main/file.png",
243+
},
244+
},
245+
res,
246+
);
247+
248+
expect(state.statusCode).toBe(302);
249+
expect(state.jsonBody).toEqual({ error: "Failed to fetch GitHub asset" });
250+
});
251+
});

0 commit comments

Comments
 (0)