diff --git a/open-sse/config/googleTtsLanguages.js b/open-sse/config/googleTtsLanguages.js new file mode 100644 index 00000000..e7d35c2b --- /dev/null +++ b/open-sse/config/googleTtsLanguages.js @@ -0,0 +1,62 @@ +export const GOOGLE_TTS_LANGUAGES = [ + { id: "af", name: "Afrikaans", type: "tts" }, + { id: "ar", name: "Arabic", type: "tts" }, + { id: "bg", name: "Bulgarian", type: "tts" }, + { id: "bn", name: "Bengali", type: "tts" }, + { id: "bs", name: "Bosnian", type: "tts" }, + { id: "ca", name: "Catalan", type: "tts" }, + { id: "cs", name: "Czech", type: "tts" }, + { id: "cy", name: "Welsh", type: "tts" }, + { id: "da", name: "Danish", type: "tts" }, + { id: "de", name: "German", type: "tts" }, + { id: "el", name: "Greek", type: "tts" }, + { id: "en", name: "English", type: "tts" }, + { id: "eo", name: "Esperanto", type: "tts" }, + { id: "es", name: "Spanish", type: "tts" }, + { id: "et", name: "Estonian", type: "tts" }, + { id: "fi", name: "Finnish", type: "tts" }, + { id: "fr", name: "French", type: "tts" }, + { id: "gu", name: "Gujarati", type: "tts" }, + { id: "hi", name: "Hindi", type: "tts" }, + { id: "hr", name: "Croatian", type: "tts" }, + { id: "hu", name: "Hungarian", type: "tts" }, + { id: "hy", name: "Armenian", type: "tts" }, + { id: "id", name: "Indonesian", type: "tts" }, + { id: "is", name: "Icelandic", type: "tts" }, + { id: "it", name: "Italian", type: "tts" }, + { id: "ja", name: "Japanese", type: "tts" }, + { id: "jw", name: "Javanese", type: "tts" }, + { id: "km", name: "Khmer", type: "tts" }, + { id: "kn", name: "Kannada", type: "tts" }, + { id: "ko", name: "Korean", type: "tts" }, + { id: "la", name: "Latin", type: "tts" }, + { id: "lv", name: "Latvian", type: "tts" }, + { id: "mk", name: "Macedonian", type: "tts" }, + { id: "ml", name: "Malayalam", type: "tts" }, + { id: "mr", name: "Marathi", type: "tts" }, + { id: "my", name: "Myanmar (Burmese)", type: "tts" }, + { id: "ne", name: "Nepali", type: "tts" }, + { id: "nl", name: "Dutch", type: "tts" }, + { id: "no", name: "Norwegian", type: "tts" }, + { id: "pl", name: "Polish", type: "tts" }, + { id: "pt", name: "Portuguese", type: "tts" }, + { id: "ro", name: "Romanian", type: "tts" }, + { id: "ru", name: "Russian", type: "tts" }, + { id: "si", name: "Sinhala", type: "tts" }, + { id: "sk", name: "Slovak", type: "tts" }, + { id: "sq", name: "Albanian", type: "tts" }, + { id: "sr", name: "Serbian", type: "tts" }, + { id: "su", name: "Sundanese", type: "tts" }, + { id: "sv", name: "Swedish", type: "tts" }, + { id: "sw", name: "Swahili", type: "tts" }, + { id: "ta", name: "Tamil", type: "tts" }, + { id: "te", name: "Telugu", type: "tts" }, + { id: "th", name: "Thai", type: "tts" }, + { id: "tl", name: "Filipino", type: "tts" }, + { id: "tr", name: "Turkish", type: "tts" }, + { id: "uk", name: "Ukrainian", type: "tts" }, + { id: "ur", name: "Urdu", type: "tts" }, + { id: "vi", name: "Vietnamese", type: "tts" }, + { id: "zh-CN", name: "Chinese (Simplified)", type: "tts" }, + { id: "zh-TW", name: "Chinese (Traditional)", type: "tts" }, +]; diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index abfe6c50..008cce03 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -1,4 +1,5 @@ import { PROVIDERS } from "./providers.js"; +import { GOOGLE_TTS_LANGUAGES } from "./googleTtsLanguages.js"; // Provider models - Single source of truth // Key = alias (cc, cx, gc, qw, if, ag, gh for OAuth; id for API Key) @@ -371,15 +372,7 @@ export const PROVIDER_MODELS = { "local-device": [ { id: "default", name: "System Default Voice", type: "tts" }, ], - "google-tts": [ - { id: "en", name: "English", type: "tts" }, - { id: "vi", name: "Vietnamese", type: "tts" }, - { id: "zh-CN", name: "Chinese (Simplified)", type: "tts" }, - { id: "fr", name: "French", type: "tts" }, - { id: "de", name: "German", type: "tts" }, - { id: "ja", name: "Japanese", type: "tts" }, - { id: "ko", name: "Korean", type: "tts" }, - ], + "google-tts": GOOGLE_TTS_LANGUAGES, // OpenAI TTS voices (hardcoded — no public API to list them) // Used by ttsCore.js when provider = openai "openai-tts-voices": [ diff --git a/open-sse/handlers/ttsCore.js b/open-sse/handlers/ttsCore.js index 18c84705..3883101e 100644 --- a/open-sse/handlers/ttsCore.js +++ b/open-sse/handlers/ttsCore.js @@ -174,24 +174,64 @@ async function bingTts(text, voiceId) { return Buffer.from(buf).toString("base64"); // base64 MP3 } -// ── Local Device TTS (macOS `say` + ffmpeg) ─────────────────── +// ── Local Device TTS (macOS `say` + Windows SAPI + ffmpeg) ────── let _localVoicesCache = null; +async function fetchLocalDeviceVoicesMac() { + const { stdout } = await execFileAsync("say", ["-v", "?"]); + const voices = []; + for (const line of stdout.split("\n")) { + // Format: "Name locale # sample" + const m = line.match(/^([^\s].*?)\s{2,}([a-z]{2}_[A-Z]{2})/); + if (!m) continue; + const name = m[1].trim(); + const locale = m[2].trim(); // e.g. en_US + const lang = locale.split("_")[0]; + const country = locale.split("_")[1]; + voices.push({ id: name, name, locale, lang, country, gender: "" }); + } + return voices; +} + +async function fetchLocalDeviceVoicesWin() { + // Use -WindowStyle Hidden to suppress PowerShell popup window + const script = [ + "Add-Type -AssemblyName System.Speech;", + "$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;", + "$s.GetInstalledVoices() | ForEach-Object { $v = $_.VoiceInfo;", + "[PSCustomObject]@{ Name=$v.Name; Culture=$v.Culture.Name; Gender=$v.Gender } }", + "| ConvertTo-Json -Compress", + ].join(" "); + const { stdout } = await execFileAsync( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", script], + { windowsHide: true } + ); + const raw = JSON.parse(stdout.trim() || "[]"); + // Normalize: single object → array + const list = Array.isArray(raw) ? raw : [raw]; + return list.map((v) => { + const culture = v.Culture || "en-US"; + const [lang, country = ""] = culture.split("-"); + // Gender: 0=NotSet, 1=Male, 2=Female (SAPI enum) + const genderMap = { 1: "Male", 2: "Female", Male: "Male", Female: "Female" }; + return { + id: v.Name, + name: v.Name, + locale: culture.replace("-", "_"), + lang, + country, + gender: genderMap[v.Gender] || "", + }; + }); +} + export async function fetchLocalDeviceVoices() { if (_localVoicesCache) return _localVoicesCache; try { - const { stdout } = await execFileAsync("say", ["-v", "?"]); - const voices = []; - for (const line of stdout.split("\n")) { - // Format: "Name locale # sample" - const m = line.match(/^([^\s].*?)\s{2,}([a-z]{2}_[A-Z]{2})/); - if (!m) continue; - const name = m[1].trim(); - const locale = m[2].trim(); // e.g. en_US - const lang = locale.split("_")[0]; - const country = locale.split("_")[1]; - voices.push({ id: name, name, locale, lang, country, gender: "" }); - } + const voices = process.platform === "win32" + ? await fetchLocalDeviceVoicesWin() + : await fetchLocalDeviceVoicesMac(); _localVoicesCache = voices; return voices; } catch { diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index 58c0c65b..5119f6c9 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -22,6 +22,9 @@ export default function APIPageClient({ machineId }) { const [createdKey, setCreatedKey] = useState(null); const [requireApiKey, setRequireApiKey] = useState(false); + const [requireLogin, setRequireLogin] = useState(true); + const [hasPassword, setHasPassword] = useState(true); + const [tunnelDashboardAccess, setTunnelDashboardAccess] = useState(false); // Cloudflare Tunnel state const [tunnelChecking, setTunnelChecking] = useState(true); @@ -74,6 +77,9 @@ export default function APIPageClient({ machineId }) { if (settingsRes.ok) { const data = await settingsRes.json(); setRequireApiKey(data.requireApiKey || false); + setRequireLogin(data.requireLogin !== false); + setHasPassword(data.hasPassword || false); + setTunnelDashboardAccess(data.tunnelDashboardAccess || false); } if (statusRes.ok) { const data = await statusRes.json(); @@ -135,6 +141,19 @@ export default function APIPageClient({ machineId }) { } }; + const handleTunnelDashboardAccess = async (value) => { + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tunnelDashboardAccess: value }), + }); + if (res.ok) setTunnelDashboardAccess(value); + } catch (error) { + console.log("Error updating tunnelDashboardAccess:", error); + } + }; + const handleRequireApiKey = async (value) => { try { const res = await fetch("/api/settings", { @@ -688,10 +707,49 @@ export default function APIPageClient({ machineId }) { )} + + {/* Security warnings when tunnel or tailscale is active */} + {(tunnelEnabled || tsEnabled) && ( +
+ {!requireApiKey && ( + + )} + {(!requireLogin || !hasPassword) && ( + + )} +
+ )} + + {/* Tunnel dashboard access option */} + {(tunnelEnabled || tsEnabled) && ( +
+ handleTunnelDashboardAccess(!tunnelDashboardAccess)} + /> +
+

Allow dashboard access via tunnel

+ +
+
+ )} {/* API Keys */} - +

