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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ SEARCH_INDEX_EXTENSIONS = # 覆盖默认
# 开发者模式 - 控制调试信息显示
DEVELOPER_MODE = false # 是否启用开发者模式 | 默认关闭

# 前端部署基路径(子路径部署时使用,例如 /repo-viewer/ | 根路径部署留空)
VITE_BASE_PATH =

# 开发者模式启用时提供以下功能:
# - 控制台详细日志输出(API请求、文件操作、组件生命周期等)
# - 分组调试信息(应用初始化、API请求流程等)
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,7 @@ coverage
.tmp
.docfind
report
src/generated/
src/generated/*.generated.ts
public/search-index/
public/initial-content/
/.serena
34 changes: 34 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Repository Guidelines

## Project Structure & Module Organization

`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/`.

## Build, Test, and Development Commands

This repo uses Vite+ (`vp`) instead of `npm run` scripts.

- `vp install` - install dependencies.
- `vp dev` - start the local development server.
- `vp build` - create a production build; also generates initial content and docfind artifacts.
- `vp check` - run the unified validation pipeline before opening a PR.
- `vp test` - run the Vitest suite.
- `vp run generate:index` - rebuild the static search index in `public/search-index/` when index-related code changes.

Copy `.env.example` to `.env` before local work.

## Coding Style & Naming Conventions

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.

## Testing Guidelines

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.

## Commit & Pull Request Guidelines

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.

## Configuration & Search Index Notes

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.
251 changes: 251 additions & 0 deletions api/github.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const { mockedAxiosGet } = vi.hoisted(() => ({
mockedAxiosGet: vi.fn(),
}));

vi.mock("axios", () => ({
default: {
get: mockedAxiosGet,
},
}));

interface MockResponseState {
headers: Record<string, string | number>;
jsonBody: unknown;
sentBody: unknown;
statusCode: number;
}

const originalEnv = { ...process.env };
const baseEnv = Object.fromEntries(
Object.entries(originalEnv).filter(
([key]) => !key.startsWith("GITHUB_PAT") && !key.startsWith("VITE_GITHUB_PAT"),
),
);

const createMockRes = (): {
res: {
status: (code: number) => unknown;
json: (data: unknown) => unknown;
send: (data: unknown) => unknown;
setHeader: (name: string, value: string | number) => unknown;
};
state: MockResponseState;
} => {
const state: MockResponseState = {
headers: {},
jsonBody: null,
sentBody: null,
statusCode: 200,
};

const res = {
status(code: number) {
state.statusCode = code;
return res;
},
json(data: unknown) {
state.jsonBody = data;
return res;
},
send(data: unknown) {
state.sentBody = data;
return res;
},
setHeader(name: string, value: string | number) {
state.headers[name] = value;
return res;
},
};

return { res, state };
};

const loadHandler = async (): Promise<(req: unknown, res: unknown) => Promise<void>> => {
vi.resetModules();
const mod = await import("./github");
return mod.default as (req: unknown, res: unknown) => Promise<void>;
};

describe("api/github handler security hardening", () => {
beforeEach(() => {
mockedAxiosGet.mockReset();
process.env = {
...baseEnv,
GITHUB_REPO_OWNER: "test-owner",
GITHUB_REPO_NAME: "test-repo",
GITHUB_REPO_BRANCH: "main",
GITHUB_PAT1: "",
};
});

afterEach(() => {
process.env = { ...originalEnv };
});

it("rejects deprecated getFileContent url parameter", async () => {
const handler = await loadHandler();
const { res, state } = createMockRes();

await handler(
{
query: {
action: "getFileContent",
url: "https://example.com/test.txt",
},
},
res,
);

expect(state.statusCode).toBe(400);
expect(state.jsonBody).toEqual({
error: "The url parameter is deprecated. Use path and optional branch instead.",
});
expect(mockedAxiosGet).not.toHaveBeenCalled();
});

it("rejects getFileContent without path", async () => {
const handler = await loadHandler();
const { res, state } = createMockRes();

await handler(
{
query: {
action: "getFileContent",
},
},
res,
);

expect(state.statusCode).toBe(400);
expect(state.jsonBody).toEqual({ error: "Missing path parameter" });
expect(mockedAxiosGet).not.toHaveBeenCalled();
});

it("fetches repo files with composed raw URL and auth header", async () => {
process.env.GITHUB_PAT1 = "secret-token";
const handler = await loadHandler();
const { res, state } = createMockRes();

mockedAxiosGet.mockResolvedValueOnce({
data: new Uint8Array([65, 66, 67]).buffer,
headers: {
"content-type": "text/plain; charset=utf-8",
},
} as never);

await handler(
{
query: {
action: "getFileContent",
path: "docs/readme.md",
branch: "main",
},
},
res,
);

expect(mockedAxiosGet).toHaveBeenCalledTimes(1);
const [calledUrl, calledConfig] = mockedAxiosGet.mock.calls[0] ?? [];
expect(calledUrl).toBe(
"https://raw.githubusercontent.com/test-owner/test-repo/main/docs/readme.md",
);
expect(calledConfig?.maxRedirects).toBe(0);
expect(calledConfig?.headers?.Authorization).toBe("token secret-token");
expect(state.statusCode).toBe(200);
expect(Buffer.isBuffer(state.sentBody)).toBe(true);
});

it("rejects getGitHubAsset non-https url", async () => {
const handler = await loadHandler();
const { res, state } = createMockRes();

await handler(
{
query: {
action: "getGitHubAsset",
url: "http://raw.githubusercontent.com/test-owner/test-repo/main/a.md",
},
},
res,
);

expect(state.statusCode).toBe(400);
expect(state.jsonBody).toEqual({ error: "Only https protocol is allowed" });
expect(mockedAxiosGet).not.toHaveBeenCalled();
});

it("rejects getGitHubAsset non-allowlisted host", async () => {
const handler = await loadHandler();
const { res, state } = createMockRes();

await handler(
{
query: {
action: "getGitHubAsset",
url: "https://example.com/assets/a.png",
},
},
res,
);

expect(state.statusCode).toBe(400);
expect(state.jsonBody).toEqual({ error: "Host is not allowed" });
expect(mockedAxiosGet).not.toHaveBeenCalled();
});

it("fetches allowlisted GitHub assets without Authorization", async () => {
process.env.GITHUB_PAT1 = "secret-token";
const handler = await loadHandler();
const { res, state } = createMockRes();

mockedAxiosGet.mockResolvedValueOnce({
data: new Uint8Array([1, 2, 3]).buffer,
headers: {
"content-type": "image/png",
},
} as never);

await handler(
{
query: {
action: "getGitHubAsset",
url: "https://user-images.githubusercontent.com/123/abc.png",
},
},
res,
);

expect(mockedAxiosGet).toHaveBeenCalledTimes(1);
const [, calledConfig] = mockedAxiosGet.mock.calls[0] ?? [];
expect(calledConfig?.maxRedirects).toBe(0);
expect(calledConfig?.headers?.Authorization).toBeUndefined();
expect(state.statusCode).toBe(200);
});

it("does not follow getGitHubAsset redirects", async () => {
const handler = await loadHandler();
const { res, state } = createMockRes();

mockedAxiosGet.mockRejectedValueOnce({
response: {
status: 302,
},
message: "Found",
});

await handler(
{
query: {
action: "getGitHubAsset",
url: "https://raw.githubusercontent.com/test-owner/test-repo/main/file.png",
},
},
res,
);

expect(state.statusCode).toBe(302);
expect(state.jsonBody).toEqual({ error: "Failed to fetch GitHub asset" });
});
});
Loading
Loading