Skip to content

Commit ce53538

Browse files
committed
chore: Adjust all long paths to import using path aliases.
fix: Fixed the issue of images failing to export due to cross-domain issues.
1 parent 02f3d75 commit ce53538

127 files changed

Lines changed: 696 additions & 287 deletions

File tree

Some content is hidden

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

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
## Test Plan
66

7+
- [ ] `pnpm check:imports`
78
- [ ] `pnpm format:check`
89
- [ ] `pnpm hygiene:artifacts`
910
- [ ] `pnpm lint`

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ jobs:
3737
- name: Artifact Hygiene
3838
run: pnpm hygiene:artifacts
3939

40+
- name: Import Path Check
41+
run: pnpm check:imports
42+
4043
- name: Lint
4144
run: pnpm lint
4245

CONTRIBUTING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Settings.
2626
Run the checks before opening a pull request:
2727

2828
```bash
29+
pnpm check:imports
2930
pnpm format:check
3031
pnpm lint
3132
pnpm typecheck

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "neo-chat",
33
"description": "An omni AI chat application with a clean UI and excellent performance.",
44
"private": true,
5-
"version": "2.2.1",
5+
"version": "2.2.2",
66
"license": "MIT",
77
"packageManager": "pnpm@10.30.3",
88
"type": "module",
@@ -19,6 +19,7 @@
1919
"lint": "eslint",
2020
"format": "prettier --write .",
2121
"format:check": "prettier --check .",
22+
"check:imports": "node scripts/check-import-paths.mjs",
2223
"hygiene:artifacts": "node scripts/check-artifacts.mjs",
2324
"typecheck": "tsc --noEmit",
2425
"test": "vitest run",

scripts/check-import-paths.mjs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { readFile, readdir } from "node:fs/promises";
2+
import { dirname, extname, relative, resolve } from "node:path";
3+
import ts from "typescript";
4+
5+
const sourceRoot = resolve(process.cwd(), "src");
6+
const sourceExtensions = new Set([
7+
".ts",
8+
".tsx",
9+
".js",
10+
".jsx",
11+
".mts",
12+
".mjs",
13+
".cts",
14+
".cjs",
15+
]);
16+
17+
async function listSourceFiles(directory) {
18+
const entries = await readdir(directory, { withFileTypes: true });
19+
const files = [];
20+
21+
for (const entry of entries) {
22+
const filePath = resolve(directory, entry.name);
23+
if (entry.isDirectory()) {
24+
files.push(...(await listSourceFiles(filePath)));
25+
} else if (sourceExtensions.has(extname(entry.name))) {
26+
files.push(filePath);
27+
}
28+
}
29+
30+
return files;
31+
}
32+
33+
function isStringLiteral(node) {
34+
return node && ts.isStringLiteral(node);
35+
}
36+
37+
function collectModuleSpecifiers(sourceFile) {
38+
const specifiers = [];
39+
40+
const addSpecifier = (node) => {
41+
if (isStringLiteral(node)) {
42+
specifiers.push({
43+
line:
44+
sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
45+
.line + 1,
46+
value: node.text,
47+
});
48+
}
49+
};
50+
51+
const visit = (node) => {
52+
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
53+
addSpecifier(node.moduleSpecifier);
54+
} else if (ts.isImportEqualsDeclaration(node)) {
55+
const reference = node.moduleReference;
56+
if (ts.isExternalModuleReference(reference)) {
57+
addSpecifier(reference.expression);
58+
}
59+
} else if (ts.isCallExpression(node) && node.arguments.length === 1) {
60+
const expression = node.expression;
61+
const isDynamicImport = expression.kind === ts.SyntaxKind.ImportKeyword;
62+
const isRequireCall =
63+
ts.isIdentifier(expression) && expression.text === "require";
64+
const isVitestModuleCall =
65+
ts.isPropertyAccessExpression(expression) &&
66+
(expression.name.text === "importActual" ||
67+
expression.name.text === "importMock");
68+
69+
if (isDynamicImport || isRequireCall || isVitestModuleCall) {
70+
addSpecifier(node.arguments[0]);
71+
}
72+
}
73+
74+
ts.forEachChild(node, visit);
75+
};
76+
77+
visit(sourceFile);
78+
return specifiers;
79+
}
80+
81+
function getLongRelativeImport(filePath, specifier) {
82+
const parentTraversal = specifier.match(/^(?:\.\.\/)+/u)?.[0] ?? "";
83+
const depth = (parentTraversal.match(/\.\.\//gu) ?? []).length;
84+
if (depth < 2) return null;
85+
86+
const sourceRelativePath = relative(process.cwd(), filePath);
87+
const targetRelativePath = relative(
88+
process.cwd(),
89+
resolve(dirname(filePath), specifier),
90+
).replaceAll("\\", "/");
91+
92+
if (targetRelativePath !== "src" && !targetRelativePath.startsWith("src/")) {
93+
return null;
94+
}
95+
96+
return {
97+
file: sourceRelativePath.replaceAll("\\", "/"),
98+
specifier,
99+
};
100+
}
101+
102+
async function findViolations() {
103+
const violations = [];
104+
105+
for (const filePath of await listSourceFiles(sourceRoot)) {
106+
const source = await readFile(filePath, "utf8");
107+
const extension = extname(filePath);
108+
const scriptKind =
109+
extension === ".tsx"
110+
? ts.ScriptKind.TSX
111+
: extension === ".jsx"
112+
? ts.ScriptKind.JSX
113+
: [".js", ".mjs", ".cjs"].includes(extension)
114+
? ts.ScriptKind.JS
115+
: ts.ScriptKind.TS;
116+
const sourceFile = ts.createSourceFile(
117+
filePath,
118+
source,
119+
ts.ScriptTarget.Latest,
120+
true,
121+
scriptKind,
122+
);
123+
124+
for (const specifier of collectModuleSpecifiers(sourceFile)) {
125+
const violation = getLongRelativeImport(filePath, specifier.value);
126+
if (violation) {
127+
violations.push({ ...violation, line: specifier.line });
128+
}
129+
}
130+
}
131+
132+
return violations.sort(
133+
(a, b) => a.file.localeCompare(b.file) || a.line - b.line,
134+
);
135+
}
136+
137+
const violations = await findViolations();
138+
139+
if (violations.length > 0) {
140+
console.error(
141+
`Found ${violations.length} src-internal long relative import${
142+
violations.length === 1 ? "" : "s"
143+
}:
144+
${violations
145+
.map(({ file, line, specifier }) => `- ${file}:${line} ${specifier}`)
146+
.join("\n")}`,
147+
);
148+
process.exit(1);
149+
}
150+
151+
console.log("No src-internal long relative imports found.");

src/__tests__/deploymentHygiene.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,14 @@ describe("deployment hygiene", () => {
2626
expect(packageJson.scripts["hygiene:artifacts"]).toBe(
2727
"node scripts/check-artifacts.mjs",
2828
);
29+
expect(packageJson.scripts["check:imports"]).toBe(
30+
"node scripts/check-import-paths.mjs",
31+
);
2932
expect(ci).toContain("pnpm build:worker");
3033
expect(ci).toContain("pnpm worker:size");
3134
expect(ci).not.toContain("pnpm worker:dry-run");
3235
expect(ci).toContain("pnpm hygiene:artifacts");
36+
expect(ci).toContain("pnpm check:imports");
3337
expect(ci).toContain("pnpm audit --prod --audit-level moderate");
3438
});
3539

src/__tests__/knowledgeStore.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const {
55
deleteFromOPFSMock,
66
deleteFromRAGMock,
77
getSettingsStateMock,
8+
getSafeOPFSPathMock,
89
listOPFSDirectoryMock,
910
saveToOPFSMock,
1011
upsertToRAGMock,
@@ -14,6 +15,9 @@ const {
1415
deleteFromOPFSMock: vi.fn(() => Promise.resolve()),
1516
deleteFromRAGMock: vi.fn(() => Promise.resolve(true)),
1617
getSettingsStateMock: vi.fn(),
18+
getSafeOPFSPathMock: vi.fn((url: string) =>
19+
url.startsWith("opfs://") ? url.slice("opfs://".length) : null,
20+
),
1721
listOPFSDirectoryMock: vi.fn((): Promise<string[]> => Promise.resolve([])),
1822
saveToOPFSMock: vi.fn(() => Promise.resolve("opfs://saved/file.txt")),
1923
upsertToRAGMock: vi.fn(() => Promise.resolve(true)),
@@ -23,6 +27,7 @@ const {
2327

2428
vi.mock("@/utils/opfs", () => ({
2529
deleteFromOPFS: deleteFromOPFSMock,
30+
getSafeOPFSPath: getSafeOPFSPathMock,
2631
listOPFSDirectory: listOPFSDirectoryMock,
2732
resolveOPFSUrl: vi.fn(() => Promise.resolve("blob:opfs-file")),
2833
saveToOPFS: saveToOPFSMock,
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { NextRequest } from "next/server";
3+
4+
const safeFetchArrayBufferMock = vi.hoisted(() => vi.fn());
5+
6+
vi.mock("server-only", () => ({}));
7+
vi.mock("../lib/security/safeFetch", () => ({
8+
safeFetchArrayBuffer: safeFetchArrayBufferMock,
9+
}));
10+
vi.mock("../lib/security/urlPolicy", async () =>
11+
vi.importActual("../lib/security/urlPolicy"),
12+
);
13+
vi.mock("../lib/utils/safeServerLog", () => ({
14+
safeServerLogError: vi.fn(),
15+
}));
16+
17+
const createRequest = (body: unknown) =>
18+
new NextRequest("https://neo.test/api/media/image-proxy", {
19+
method: "POST",
20+
headers: { "content-type": "application/json" },
21+
body: JSON.stringify(body),
22+
});
23+
24+
describe("message image proxy route", () => {
25+
beforeEach(() => {
26+
vi.resetModules();
27+
safeFetchArrayBufferMock.mockReset();
28+
});
29+
30+
it("returns supported upstream image bytes with a safe content type", async () => {
31+
const bytes = Uint8Array.from([1, 2, 3]).buffer;
32+
safeFetchArrayBufferMock.mockResolvedValue({
33+
response: new Response(null, {
34+
status: 200,
35+
headers: { "content-type": "image/png; charset=binary" },
36+
}),
37+
arrayBuffer: bytes,
38+
});
39+
40+
const { POST } = await import("../app/api/media/image-proxy/route");
41+
const response = await POST(
42+
createRequest({
43+
url: "https://platform-outputs.agnes-ai.space/images/t2i/image.png",
44+
}),
45+
);
46+
47+
expect(response.status).toBe(200);
48+
expect(response.headers.get("content-type")).toBe("image/png");
49+
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
50+
await expect(response.arrayBuffer()).resolves.toEqual(bytes);
51+
const upstreamUrl = safeFetchArrayBufferMock.mock.calls[0]?.[0] as URL;
52+
expect(upstreamUrl.toString()).toBe(
53+
"https://platform-outputs.agnes-ai.space/images/t2i/image.png",
54+
);
55+
expect(safeFetchArrayBufferMock).toHaveBeenCalledWith(
56+
expect.any(URL),
57+
expect.objectContaining({
58+
method: "GET",
59+
headers: { Accept: "image/*" },
60+
}),
61+
expect.objectContaining({
62+
maxResponseBytes: 10 * 1024 * 1024,
63+
policy: expect.objectContaining({ context: "image" }),
64+
}),
65+
);
66+
});
67+
68+
it("rejects malformed proxy requests before fetching upstream", async () => {
69+
const { POST } = await import("../app/api/media/image-proxy/route");
70+
const response = await POST(createRequest({ url: "not-a-url" }));
71+
72+
expect(response.status).toBe(400);
73+
expect(safeFetchArrayBufferMock).not.toHaveBeenCalled();
74+
});
75+
76+
it("rejects upstream responses that are not supported raster images", async () => {
77+
safeFetchArrayBufferMock.mockResolvedValue({
78+
response: new Response(null, {
79+
status: 200,
80+
headers: { "content-type": "image/svg+xml" },
81+
}),
82+
arrayBuffer: new ArrayBuffer(0),
83+
});
84+
85+
const { POST } = await import("../app/api/media/image-proxy/route");
86+
const response = await POST(
87+
createRequest({ url: "https://example.com/image.svg" }),
88+
);
89+
90+
expect(response.status).toBe(415);
91+
});
92+
});

src/__tests__/messageItemComposition.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,11 @@ describe("MessageItem composition", () => {
123123
expect(messageItem).toContain("forceExpandCodeBlocks");
124124
expect(messageItem).toContain("MessageOutputRenderer");
125125
expect(messageItem).toContain("proxyMessageExportImages");
126-
expect(messageItem).toContain("https://serveproxy.com/?url=");
127-
expect(messageItem).toContain("encodeURIComponent");
126+
expect(messageItem).toContain("MESSAGE_IMAGE_PROXY_PATH");
127+
expect(messageItem).toContain("signedApiFetch");
128+
expect(messageItem).toContain("URL.createObjectURL");
129+
expect(messageItem).toContain("URL.revokeObjectURL");
130+
expect(messageItem).not.toContain("serveproxy.com");
128131
expect(messageItem).toContain("waitForMessageExportImages");
129132
expect(messageItem).toContain("DropdownMenuSub");
130133
expect(messageItem).toContain("DropdownMenuSubTrigger");

src/__tests__/urlPolicy.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,26 @@ describe("url policy and provider runtime helpers", () => {
173173
expect(result.hostname).toBe("localhost");
174174
});
175175

176+
it("applies deployment network policy to image proxy targets", () => {
177+
process.env.DEPLOYMENT_MODE = "hosted";
178+
delete process.env.ALLOW_LOCAL_NETWORK_PROXY;
179+
180+
expect(() =>
181+
validateOutboundUrl(
182+
"https://127.0.0.1/image.png",
183+
getSafeUrlPolicy("image"),
184+
),
185+
).toThrow(/Private network|Localhost/i);
186+
187+
process.env.ALLOW_LOCAL_NETWORK_PROXY = "true";
188+
expect(
189+
validateOutboundUrl(
190+
"https://127.0.0.1/image.png",
191+
getSafeUrlPolicy("image"),
192+
).hostname,
193+
).toBe("127.0.0.1");
194+
});
195+
176196
it("blocks local provider proxying in hosted mode with a public error code", () => {
177197
process.env.DEPLOYMENT_MODE = "hosted";
178198
delete process.env.ALLOW_LOCAL_NETWORK_PROXY;

0 commit comments

Comments
 (0)