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:
Biki Kalita
2026-01-05 14:51:24 +05:30
parent 64268b0fac
commit 580d42f535
8 changed files with 1625 additions and 55 deletions

View File

@@ -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(

View File

@@ -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>

View File

@@ -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...",

View File

@@ -187,7 +187,7 @@
"chars": "文字", "chars": "文字",
"removeFile": "ファイルを削除" "removeFile": "ファイルを削除"
}, },
"pdf": { "url": {
"title": "URLからコンテンツを抽出", "title": "URLからコンテンツを抽出",
"description": "URLを貼り付けてそのコンテンツを抽出および分析します", "description": "URLを貼り付けてそのコンテンツを抽出および分析します",
"Extracting": "抽出中...", "Extracting": "抽出中...",

View File

@@ -187,7 +187,7 @@
"chars": "字符", "chars": "字符",
"removeFile": "移除文件" "removeFile": "移除文件"
}, },
"pdf": { "url": {
"title": "从 URL 提取内容", "title": "从 URL 提取内容",
"description": "粘贴 URL 以提取和分析其内容", "description": "粘贴 URL 以提取和分析其内容",
"Extracting": "提取中...", "Extracting": "提取中...",

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}, },