mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
Enhance TTS functionality and security settings
- Integrated Google TTS languages from a separate module for better maintainability. - Updated local device voice fetching to support both macOS and Windows, improving cross-platform compatibility. - Enhanced dashboard route protection by adding dynamic settings for login requirements and tunnel access. - Introduced UI elements for managing security settings related to API key requirements and dashboard access via tunnel. - Added default TTS response example in the media provider page for better user guidance. - Updated constants to reflect changes in TTS provider configurations. This commit improves the overall user experience and security of the TTS features.
This commit is contained in:
@@ -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" },
|
||||||
|
];
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { PROVIDERS } from "./providers.js";
|
import { PROVIDERS } from "./providers.js";
|
||||||
|
import { GOOGLE_TTS_LANGUAGES } from "./googleTtsLanguages.js";
|
||||||
|
|
||||||
// Provider models - Single source of truth
|
// Provider models - Single source of truth
|
||||||
// Key = alias (cc, cx, gc, qw, if, ag, gh for OAuth; id for API Key)
|
// 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": [
|
"local-device": [
|
||||||
{ id: "default", name: "System Default Voice", type: "tts" },
|
{ id: "default", name: "System Default Voice", type: "tts" },
|
||||||
],
|
],
|
||||||
"google-tts": [
|
"google-tts": GOOGLE_TTS_LANGUAGES,
|
||||||
{ 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" },
|
|
||||||
],
|
|
||||||
// OpenAI TTS voices (hardcoded — no public API to list them)
|
// OpenAI TTS voices (hardcoded — no public API to list them)
|
||||||
// Used by ttsCore.js when provider = openai
|
// Used by ttsCore.js when provider = openai
|
||||||
"openai-tts-voices": [
|
"openai-tts-voices": [
|
||||||
|
|||||||
@@ -174,24 +174,64 @@ async function bingTts(text, voiceId) {
|
|||||||
return Buffer.from(buf).toString("base64"); // base64 MP3
|
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;
|
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() {
|
export async function fetchLocalDeviceVoices() {
|
||||||
if (_localVoicesCache) return _localVoicesCache;
|
if (_localVoicesCache) return _localVoicesCache;
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync("say", ["-v", "?"]);
|
const voices = process.platform === "win32"
|
||||||
const voices = [];
|
? await fetchLocalDeviceVoicesWin()
|
||||||
for (const line of stdout.split("\n")) {
|
: await fetchLocalDeviceVoicesMac();
|
||||||
// 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: "" });
|
|
||||||
}
|
|
||||||
_localVoicesCache = voices;
|
_localVoicesCache = voices;
|
||||||
return voices;
|
return voices;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export default function APIPageClient({ machineId }) {
|
|||||||
const [createdKey, setCreatedKey] = useState(null);
|
const [createdKey, setCreatedKey] = useState(null);
|
||||||
|
|
||||||
const [requireApiKey, setRequireApiKey] = useState(false);
|
const [requireApiKey, setRequireApiKey] = useState(false);
|
||||||
|
const [requireLogin, setRequireLogin] = useState(true);
|
||||||
|
const [hasPassword, setHasPassword] = useState(true);
|
||||||
|
const [tunnelDashboardAccess, setTunnelDashboardAccess] = useState(false);
|
||||||
|
|
||||||
// Cloudflare Tunnel state
|
// Cloudflare Tunnel state
|
||||||
const [tunnelChecking, setTunnelChecking] = useState(true);
|
const [tunnelChecking, setTunnelChecking] = useState(true);
|
||||||
@@ -74,6 +77,9 @@ export default function APIPageClient({ machineId }) {
|
|||||||
if (settingsRes.ok) {
|
if (settingsRes.ok) {
|
||||||
const data = await settingsRes.json();
|
const data = await settingsRes.json();
|
||||||
setRequireApiKey(data.requireApiKey || false);
|
setRequireApiKey(data.requireApiKey || false);
|
||||||
|
setRequireLogin(data.requireLogin !== false);
|
||||||
|
setHasPassword(data.hasPassword || false);
|
||||||
|
setTunnelDashboardAccess(data.tunnelDashboardAccess || false);
|
||||||
}
|
}
|
||||||
if (statusRes.ok) {
|
if (statusRes.ok) {
|
||||||
const data = await statusRes.json();
|
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) => {
|
const handleRequireApiKey = async (value) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/settings", {
|
const res = await fetch("/api/settings", {
|
||||||
@@ -688,10 +707,49 @@ export default function APIPageClient({ machineId }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Security warnings when tunnel or tailscale is active */}
|
||||||
|
{(tunnelEnabled || tsEnabled) && (
|
||||||
|
<div className="mt-4 flex flex-col gap-2">
|
||||||
|
{!requireApiKey && (
|
||||||
|
<SecurityWarning
|
||||||
|
message="Require API key is disabled — your endpoint is publicly accessible without authentication."
|
||||||
|
action={{ label: "Enable", href: "#require-api-key" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(!requireLogin || !hasPassword) && (
|
||||||
|
<SecurityWarning
|
||||||
|
message={
|
||||||
|
!requireLogin
|
||||||
|
? "Require login is disabled — anyone can access your dashboard via tunnel."
|
||||||
|
: "Dashboard uses the default password — change it in Profile settings."
|
||||||
|
}
|
||||||
|
action={{
|
||||||
|
label: !requireLogin ? "Enable" : "Change password",
|
||||||
|
href: "/dashboard/profile",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tunnel dashboard access option */}
|
||||||
|
{(tunnelEnabled || tsEnabled) && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-border flex items-center gap-3">
|
||||||
|
<Toggle
|
||||||
|
checked={tunnelDashboardAccess}
|
||||||
|
onChange={() => handleTunnelDashboardAccess(!tunnelDashboardAccess)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<p className="font-medium text-sm">Allow dashboard access via tunnel</p>
|
||||||
|
<Tooltip text="When enabled, the dashboard can be accessed through your tunnel or Tailscale URL without requiring login. Only enable if you trust everyone who can reach your tunnel URL." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* API Keys */}
|
{/* API Keys */}
|
||||||
<Card>
|
<Card id="require-api-key">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold">API Keys</h2>
|
<h2 className="text-lg font-semibold">API Keys</h2>
|
||||||
<Button icon="add" onClick={() => setShowAddModal(true)}>
|
<Button icon="add" onClick={() => setShowAddModal(true)}>
|
||||||
@@ -1063,6 +1121,40 @@ function StatusAlert({ status, className = "" }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Inline tooltip, Claude Code CLI style */
|
||||||
|
function Tooltip({ text }) {
|
||||||
|
return (
|
||||||
|
<span className="relative group inline-flex items-center">
|
||||||
|
<span className="material-symbols-outlined text-[14px] text-text-muted cursor-help">help</span>
|
||||||
|
<span className="pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 z-50 w-64 rounded bg-gray-900 dark:bg-gray-800 text-white text-xs px-2.5 py-1.5 opacity-0 group-hover:opacity-100 transition-opacity shadow-lg">
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Security warning banner with optional action link */
|
||||||
|
function SecurityWarning({ message, action }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20 text-amber-700 dark:text-amber-400">
|
||||||
|
<span className="material-symbols-outlined text-[16px] shrink-0 mt-0.5">warning</span>
|
||||||
|
<p className="text-xs flex-1">{message}</p>
|
||||||
|
{action && (
|
||||||
|
<a
|
||||||
|
href={action.href}
|
||||||
|
className="text-xs font-medium underline shrink-0 hover:opacity-80"
|
||||||
|
onClick={action.href.startsWith("#") ? (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById(action.href.slice(1))?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
} : undefined}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
APIPageClient.propTypes = {
|
APIPageClient.propTypes = {
|
||||||
machineId: PropTypes.string.isRequired,
|
machineId: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ function Row({ label, children }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TTS_RESPONSE_EXAMPLE = `// Audio will appear here after running.
|
||||||
|
// Example JSON response (response_format=json):
|
||||||
|
{
|
||||||
|
"format": "mp3",
|
||||||
|
"audio": "//NExAANaAIIAUAAANNNNNNNN..." // base64 encoded MP3
|
||||||
|
}`;
|
||||||
|
|
||||||
const DEFAULT_RESPONSE_EXAMPLE = `{
|
const DEFAULT_RESPONSE_EXAMPLE = `{
|
||||||
"object": "list",
|
"object": "list",
|
||||||
"data": [{
|
"data": [{
|
||||||
@@ -296,12 +303,15 @@ function TtsExampleCard({ providerId }) {
|
|||||||
const voiceKey = config.voiceKey || providerId;
|
const voiceKey = config.voiceKey || providerId;
|
||||||
const voices = getModelsByProviderId(voiceKey).filter((m) => m.type === "tts");
|
const voices = getModelsByProviderId(voiceKey).filter((m) => m.type === "tts");
|
||||||
if (voices.length) {
|
if (voices.length) {
|
||||||
if (config.hasLanguageDropdown) {
|
if (config.hasBrowseButton) {
|
||||||
// Google TTS: just set voice
|
// Google TTS: pre-select "en" (English) as default, show as single voice chip
|
||||||
setSelectedVoice(voices[0].id);
|
const defaultVoice = voices.find((v) => v.id === "en") || voices[0];
|
||||||
setSelectedVoiceName(voices[0].name || voices[0].id);
|
setSelectedLang(defaultVoice.id);
|
||||||
|
setSelectedVoice(defaultVoice.id);
|
||||||
|
setSelectedVoiceName(defaultVoice.name);
|
||||||
|
setCountryVoices([{ id: defaultVoice.id, name: defaultVoice.name }]);
|
||||||
} else {
|
} else {
|
||||||
// OpenAI: set voice chips
|
// OpenAI: set voice chips directly (no language picker)
|
||||||
setCountryVoices(voices);
|
setCountryVoices(voices);
|
||||||
setSelectedVoice(voices[0].id);
|
setSelectedVoice(voices[0].id);
|
||||||
setSelectedVoiceName(voices[0].name || voices[0].id);
|
setSelectedVoiceName(voices[0].name || voices[0].id);
|
||||||
@@ -319,15 +329,27 @@ function TtsExampleCard({ providerId }) {
|
|||||||
if (languages.length) return; // already loaded
|
if (languages.length) return; // already loaded
|
||||||
setModalLoading(true);
|
setModalLoading(true);
|
||||||
try {
|
try {
|
||||||
// Use provider-specific apiEndpoint if available, else default to edge-tts voices API
|
if (config.voiceSource === "hardcoded") {
|
||||||
const url = config.apiEndpoint
|
// Build languages/byLang from static providerModels data
|
||||||
? config.apiEndpoint
|
const voiceKey = config.voiceKey || providerId;
|
||||||
: `/api/media-providers/tts/voices?provider=${providerId === "local-device" ? "local-device" : "edge-tts"}`;
|
const voices = getModelsByProviderId(voiceKey).filter((m) => m.type === "tts");
|
||||||
const r = await fetch(url);
|
const byLangMap = {};
|
||||||
const d = await r.json();
|
for (const v of voices) {
|
||||||
if (d.error) { setModalError(d.error); return; }
|
if (!byLangMap[v.id]) byLangMap[v.id] = { code: v.id, name: v.name, voices: [{ id: v.id, name: v.name }] };
|
||||||
setLanguages(d.languages || []);
|
}
|
||||||
setByLang(d.byLang || {});
|
setByLang(byLangMap);
|
||||||
|
setLanguages(Object.values(byLangMap).sort((a, b) => a.name.localeCompare(b.name)));
|
||||||
|
} else {
|
||||||
|
// Use provider-specific apiEndpoint if available, else default to edge-tts voices API
|
||||||
|
const url = config.apiEndpoint
|
||||||
|
? config.apiEndpoint
|
||||||
|
: `/api/media-providers/tts/voices?provider=${providerId === "local-device" ? "local-device" : "edge-tts"}`;
|
||||||
|
const r = await fetch(url);
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.error) { setModalError(d.error); return; }
|
||||||
|
setLanguages(d.languages || []);
|
||||||
|
setByLang(d.byLang || {});
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setModalError(e.message);
|
setModalError(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -472,7 +494,7 @@ function TtsExampleCard({ providerId }) {
|
|||||||
className="flex items-center gap-1 text-xs px-2.5 py-1.5 rounded-lg border border-border text-text-muted hover:text-primary hover:border-primary/40 transition-colors shrink-0"
|
className="flex items-center gap-1 text-xs px-2.5 py-1.5 rounded-lg border border-border text-text-muted hover:text-primary hover:border-primary/40 transition-colors shrink-0"
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-[14px]">language</span>
|
<span className="material-symbols-outlined text-[14px]">language</span>
|
||||||
Browse
|
Select language
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
@@ -647,7 +669,10 @@ function TtsExampleCard({ providerId }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-text-muted opacity-60">Audio will appear here after running.</p>
|
<div>
|
||||||
|
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Response</span>
|
||||||
|
<pre className="mt-1.5 bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre opacity-50">{DEFAULT_TTS_RESPONSE_EXAMPLE}</pre>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ export async function GET() {
|
|||||||
try {
|
try {
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
const requireLogin = settings.requireLogin !== false;
|
const requireLogin = settings.requireLogin !== false;
|
||||||
return NextResponse.json({ requireLogin });
|
const tunnelDashboardAccess = settings.tunnelDashboardAccess === true;
|
||||||
|
return NextResponse.json({ requireLogin, tunnelDashboardAccess });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json({ requireLogin: true }, { status: 200 });
|
return NextResponse.json({ requireLogin: true }, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-12
@@ -69,29 +69,41 @@ export async function proxy(request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Protect all dashboard routes
|
// Protect all dashboard routes
|
||||||
if (pathname.startsWith("/dashboard")) {
|
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) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
await jwtVerify(token, SECRET);
|
await jwtVerify(token, SECRET);
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
} catch (err) {
|
} catch {
|
||||||
return NextResponse.redirect(new URL("/login", request.url));
|
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));
|
return NextResponse.redirect(new URL("/login", request.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ const defaultData = {
|
|||||||
comboStrategy: "fallback",
|
comboStrategy: "fallback",
|
||||||
comboStrategies: {},
|
comboStrategies: {},
|
||||||
requireLogin: true,
|
requireLogin: true,
|
||||||
|
tunnelDashboardAccess: true,
|
||||||
observabilityEnabled: true,
|
observabilityEnabled: true,
|
||||||
observabilityMaxRecords: 1000,
|
observabilityMaxRecords: 1000,
|
||||||
observabilityBatchSize: 20,
|
observabilityBatchSize: 20,
|
||||||
@@ -99,6 +100,7 @@ function cloneDefaultData() {
|
|||||||
comboStrategy: "fallback",
|
comboStrategy: "fallback",
|
||||||
comboStrategies: {},
|
comboStrategies: {},
|
||||||
requireLogin: true,
|
requireLogin: true,
|
||||||
|
tunnelDashboardAccess: true,
|
||||||
observabilityEnabled: true,
|
observabilityEnabled: true,
|
||||||
observabilityMaxRecords: 1000,
|
observabilityMaxRecords: 1000,
|
||||||
observabilityBatchSize: 20,
|
observabilityBatchSize: 20,
|
||||||
|
|||||||
+33
-12
@@ -2,7 +2,7 @@ import fs from "fs";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
import { execSync, spawn } from "child_process";
|
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";
|
import { saveTailscalePid, loadTailscalePid, clearTailscalePid } from "./state.js";
|
||||||
|
|
||||||
const BIN_DIR = path.join(os.homedir(), ".9router", "bin");
|
const BIN_DIR = path.join(os.homedir(), ".9router", "bin");
|
||||||
@@ -86,8 +86,11 @@ export function getTailscaleFunnelUrl(port) {
|
|||||||
*/
|
*/
|
||||||
export async function installTailscale(sudoPassword, hostname, onProgress) {
|
export async function installTailscale(sudoPassword, hostname, onProgress) {
|
||||||
const log = onProgress || (() => {});
|
const log = onProgress || (() => {});
|
||||||
if (IS_WINDOWS) await installTailscaleWindows(log);
|
if (IS_WINDOWS) {
|
||||||
else if (IS_MAC) await installTailscaleMac(sudoPassword, log);
|
await installTailscaleWindows(log);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (IS_MAC) await installTailscaleMac(sudoPassword, log);
|
||||||
else await installTailscaleLinux(sudoPassword, log);
|
else await installTailscaleLinux(sudoPassword, log);
|
||||||
|
|
||||||
log("Starting daemon...");
|
log("Starting daemon...");
|
||||||
@@ -215,18 +218,36 @@ async function installTailscaleLinux(sudoPassword, log) {
|
|||||||
async function installTailscaleWindows(log) {
|
async function installTailscaleWindows(log) {
|
||||||
const msiUrl = "https://pkgs.tailscale.com/stable/tailscale-setup-latest-amd64.msi";
|
const msiUrl = "https://pkgs.tailscale.com/stable/tailscale-setup-latest-amd64.msi";
|
||||||
const msiPath = path.join(os.tmpdir(), "tailscale-setup.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...");
|
log("Downloading Tailscale installer...");
|
||||||
const psScript = [
|
await new Promise((resolve, reject) => {
|
||||||
`Invoke-WebRequest -Uri '${msiUrl}' -OutFile '${msiPath}'`,
|
const child = spawn("powershell", [
|
||||||
`Start-Process msiexec.exe -ArgumentList '/i','${msiPath}','/quiet','/norestart' -Wait`,
|
"-NoProfile", "-NonInteractive", "-Command",
|
||||||
`Remove-Item '${msiPath}' -Force -ErrorAction SilentlyContinue`,
|
`Invoke-WebRequest -Uri '${msiUrl}' -OutFile '${msiPath}'`
|
||||||
].join("\n");
|
], { 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");
|
// Install MSI silently — Windows Installer handles UAC elevation automatically
|
||||||
log("Installing (UAC prompt may appear)...");
|
log("Installing Tailscale (UAC prompt may appear)...");
|
||||||
await executeElevatedPowerShell(psScriptPath, 120000);
|
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) */
|
/** Start tailscaled with sudo (TUN mode required for Funnel) */
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
*/
|
*/
|
||||||
export const TTS_PROVIDER_CONFIG = {
|
export const TTS_PROVIDER_CONFIG = {
|
||||||
"google-tts": {
|
"google-tts": {
|
||||||
hasLanguageDropdown: true,
|
hasLanguageDropdown: false,
|
||||||
hasModelSelector: false,
|
hasModelSelector: false,
|
||||||
hasBrowseButton: false,
|
hasBrowseButton: true,
|
||||||
voiceSource: "hardcoded", // from providerModels
|
voiceSource: "hardcoded", // languages built from providerModels at runtime
|
||||||
},
|
},
|
||||||
"openai": {
|
"openai": {
|
||||||
hasLanguageDropdown: false,
|
hasLanguageDropdown: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user