mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-10 02:02:31 +08:00
Changes made as recommended by Claude:
1. Added a request timeout to prevent server resources from being tied up (route.ts) 2. Implemented runtime validation for the API response shape (url-utils.ts) 3. Removed hardcoded English error messages and replaced them with localized strings (url-input-dialog.tsx) 4. Fixed the incorrect i18n namespace (changed from pdf.* to url.*) (url-input-dialog.tsx and en/ja/zh.json)
This commit is contained in:
@@ -3,6 +3,7 @@ import { NextResponse } from "next/server"
|
|||||||
import TurndownService from "turndown"
|
import TurndownService from "turndown"
|
||||||
|
|
||||||
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
|
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
|
||||||
|
const EXTRACT_TIMEOUT_MS = 15000
|
||||||
|
|
||||||
// SSRF protection - block private/internal addresses
|
// SSRF protection - block private/internal addresses
|
||||||
function isPrivateUrl(urlString: string): boolean {
|
function isPrivateUrl(urlString: string): boolean {
|
||||||
@@ -84,12 +85,31 @@ export async function POST(req: Request) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract article content
|
// Extract article content with timeout to avoid tying up server resources
|
||||||
const article = await extract(url, {
|
const controller = new AbortController()
|
||||||
headers: {
|
const timeoutId = setTimeout(() => {
|
||||||
"User-Agent": "Mozilla/5.0 (compatible; NextAIDrawio/1.0)",
|
controller.abort()
|
||||||
},
|
}, EXTRACT_TIMEOUT_MS)
|
||||||
})
|
|
||||||
|
let article
|
||||||
|
try {
|
||||||
|
article = await extract(url, {
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "Mozilla/5.0 (compatible; NextAIDrawio/1.0)",
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.name === "AbortError") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Timed out while fetching URL content" },
|
||||||
|
{ status: 504 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
|
||||||
if (!article || !article.content) {
|
if (!article || !article.content) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -60,9 +60,9 @@ export function UrlInputDialog({
|
|||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{dict.pdf.title}</DialogTitle>
|
<DialogTitle>{dict.url.title}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{dict.pdf.description}
|
{dict.url.description}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ export function UrlInputDialog({
|
|||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
disabled={isExtracting}
|
disabled={isExtracting}
|
||||||
>
|
>
|
||||||
{dict.pdf.Cancel}
|
{dict.url.Cancel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
@@ -100,12 +100,12 @@ export function UrlInputDialog({
|
|||||||
{isExtracting ? (
|
{isExtracting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
{dict.pdf.Extracting}
|
{dict.url.Extracting}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Link className="mr-2 h-4 w-4" />
|
<Link className="mr-2 h-4 w-4" />
|
||||||
{dict.pdf.extract}
|
{dict.url.extract}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -187,7 +187,7 @@
|
|||||||
"chars": "chars",
|
"chars": "chars",
|
||||||
"removeFile": "Remove file"
|
"removeFile": "Remove file"
|
||||||
},
|
},
|
||||||
"pdf": {
|
"url": {
|
||||||
"title": "Extract Content from URL",
|
"title": "Extract Content from URL",
|
||||||
"description": "Paste a URL to extract and analyze its content",
|
"description": "Paste a URL to extract and analyze its content",
|
||||||
"Extracting": "Extracting...",
|
"Extracting": "Extracting...",
|
||||||
|
|||||||
@@ -187,7 +187,7 @@
|
|||||||
"chars": "文字",
|
"chars": "文字",
|
||||||
"removeFile": "ファイルを削除"
|
"removeFile": "ファイルを削除"
|
||||||
},
|
},
|
||||||
"pdf": {
|
"url": {
|
||||||
"title": "URLからコンテンツを抽出",
|
"title": "URLからコンテンツを抽出",
|
||||||
"description": "URLを貼り付けてそのコンテンツを抽出および分析します",
|
"description": "URLを貼り付けてそのコンテンツを抽出および分析します",
|
||||||
"Extracting": "抽出中...",
|
"Extracting": "抽出中...",
|
||||||
|
|||||||
@@ -187,7 +187,7 @@
|
|||||||
"chars": "字符",
|
"chars": "字符",
|
||||||
"removeFile": "移除文件"
|
"removeFile": "移除文件"
|
||||||
},
|
},
|
||||||
"pdf": {
|
"url": {
|
||||||
"title": "从 URL 提取内容",
|
"title": "从 URL 提取内容",
|
||||||
"description": "粘贴 URL 以提取和分析其内容",
|
"description": "粘贴 URL 以提取和分析其内容",
|
||||||
"Extracting": "提取中...",
|
"Extracting": "提取中...",
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
export interface UrlData {
|
export interface UrlData {
|
||||||
url: string
|
url: string
|
||||||
title: string
|
title: string
|
||||||
@@ -6,6 +8,12 @@ export interface UrlData {
|
|||||||
isExtracting: boolean
|
isExtracting: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UrlResponseSchema = z.object({
|
||||||
|
title: z.string().default("Untitled"),
|
||||||
|
content: z.string(),
|
||||||
|
charCount: z.number().int().nonnegative(),
|
||||||
|
})
|
||||||
|
|
||||||
export async function extractUrlContent(url: string): Promise<UrlData> {
|
export async function extractUrlContent(url: string): Promise<UrlData> {
|
||||||
const response = await fetch("/api/parse-url", {
|
const response = await fetch("/api/parse-url", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -13,17 +21,29 @@ export async function extractUrlContent(url: string): Promise<UrlData> {
|
|||||||
body: JSON.stringify({ url }),
|
body: JSON.stringify({ url }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Try to parse JSON once
|
||||||
|
const raw = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ error: "Unexpected non-JSON response" }))
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => null)
|
const message =
|
||||||
throw new Error(error?.error || "Failed to extract URL content")
|
typeof raw === "object" && raw && "error" in raw
|
||||||
|
? String((raw as any).error)
|
||||||
|
: "Failed to extract URL content"
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = UrlResponseSchema.safeParse(raw)
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new Error("Malformed response from URL extraction API")
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
title: data.title,
|
title: parsed.data.title,
|
||||||
content: data.content,
|
content: parsed.data.content,
|
||||||
charCount: data.charCount,
|
charCount: parsed.data.charCount,
|
||||||
isExtracting: false,
|
isExtracting: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1586
package-lock.json
generated
1586
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -25,7 +25,9 @@
|
|||||||
"dist:mac": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac",
|
"dist:mac": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac",
|
||||||
"dist:win": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --win",
|
"dist:win": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --win",
|
||||||
"dist:linux": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --linux",
|
"dist:linux": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --linux",
|
||||||
"dist:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac --win --linux"
|
"dist:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac --win --linux",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/amazon-bedrock": "^4.0.1",
|
"@ai-sdk/amazon-bedrock": "^4.0.1",
|
||||||
@@ -67,7 +69,6 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"js-tiktoken": "^1.0.21",
|
"js-tiktoken": "^1.0.21",
|
||||||
"jsdom": "^27.0.0",
|
|
||||||
"jsonrepair": "^3.13.1",
|
"jsonrepair": "^3.13.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"motion": "^12.23.25",
|
"motion": "^12.23.25",
|
||||||
@@ -105,14 +106,20 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||||
"@biomejs/biome": "^2.3.10",
|
"@biomejs/biome": "^2.3.10",
|
||||||
|
"@playwright/test": "^1.57.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@testing-library/react": "^16.3.1",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/negotiator": "^0.6.4",
|
"@types/negotiator": "^0.6.4",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/pako": "^2.0.3",
|
"@types/pako": "^2.0.3",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/turndown": "^5.0.6",
|
"@types/turndown": "^5.0.6",
|
||||||
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
|
"@vitest/coverage-v8": "^4.0.16",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"electron": "^39.2.7",
|
"electron": "^39.2.7",
|
||||||
@@ -121,10 +128,13 @@
|
|||||||
"eslint": "9.39.2",
|
"eslint": "9.39.2",
|
||||||
"eslint-config-next": "16.1.1",
|
"eslint-config-next": "16.1.1",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
|
"jsdom": "^27.4.0",
|
||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
"shx": "^0.4.0",
|
"shx": "^0.4.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
|
"vite-tsconfig-paths": "^6.0.3",
|
||||||
|
"vitest": "^4.0.16",
|
||||||
"wait-on": "^9.0.3",
|
"wait-on": "^9.0.3",
|
||||||
"wrangler": "4.54.0"
|
"wrangler": "4.54.0"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user