API Keys

@@ -647,7 +669,10 @@ function TtsExampleCard({ providerId }) { )} ) : ( -

Audio will appear here after running.

+
+ Response +
{DEFAULT_TTS_RESPONSE_EXAMPLE}
+
)}
diff --git a/src/app/api/settings/require-login/route.js b/src/app/api/settings/require-login/route.js index 1e1a14d6..08f0661c 100644 --- a/src/app/api/settings/require-login/route.js +++ b/src/app/api/settings/require-login/route.js @@ -5,7 +5,8 @@ export async function GET() { try { const settings = await getSettings(); const requireLogin = settings.requireLogin !== false; - return NextResponse.json({ requireLogin }); + const tunnelDashboardAccess = settings.tunnelDashboardAccess === true; + return NextResponse.json({ requireLogin, tunnelDashboardAccess }); } catch (error) { return NextResponse.json({ requireLogin: true }, { status: 200 }); } diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index bff3b230..15b2c666 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -69,29 +69,41 @@ export async function proxy(request) { } + // Protect all dashboard routes if (pathname.startsWith("/dashboard")) { - const token = request.cookies.get("auth_token")?.value; + const origin = request.nextUrl.origin; + let requireLogin = true; + let tunnelDashboardAccess = false; + try { + const res = await fetch(`${origin}/api/settings/require-login`); + const data = await res.json(); + requireLogin = data.requireLogin !== false; + tunnelDashboardAccess = data.tunnelDashboardAccess === true; + } catch { + // On error, keep defaults (require login, block tunnel) + } + + // Block tunnel access if disabled (checked before token to enforce the setting) + if (!isLocalRequest(request) && !tunnelDashboardAccess) { + return NextResponse.redirect(new URL("/login", request.url)); + } + + // If login not required, allow through + if (!requireLogin) return NextResponse.next(); + + // Verify JWT token + const token = request.cookies.get("auth_token")?.value; if (token) { try { await jwtVerify(token, SECRET); return NextResponse.next(); - } catch (err) { + } catch { return NextResponse.redirect(new URL("/login", request.url)); } } - const origin = request.nextUrl.origin; - try { - const res = await fetch(`${origin}/api/settings/require-login`); - const data = await res.json(); - if (data.requireLogin === false) { - return NextResponse.next(); - } - } catch (err) { - // On error, require login - } return NextResponse.redirect(new URL("/login", request.url)); } diff --git a/src/lib/localDb.js b/src/lib/localDb.js index 76cf935b..581afcfe 100644 --- a/src/lib/localDb.js +++ b/src/lib/localDb.js @@ -62,6 +62,7 @@ const defaultData = { comboStrategy: "fallback", comboStrategies: {}, requireLogin: true, + tunnelDashboardAccess: true, observabilityEnabled: true, observabilityMaxRecords: 1000, observabilityBatchSize: 20, @@ -99,6 +100,7 @@ function cloneDefaultData() { comboStrategy: "fallback", comboStrategies: {}, requireLogin: true, + tunnelDashboardAccess: true, observabilityEnabled: true, observabilityMaxRecords: 1000, observabilityBatchSize: 20, diff --git a/src/lib/tunnel/tailscale.js b/src/lib/tunnel/tailscale.js index 91ae8fe4..3b442d5d 100644 --- a/src/lib/tunnel/tailscale.js +++ b/src/lib/tunnel/tailscale.js @@ -2,7 +2,7 @@ import fs from "fs"; import path from "path"; import os from "os"; import { execSync, spawn } from "child_process"; -import { execWithPassword, executeElevatedPowerShell } from "@/mitm/dns/dnsConfig"; +import { execWithPassword } from "@/mitm/dns/dnsConfig"; import { saveTailscalePid, loadTailscalePid, clearTailscalePid } from "./state.js"; const BIN_DIR = path.join(os.homedir(), ".9router", "bin"); @@ -86,8 +86,11 @@ export function getTailscaleFunnelUrl(port) { */ export async function installTailscale(sudoPassword, hostname, onProgress) { const log = onProgress || (() => {}); - if (IS_WINDOWS) await installTailscaleWindows(log); - else if (IS_MAC) await installTailscaleMac(sudoPassword, log); + if (IS_WINDOWS) { + await installTailscaleWindows(log); + return { success: true }; + } + if (IS_MAC) await installTailscaleMac(sudoPassword, log); else await installTailscaleLinux(sudoPassword, log); log("Starting daemon..."); @@ -215,18 +218,36 @@ async function installTailscaleLinux(sudoPassword, log) { async function installTailscaleWindows(log) { const msiUrl = "https://pkgs.tailscale.com/stable/tailscale-setup-latest-amd64.msi"; const msiPath = path.join(os.tmpdir(), "tailscale-setup.msi"); - const psScriptPath = path.join(os.tmpdir(), `tailscale-install-${Date.now()}.ps1`); + // Download MSI via PowerShell with streaming output log("Downloading Tailscale installer..."); - const psScript = [ - `Invoke-WebRequest -Uri '${msiUrl}' -OutFile '${msiPath}'`, - `Start-Process msiexec.exe -ArgumentList '/i','${msiPath}','/quiet','/norestart' -Wait`, - `Remove-Item '${msiPath}' -Force -ErrorAction SilentlyContinue`, - ].join("\n"); + await new Promise((resolve, reject) => { + const child = spawn("powershell", [ + "-NoProfile", "-NonInteractive", "-Command", + `Invoke-WebRequest -Uri '${msiUrl}' -OutFile '${msiPath}'` + ], { stdio: ["ignore", "pipe", "pipe"] }); + child.stdout.on("data", (d) => { const l = d.toString().trim(); if (l) log(l); }); + child.stderr.on("data", (d) => { const l = d.toString().trim(); if (l) log(l); }); + child.on("close", (c) => c === 0 ? resolve() : reject(new Error("Download failed"))); + child.on("error", reject); + }); - fs.writeFileSync(psScriptPath, psScript, "utf8"); - log("Installing (UAC prompt may appear)..."); - await executeElevatedPowerShell(psScriptPath, 120000); + // Install MSI silently — Windows Installer handles UAC elevation automatically + log("Installing Tailscale (UAC prompt may appear)..."); + await new Promise((resolve, reject) => { + const child = spawn("msiexec", ["/i", msiPath, "/quiet", "/norestart"], { + stdio: ["ignore", "pipe", "pipe"] + }); + child.stdout.on("data", (d) => { const l = d.toString().trim(); if (l) log(l); }); + child.stderr.on("data", (d) => { const l = d.toString().trim(); if (l) log(l); }); + child.on("close", (c) => { + try { fs.unlinkSync(msiPath); } catch { /* ignore */ } + c === 0 ? resolve() : reject(new Error(`msiexec failed (code ${c})`)); + }); + child.on("error", reject); + }); + + log("Installation complete."); } /** Start tailscaled with sudo (TUN mode required for Funnel) */ diff --git a/src/shared/constants/ttsProviders.js b/src/shared/constants/ttsProviders.js index 0117febb..dbe1a136 100644 --- a/src/shared/constants/ttsProviders.js +++ b/src/shared/constants/ttsProviders.js @@ -4,10 +4,10 @@ */ export const TTS_PROVIDER_CONFIG = { "google-tts": { - hasLanguageDropdown: true, + hasLanguageDropdown: false, hasModelSelector: false, - hasBrowseButton: false, - voiceSource: "hardcoded", // from providerModels + hasBrowseButton: true, + voiceSource: "hardcoded", // languages built from providerModels at runtime }, "openai": { hasLanguageDropdown: false,