mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
Merge origin/main into features/validate-diagram-with-vlm
This commit is contained in:
@@ -1,61 +1,11 @@
|
||||
import { extract } from "@extractus/article-extractor"
|
||||
import { NextResponse } from "next/server"
|
||||
import TurndownService from "turndown"
|
||||
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
||||
|
||||
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
|
||||
const EXTRACT_TIMEOUT_MS = 15000
|
||||
|
||||
// SSRF protection - block private/internal addresses
|
||||
function isPrivateUrl(urlString: string): boolean {
|
||||
try {
|
||||
const url = new URL(urlString)
|
||||
const hostname = url.hostname.toLowerCase()
|
||||
|
||||
// Block localhost
|
||||
if (
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "::1"
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Block AWS/cloud metadata endpoints
|
||||
if (
|
||||
hostname === "169.254.169.254" ||
|
||||
hostname === "metadata.google.internal"
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for private IPv4 ranges
|
||||
const ipv4Match = hostname.match(
|
||||
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
|
||||
)
|
||||
if (ipv4Match) {
|
||||
const [, a, b] = ipv4Match.map(Number)
|
||||
if (a === 10) return true // 10.0.0.0/8
|
||||
if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12
|
||||
if (a === 192 && b === 168) return true // 192.168.0.0/16
|
||||
if (a === 169 && b === 254) return true // 169.254.0.0/16 (link-local)
|
||||
if (a === 127) return true // 127.0.0.0/8 (loopback)
|
||||
}
|
||||
|
||||
// Block common internal hostnames
|
||||
if (
|
||||
hostname.endsWith(".local") ||
|
||||
hostname.endsWith(".internal") ||
|
||||
hostname.endsWith(".localhost")
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch {
|
||||
return true // Invalid URL - block it
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { url } = await req.json()
|
||||
@@ -78,7 +28,7 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
// SSRF protection
|
||||
if (isPrivateUrl(url)) {
|
||||
if (!allowPrivateUrls && isPrivateUrl(url)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Cannot access private/internal URLs" },
|
||||
{ status: 400 },
|
||||
|
||||
@@ -9,69 +9,10 @@ import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||
import { generateText } from "ai"
|
||||
import { NextResponse } from "next/server"
|
||||
import { createOllama } from "ollama-ai-provider-v2"
|
||||
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
/**
|
||||
* SECURITY: Check if URL points to private/internal network (SSRF protection)
|
||||
* Blocks: localhost, private IPs, link-local, AWS metadata service
|
||||
*/
|
||||
function isPrivateUrl(urlString: string): boolean {
|
||||
try {
|
||||
const url = new URL(urlString)
|
||||
const hostname = url.hostname.toLowerCase()
|
||||
|
||||
// Block localhost
|
||||
if (
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "::1"
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Block AWS/cloud metadata endpoints
|
||||
if (
|
||||
hostname === "169.254.169.254" ||
|
||||
hostname === "metadata.google.internal"
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for private IPv4 ranges
|
||||
const ipv4Match = hostname.match(
|
||||
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
|
||||
)
|
||||
if (ipv4Match) {
|
||||
const [, a, b] = ipv4Match.map(Number)
|
||||
// 10.0.0.0/8
|
||||
if (a === 10) return true
|
||||
// 172.16.0.0/12
|
||||
if (a === 172 && b >= 16 && b <= 31) return true
|
||||
// 192.168.0.0/16
|
||||
if (a === 192 && b === 168) return true
|
||||
// 169.254.0.0/16 (link-local)
|
||||
if (a === 169 && b === 254) return true
|
||||
// 127.0.0.0/8 (loopback)
|
||||
if (a === 127) return true
|
||||
}
|
||||
|
||||
// Block common internal hostnames
|
||||
if (
|
||||
hostname.endsWith(".local") ||
|
||||
hostname.endsWith(".internal") ||
|
||||
hostname.endsWith(".localhost")
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch {
|
||||
// Invalid URL - block it
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
interface ValidateRequest {
|
||||
provider: string
|
||||
apiKey: string
|
||||
@@ -108,7 +49,7 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
// SECURITY: Block SSRF attacks via custom baseUrl
|
||||
if (baseUrl && isPrivateUrl(baseUrl)) {
|
||||
if (baseUrl && !allowPrivateUrls && isPrivateUrl(baseUrl)) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "Invalid base URL" },
|
||||
{ status: 400 },
|
||||
|
||||
@@ -160,6 +160,9 @@ interface ChatInputProps {
|
||||
onModelSelect?: (modelId: string | undefined) => void
|
||||
showUnvalidatedModels?: boolean
|
||||
onConfigureModels?: () => void
|
||||
// Focus control props
|
||||
shouldFocus?: boolean
|
||||
onFocused?: () => void
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
@@ -179,6 +182,8 @@ export function ChatInput({
|
||||
onModelSelect = () => {},
|
||||
showUnvalidatedModels = false,
|
||||
onConfigureModels = () => {},
|
||||
shouldFocus = false,
|
||||
onFocused,
|
||||
}: ChatInputProps) {
|
||||
const dict = useDictionary()
|
||||
const {
|
||||
@@ -192,6 +197,19 @@ export function ChatInput({
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
// Focus the textarea when shouldFocus becomes true
|
||||
// Use setTimeout to ensure focus happens after drawio iframe settles
|
||||
useEffect(() => {
|
||||
if (shouldFocus) {
|
||||
const timer = setTimeout(() => {
|
||||
textareaRef.current?.focus()
|
||||
onFocused?.()
|
||||
}, 150)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [shouldFocus, onFocused])
|
||||
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [showUrlDialog, setShowUrlDialog] = useState(false)
|
||||
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
|
||||
|
||||
@@ -179,6 +179,7 @@ export default function ChatPanel({
|
||||
const [tpmLimit, setTpmLimit] = useState(0)
|
||||
const [minimalStyle, setMinimalStyle] = useState(false)
|
||||
const [vlmValidationEnabled, setVlmValidationEnabled] = useState(true)
|
||||
const [shouldFocusInput, setShouldFocusInput] = useState(false)
|
||||
|
||||
// Restore input from sessionStorage on mount (when ChatPanel remounts due to key change)
|
||||
useEffect(() => {
|
||||
@@ -921,6 +922,7 @@ export default function ChatPanel({
|
||||
|
||||
// Clear UI state (can't use syncUIWithSession here because we also need to clear files)
|
||||
setMessages([])
|
||||
setInput("")
|
||||
clearDiagram()
|
||||
setDiagramHistory([])
|
||||
setValidationStates({}) // Clear validation states to prevent memory leak
|
||||
@@ -936,6 +938,9 @@ export default function ChatPanel({
|
||||
|
||||
// Clear URL param to show blank state
|
||||
router.replace(window.location.pathname, { scroll: false })
|
||||
|
||||
// After starting a fresh chat, move focus back to the chat input
|
||||
setShouldFocusInput(true)
|
||||
}, [
|
||||
clearDiagram,
|
||||
handleFileChange,
|
||||
@@ -1364,6 +1369,8 @@ export default function ChatPanel({
|
||||
onModelSelect={modelConfig.setSelectedModelId}
|
||||
showUnvalidatedModels={modelConfig.showUnvalidatedModels}
|
||||
onConfigureModels={() => setShowModelConfigDialog(true)}
|
||||
shouldFocus={shouldFocusInput}
|
||||
onFocused={() => setShouldFocusInput(false)}
|
||||
/>
|
||||
</footer>
|
||||
|
||||
|
||||
@@ -172,6 +172,13 @@ function SettingsContent({
|
||||
// Save locale to localStorage for persistence across restarts
|
||||
localStorage.setItem("next-ai-draw-io-locale", lang)
|
||||
|
||||
// Notify Electron main process to update its menu language
|
||||
if (window.electronAPI?.setUserLocale) {
|
||||
window.electronAPI.setUserLocale(lang).catch((error) => {
|
||||
console.error("Failed to sync locale with Electron:", error)
|
||||
})
|
||||
}
|
||||
|
||||
const parts = pathname.split("/")
|
||||
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
|
||||
parts[1] = lang
|
||||
|
||||
18
electron/electron.d.ts
vendored
18
electron/electron.d.ts
vendored
@@ -38,6 +38,12 @@ interface SetProxyResult {
|
||||
devMode?: boolean
|
||||
}
|
||||
|
||||
/** Result of setting user locale */
|
||||
interface SetUserLocaleResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/** Main window Electron API */
|
||||
@@ -62,6 +68,10 @@ declare global {
|
||||
getProxy: () => Promise<ProxyConfig>
|
||||
/** Set proxy configuration (saves and restarts server) */
|
||||
setProxy: (config: ProxyConfig) => Promise<SetProxyResult>
|
||||
/** Get user's preferred locale */
|
||||
getUserLocale: () => Promise<"en" | "zh" | "ja" | undefined>
|
||||
/** Set user's preferred locale */
|
||||
setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
|
||||
}
|
||||
|
||||
/** Settings window Electron API */
|
||||
@@ -88,4 +98,10 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export { ConfigPreset, ApplyPresetResult, ProxyConfig, SetProxyResult }
|
||||
export type {
|
||||
ConfigPreset,
|
||||
ApplyPresetResult,
|
||||
ProxyConfig,
|
||||
SetProxyResult,
|
||||
SetUserLocaleResult,
|
||||
}
|
||||
|
||||
@@ -12,11 +12,12 @@ import {
|
||||
getCurrentPresetId,
|
||||
setCurrentPreset,
|
||||
} from "./config-manager"
|
||||
import { getMenuTranslations, getPreferredLocale } from "./menu-i18n"
|
||||
import { restartNextServer } from "./next-server"
|
||||
import { showSettingsWindow } from "./settings-window"
|
||||
|
||||
/**
|
||||
* Build and set the application menu
|
||||
* Build and set the application menu with i18n support
|
||||
*/
|
||||
export function buildAppMenu(): void {
|
||||
const template = getMenuTemplate()
|
||||
@@ -25,18 +26,22 @@ export function buildAppMenu(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the menu (call this when presets change)
|
||||
* Rebuild the menu (call this when presets change or language changes)
|
||||
*/
|
||||
export function rebuildAppMenu(): void {
|
||||
buildAppMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the menu template
|
||||
* Get the menu template with translations
|
||||
*/
|
||||
function getMenuTemplate(): MenuItemConstructorOptions[] {
|
||||
const isMac = process.platform === "darwin"
|
||||
|
||||
// Get translations for preferred locale (saved preference or system default)
|
||||
const locale = getPreferredLocale(app.getLocale())
|
||||
const t = getMenuTranslations(locale)
|
||||
|
||||
const template: MenuItemConstructorOptions[] = []
|
||||
|
||||
// macOS app menu
|
||||
@@ -44,10 +49,10 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
||||
template.push({
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: "about" },
|
||||
{ role: "about" }, // System-translated
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Settings...",
|
||||
label: t.settings,
|
||||
accelerator: "CmdOrCtrl+,",
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow()
|
||||
@@ -55,26 +60,26 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ role: "services" },
|
||||
{ role: "services" }, // System-translated
|
||||
{ type: "separator" },
|
||||
{ role: "hide" },
|
||||
{ role: "hideOthers" },
|
||||
{ role: "unhide" },
|
||||
{ role: "hide" }, // System-translated
|
||||
{ role: "hideOthers" }, // System-translated
|
||||
{ role: "unhide" }, // System-translated
|
||||
{ type: "separator" },
|
||||
{ role: "quit" },
|
||||
{ role: "quit" }, // System-translated
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// File menu
|
||||
template.push({
|
||||
label: "File",
|
||||
label: t.file,
|
||||
submenu: [
|
||||
...(isMac
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: "Settings",
|
||||
label: t.settings,
|
||||
accelerator: "CmdOrCtrl+,",
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow()
|
||||
@@ -83,76 +88,76 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
||||
},
|
||||
{ type: "separator" } as MenuItemConstructorOptions,
|
||||
]),
|
||||
isMac ? { role: "close" } : { role: "quit" },
|
||||
isMac ? { role: "close" } : { role: "quit" }, // System-translated
|
||||
],
|
||||
})
|
||||
|
||||
// Edit menu
|
||||
template.push({
|
||||
label: "Edit",
|
||||
label: t.edit,
|
||||
submenu: [
|
||||
{ role: "undo" },
|
||||
{ role: "redo" },
|
||||
{ role: "undo" }, // System-translated
|
||||
{ role: "redo" }, // System-translated
|
||||
{ type: "separator" },
|
||||
{ role: "cut" },
|
||||
{ role: "copy" },
|
||||
{ role: "paste" },
|
||||
{ role: "cut" }, // System-translated
|
||||
{ role: "copy" }, // System-translated
|
||||
{ role: "paste" }, // System-translated
|
||||
...(isMac
|
||||
? [
|
||||
{
|
||||
role: "pasteAndMatchStyle",
|
||||
} as MenuItemConstructorOptions,
|
||||
{ role: "delete" } as MenuItemConstructorOptions,
|
||||
{ role: "selectAll" } as MenuItemConstructorOptions,
|
||||
} as MenuItemConstructorOptions, // System-translated
|
||||
{ role: "delete" } as MenuItemConstructorOptions, // System-translated
|
||||
{ role: "selectAll" } as MenuItemConstructorOptions, // System-translated
|
||||
]
|
||||
: [
|
||||
{ role: "delete" } as MenuItemConstructorOptions,
|
||||
{ role: "delete" } as MenuItemConstructorOptions, // System-translated
|
||||
{ type: "separator" } as MenuItemConstructorOptions,
|
||||
{ role: "selectAll" } as MenuItemConstructorOptions,
|
||||
{ role: "selectAll" } as MenuItemConstructorOptions, // System-translated
|
||||
]),
|
||||
],
|
||||
})
|
||||
|
||||
// View menu
|
||||
template.push({
|
||||
label: "View",
|
||||
label: t.view,
|
||||
submenu: [
|
||||
{ role: "reload" },
|
||||
{ role: "forceReload" },
|
||||
{ role: "toggleDevTools" },
|
||||
{ role: "reload" }, // System-translated
|
||||
{ role: "forceReload" }, // System-translated
|
||||
{ role: "toggleDevTools" }, // System-translated
|
||||
{ type: "separator" },
|
||||
{ role: "resetZoom" },
|
||||
{ role: "zoomIn" },
|
||||
{ role: "zoomOut" },
|
||||
{ role: "resetZoom" }, // System-translated
|
||||
{ role: "zoomIn" }, // System-translated
|
||||
{ role: "zoomOut" }, // System-translated
|
||||
{ type: "separator" },
|
||||
{ role: "togglefullscreen" },
|
||||
{ role: "togglefullscreen" }, // System-translated
|
||||
],
|
||||
})
|
||||
|
||||
// Configuration menu with presets
|
||||
template.push(buildConfigMenu())
|
||||
template.push(buildConfigMenu(t))
|
||||
|
||||
// Window menu
|
||||
template.push({
|
||||
label: "Window",
|
||||
label: t.window,
|
||||
submenu: [
|
||||
{ role: "minimize" },
|
||||
{ role: "zoom" },
|
||||
{ role: "minimize" }, // System-translated
|
||||
{ role: "zoom" }, // System-translated
|
||||
...(isMac
|
||||
? [
|
||||
{ type: "separator" } as MenuItemConstructorOptions,
|
||||
{ role: "front" } as MenuItemConstructorOptions,
|
||||
{ role: "front" } as MenuItemConstructorOptions, // System-translated
|
||||
]
|
||||
: [{ role: "close" } as MenuItemConstructorOptions]),
|
||||
: [{ role: "close" } as MenuItemConstructorOptions]), // System-translated
|
||||
],
|
||||
})
|
||||
|
||||
// Help menu
|
||||
template.push({
|
||||
label: "Help",
|
||||
label: t.help,
|
||||
submenu: [
|
||||
{
|
||||
label: "Documentation",
|
||||
label: t.documentation,
|
||||
click: async () => {
|
||||
await shell.openExternal(
|
||||
"https://github.com/dayuanjiang/next-ai-draw-io",
|
||||
@@ -160,7 +165,7 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Report Issue",
|
||||
label: t.reportIssue,
|
||||
click: async () => {
|
||||
await shell.openExternal(
|
||||
"https://github.com/dayuanjiang/next-ai-draw-io/issues",
|
||||
@@ -176,7 +181,9 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
||||
/**
|
||||
* Build the Configuration menu with presets
|
||||
*/
|
||||
function buildConfigMenu(): MenuItemConstructorOptions {
|
||||
function buildConfigMenu(
|
||||
t: ReturnType<typeof getMenuTranslations>,
|
||||
): MenuItemConstructorOptions {
|
||||
const presets = getAllPresets()
|
||||
const currentPresetId = getCurrentPresetId()
|
||||
|
||||
@@ -216,11 +223,11 @@ function buildConfigMenu(): MenuItemConstructorOptions {
|
||||
}))
|
||||
|
||||
return {
|
||||
label: "Configuration",
|
||||
label: t.configuration,
|
||||
submenu: [
|
||||
...(presetItems.length > 0
|
||||
? [
|
||||
{ label: "Switch Preset", enabled: false },
|
||||
{ label: t.switchPreset, enabled: false },
|
||||
{ type: "separator" } as MenuItemConstructorOptions,
|
||||
...presetItems,
|
||||
{ type: "separator" } as MenuItemConstructorOptions,
|
||||
@@ -229,8 +236,8 @@ function buildConfigMenu(): MenuItemConstructorOptions {
|
||||
{
|
||||
label:
|
||||
presetItems.length > 0
|
||||
? "Manage Presets..."
|
||||
: "Add Configuration Preset...",
|
||||
? t.managePresets
|
||||
: t.addConfigurationPreset,
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow()
|
||||
showSettingsWindow(win || undefined)
|
||||
|
||||
@@ -137,6 +137,7 @@ interface ConfigPresetsFile {
|
||||
version: 1
|
||||
currentPresetId: string | null
|
||||
presets: ConfigPreset[]
|
||||
userLocale?: "en" | "zh" | "ja"
|
||||
}
|
||||
|
||||
const CONFIG_FILE_NAME = "config-presets.json"
|
||||
@@ -161,6 +162,7 @@ export function loadPresets(): ConfigPresetsFile {
|
||||
version: 1,
|
||||
currentPresetId: null,
|
||||
presets: [],
|
||||
userLocale: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +183,7 @@ export function loadPresets(): ConfigPresetsFile {
|
||||
version: 1,
|
||||
currentPresetId: null,
|
||||
presets: [],
|
||||
userLocale: undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -462,3 +465,21 @@ export function getCurrentPresetEnv(): Record<string, string> {
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's preferred locale from config
|
||||
* Returns undefined if not set
|
||||
*/
|
||||
export function getUserLocale(): "en" | "zh" | "ja" | undefined {
|
||||
const data = loadPresets()
|
||||
return data.userLocale
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user's preferred locale in config
|
||||
*/
|
||||
export function setUserLocale(locale: "en" | "zh" | "ja" | null): void {
|
||||
const data = loadPresets()
|
||||
data.userLocale = locale === null ? undefined : locale
|
||||
savePresets(data)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { app, BrowserWindow, dialog, ipcMain } from "electron"
|
||||
import { rebuildAppMenu } from "./app-menu"
|
||||
import {
|
||||
applyPresetToEnv,
|
||||
type ConfigPreset,
|
||||
@@ -7,7 +8,9 @@ import {
|
||||
getAllPresets,
|
||||
getCurrentPreset,
|
||||
getCurrentPresetId,
|
||||
getUserLocale,
|
||||
setCurrentPreset,
|
||||
setUserLocale,
|
||||
updatePreset,
|
||||
} from "./config-manager"
|
||||
import { restartNextServer } from "./next-server"
|
||||
@@ -251,4 +254,32 @@ export function registerIpcHandlers(): void {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== User Locale ====================
|
||||
|
||||
ipcMain.handle("get-user-locale", () => {
|
||||
return getUserLocale()
|
||||
})
|
||||
|
||||
ipcMain.handle("set-user-locale", (_event, locale: string) => {
|
||||
// Validate locale is one of the supported values
|
||||
if (!["en", "zh", "ja"].includes(locale)) {
|
||||
return { success: false, error: "Invalid locale" }
|
||||
}
|
||||
|
||||
try {
|
||||
setUserLocale(locale as "en" | "zh" | "ja")
|
||||
// Rebuild the menu to reflect the new locale
|
||||
rebuildAppMenu()
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to set locale",
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
162
electron/main/menu-i18n.ts
Normal file
162
electron/main/menu-i18n.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Internationalization support for Electron menu
|
||||
* Translations for menu labels that don't use Electron's built-in roles
|
||||
*/
|
||||
|
||||
import { getUserLocale } from "./config-manager"
|
||||
|
||||
export type MenuLocale = "en" | "zh" | "ja"
|
||||
|
||||
export interface MenuTranslations {
|
||||
// App menu (macOS only)
|
||||
settings: string
|
||||
|
||||
// File menu
|
||||
file: string
|
||||
|
||||
// Edit menu
|
||||
edit: string
|
||||
|
||||
// View menu
|
||||
view: string
|
||||
|
||||
// Configuration menu
|
||||
configuration: string
|
||||
switchPreset: string
|
||||
managePresets: string
|
||||
addConfigurationPreset: string
|
||||
|
||||
// Window menu
|
||||
window: string
|
||||
|
||||
// Help menu
|
||||
help: string
|
||||
documentation: string
|
||||
reportIssue: string
|
||||
}
|
||||
|
||||
const translations: Record<MenuLocale, MenuTranslations> = {
|
||||
en: {
|
||||
// App menu
|
||||
settings: "Settings...",
|
||||
|
||||
// File menu
|
||||
file: "File",
|
||||
|
||||
// Edit menu
|
||||
edit: "Edit",
|
||||
|
||||
// View menu
|
||||
view: "View",
|
||||
|
||||
// Configuration menu
|
||||
configuration: "Configuration",
|
||||
switchPreset: "Switch Preset",
|
||||
managePresets: "Manage Presets...",
|
||||
addConfigurationPreset: "Add Configuration Preset...",
|
||||
|
||||
// Window menu
|
||||
window: "Window",
|
||||
|
||||
// Help menu
|
||||
help: "Help",
|
||||
documentation: "Documentation",
|
||||
reportIssue: "Report Issue",
|
||||
},
|
||||
|
||||
zh: {
|
||||
// App menu
|
||||
settings: "设置...",
|
||||
|
||||
// File menu
|
||||
file: "文件",
|
||||
|
||||
// Edit menu
|
||||
edit: "编辑",
|
||||
|
||||
// View menu
|
||||
view: "查看",
|
||||
|
||||
// Configuration menu
|
||||
configuration: "配置",
|
||||
switchPreset: "切换预设",
|
||||
managePresets: "管理预设...",
|
||||
addConfigurationPreset: "添加配置预设...",
|
||||
|
||||
// Window menu
|
||||
window: "窗口",
|
||||
|
||||
// Help menu
|
||||
help: "帮助",
|
||||
documentation: "文档",
|
||||
reportIssue: "报告问题",
|
||||
},
|
||||
|
||||
ja: {
|
||||
// App menu
|
||||
settings: "設定...",
|
||||
|
||||
// File menu
|
||||
file: "ファイル",
|
||||
|
||||
// Edit menu
|
||||
edit: "編集",
|
||||
|
||||
// View menu
|
||||
view: "表示",
|
||||
|
||||
// Configuration menu
|
||||
configuration: "設定",
|
||||
switchPreset: "プリセット切り替え",
|
||||
managePresets: "プリセット管理...",
|
||||
addConfigurationPreset: "設定プリセットを追加...",
|
||||
|
||||
// Window menu
|
||||
window: "ウインドウ",
|
||||
|
||||
// Help menu
|
||||
help: "ヘルプ",
|
||||
documentation: "ドキュメント",
|
||||
reportIssue: "問題を報告",
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Get menu translations for a given locale
|
||||
* Falls back to English if locale is not supported
|
||||
*/
|
||||
export function getMenuTranslations(locale: string): MenuTranslations {
|
||||
// Normalize locale (e.g., "zh-CN" -> "zh", "ja-JP" -> "ja")
|
||||
const normalized = locale.toLowerCase().split("-")[0]
|
||||
|
||||
if (normalized === "zh") return translations.zh
|
||||
if (normalized === "ja") return translations.ja
|
||||
return translations.en
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect system locale from Electron app
|
||||
* Returns one of: "en", "zh", "ja"
|
||||
*/
|
||||
export function detectSystemLocale(appLocale: string): MenuLocale {
|
||||
const normalized = appLocale.toLowerCase().split("-")[0]
|
||||
|
||||
if (normalized === "zh") return "zh"
|
||||
if (normalized === "ja") return "ja"
|
||||
return "en"
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locale from stored preference or system default
|
||||
* Checks config file for user's language preference first
|
||||
*/
|
||||
export function getPreferredLocale(appLocale: string): MenuLocale {
|
||||
// Try to get from saved preference first
|
||||
const savedLocale = getUserLocale()
|
||||
if (savedLocale) {
|
||||
return savedLocale
|
||||
}
|
||||
|
||||
// Fall back to system locale
|
||||
return detectSystemLocale(appLocale)
|
||||
}
|
||||
@@ -26,4 +26,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
getProxy: () => ipcRenderer.invoke("get-proxy"),
|
||||
setProxy: (config: { httpProxy?: string; httpsProxy?: string }) =>
|
||||
ipcRenderer.invoke("set-proxy", config),
|
||||
|
||||
// User locale settings
|
||||
getUserLocale: () => ipcRenderer.invoke("get-user-locale"),
|
||||
setUserLocale: (locale: string) =>
|
||||
ipcRenderer.invoke("set-user-locale", locale),
|
||||
})
|
||||
|
||||
@@ -129,3 +129,8 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# Enabled by default. Set to "false" to disable.
|
||||
# ENABLE_PDF_INPUT=true
|
||||
# NEXT_PUBLIC_MAX_EXTRACTED_CHARS=150000 # Max characters for PDF/text extraction (default: 150000)
|
||||
|
||||
# Security Settings (Optional)
|
||||
# Allow private/internal URLs for reverse proxy setups (default: true)
|
||||
# Set to "false" to block private IPs, localhost, and internal hostnames
|
||||
# ALLOW_PRIVATE_URLS=false
|
||||
|
||||
63
lib/ssrf-protection.ts
Normal file
63
lib/ssrf-protection.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* SSRF (Server-Side Request Forgery) protection utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if URL points to private/internal network
|
||||
* Blocks: localhost, private IPs, link-local, AWS metadata service
|
||||
*/
|
||||
export function isPrivateUrl(urlString: string): boolean {
|
||||
try {
|
||||
const url = new URL(urlString)
|
||||
const hostname = url.hostname.toLowerCase()
|
||||
|
||||
// Block localhost
|
||||
if (
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "::1"
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Block AWS/cloud metadata endpoints
|
||||
if (
|
||||
hostname === "169.254.169.254" ||
|
||||
hostname === "metadata.google.internal"
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for private IPv4 ranges
|
||||
const ipv4Match = hostname.match(
|
||||
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
|
||||
)
|
||||
if (ipv4Match) {
|
||||
const [, a, b] = ipv4Match.map(Number)
|
||||
if (a === 10) return true // 10.0.0.0/8
|
||||
if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12
|
||||
if (a === 192 && b === 168) return true // 192.168.0.0/16
|
||||
if (a === 169 && b === 254) return true // 169.254.0.0/16 (link-local)
|
||||
if (a === 127) return true // 127.0.0.0/8 (loopback)
|
||||
}
|
||||
|
||||
// Block common internal hostnames
|
||||
if (
|
||||
hostname.endsWith(".local") ||
|
||||
hostname.endsWith(".internal") ||
|
||||
hostname.endsWith(".localhost")
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch {
|
||||
return true // Invalid URL - block it
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether private URLs are allowed (defaults to true)
|
||||
* Set ALLOW_PRIVATE_URLS=false to block private URLs
|
||||
*/
|
||||
export const allowPrivateUrls = process.env.ALLOW_PRIVATE_URLS !== "false"
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "next-ai-draw-io",
|
||||
"version": "0.4.10",
|
||||
"version": "0.4.11",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"main": "dist-electron/main/index.js",
|
||||
|
||||
@@ -3,9 +3,49 @@
|
||||
* Copies node_modules to the standalone directory in the packaged app
|
||||
*/
|
||||
|
||||
const { cpSync, existsSync } = require("fs")
|
||||
const {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
} = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
/**
|
||||
* Copy directory recursively, converting symlinks to regular files/directories.
|
||||
* This is needed because cpSync with dereference:true does NOT convert symlinks.
|
||||
* macOS codesign fails if bundle contains symlinks pointing outside the bundle.
|
||||
*/
|
||||
function copyDereferenced(src, dst) {
|
||||
const lstat = lstatSync(src)
|
||||
|
||||
if (lstat.isSymbolicLink()) {
|
||||
// Follow symlink and check what it points to
|
||||
const stat = statSync(src)
|
||||
if (stat.isDirectory()) {
|
||||
// Symlink to directory: recursively copy the directory contents
|
||||
mkdirSync(dst, { recursive: true })
|
||||
for (const entry of readdirSync(src)) {
|
||||
copyDereferenced(path.join(src, entry), path.join(dst, entry))
|
||||
}
|
||||
} else {
|
||||
// Symlink to file: copy the actual file content
|
||||
mkdirSync(path.join(dst, ".."), { recursive: true })
|
||||
copyFileSync(src, dst)
|
||||
}
|
||||
} else if (lstat.isDirectory()) {
|
||||
mkdirSync(dst, { recursive: true })
|
||||
for (const entry of readdirSync(src)) {
|
||||
copyDereferenced(path.join(src, entry), path.join(dst, entry))
|
||||
}
|
||||
} else {
|
||||
mkdirSync(path.join(dst, ".."), { recursive: true })
|
||||
copyFileSync(src, dst)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = async (context) => {
|
||||
const appOutDir = context.appOutDir
|
||||
const resourcesDir = path.join(
|
||||
@@ -25,7 +65,7 @@ module.exports = async (context) => {
|
||||
console.log(`[afterPack] Copying node_modules to ${targetNodeModules}`)
|
||||
|
||||
if (existsSync(sourceNodeModules) && existsSync(standaloneDir)) {
|
||||
cpSync(sourceNodeModules, targetNodeModules, { recursive: true })
|
||||
copyDereferenced(sourceNodeModules, targetNodeModules)
|
||||
console.log("[afterPack] node_modules copied successfully")
|
||||
} else {
|
||||
console.error("[afterPack] Source or target directory not found!")
|
||||
|
||||
@@ -6,13 +6,54 @@
|
||||
* that electron-builder can properly include
|
||||
*/
|
||||
|
||||
import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs"
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
||||
const rootDir = join(__dirname, "..")
|
||||
|
||||
/**
|
||||
* Copy directory recursively, converting symlinks to regular files/directories.
|
||||
* This is needed because cpSync with dereference:true does NOT convert symlinks.
|
||||
* macOS codesign fails if bundle contains symlinks pointing outside the bundle.
|
||||
*/
|
||||
function copyDereferenced(src, dst) {
|
||||
const lstat = lstatSync(src)
|
||||
|
||||
if (lstat.isSymbolicLink()) {
|
||||
// Follow symlink and check what it points to
|
||||
const stat = statSync(src)
|
||||
if (stat.isDirectory()) {
|
||||
// Symlink to directory: recursively copy the directory contents
|
||||
mkdirSync(dst, { recursive: true })
|
||||
for (const entry of readdirSync(src)) {
|
||||
copyDereferenced(join(src, entry), join(dst, entry))
|
||||
}
|
||||
} else {
|
||||
// Symlink to file: copy the actual file content
|
||||
mkdirSync(join(dst, ".."), { recursive: true })
|
||||
copyFileSync(src, dst)
|
||||
}
|
||||
} else if (lstat.isDirectory()) {
|
||||
mkdirSync(dst, { recursive: true })
|
||||
for (const entry of readdirSync(src)) {
|
||||
copyDereferenced(join(src, entry), join(dst, entry))
|
||||
}
|
||||
} else {
|
||||
mkdirSync(join(dst, ".."), { recursive: true })
|
||||
copyFileSync(src, dst)
|
||||
}
|
||||
}
|
||||
|
||||
const standaloneDir = join(rootDir, ".next", "standalone")
|
||||
const staticDir = join(rootDir, ".next", "static")
|
||||
const targetDir = join(rootDir, "electron-standalone")
|
||||
@@ -30,20 +71,19 @@ mkdirSync(targetDir, { recursive: true })
|
||||
|
||||
// Copy standalone (includes node_modules)
|
||||
console.log("Copying standalone directory...")
|
||||
cpSync(standaloneDir, targetDir, { recursive: true })
|
||||
copyDereferenced(standaloneDir, targetDir)
|
||||
|
||||
// Copy static files
|
||||
console.log("Copying static files...")
|
||||
const targetStaticDir = join(targetDir, ".next", "static")
|
||||
mkdirSync(targetStaticDir, { recursive: true })
|
||||
cpSync(staticDir, targetStaticDir, { recursive: true })
|
||||
copyDereferenced(staticDir, targetStaticDir)
|
||||
|
||||
// Copy public folder (required for favicon-white.svg and other assets)
|
||||
console.log("Copying public folder...")
|
||||
const publicDir = join(rootDir, "public")
|
||||
const targetPublicDir = join(targetDir, "public")
|
||||
if (existsSync(publicDir)) {
|
||||
cpSync(publicDir, targetPublicDir, { recursive: true })
|
||||
copyDereferenced(publicDir, targetPublicDir)
|
||||
}
|
||||
|
||||
console.log("Done! Files prepared in electron-standalone/")
|
||||
|
||||
Reference in New Issue
Block a user