Compare commits
No commits in common. "bca107846e44c1f1b21be2f60e523033da6d86de" and "bee9361c1d3de8663ae71110669c9a94ed883a23" have entirely different histories.
bca107846e
...
bee9361c1d
31 changed files with 76 additions and 394 deletions
|
|
@ -103,7 +103,8 @@ Authenticate a user. Supports optional TOTP two-factor authentication. On succes
|
||||||
{
|
{
|
||||||
"email": "string",
|
"email": "string",
|
||||||
"password": "string",
|
"password": "string",
|
||||||
"totpCode": "string (optional, required if 2FA is enabled)"
|
"totpCode": "string (optional, required if 2FA is enabled)",
|
||||||
|
"turnstileToken": "string"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
- **Response:**
|
- **Response:**
|
||||||
|
|
|
||||||
|
|
@ -123,23 +123,9 @@ necessary UX tradeoff over perfect indistinguishability.
|
||||||
|
|
||||||
## Turnstile (Cloudflare bot protection)
|
## Turnstile (Cloudflare bot protection)
|
||||||
|
|
||||||
Applied to `/api/auth/register` and `/api/auth/forgot-password` when
|
Applied to `/api/auth/login`, `/register`, `/forgot-password` when
|
||||||
`TURNSTILE_SECRET_KEY` is set. No-op when unset (dev mode).
|
`TURNSTILE_SECRET_KEY` is set. No-op when unset (dev mode).
|
||||||
|
|
||||||
`/api/auth/login` is deliberately **not** gated: the widget could not
|
|
||||||
reliably complete a challenge inside the Capacitor WebView, which locked
|
|
||||||
mobile users out of the app. Login is covered instead by its per-IP rate
|
|
||||||
limit (10 / 15 min), the constant-time credential check, and TOTP 2FA.
|
|
||||||
|
|
||||||
The two remaining widgets are rendered explicitly (`api.js?render=explicit`)
|
|
||||||
the first time their form becomes visible — Turnstile does not reliably
|
|
||||||
complete a challenge inside a `display:none` container, and both forms start
|
|
||||||
hidden. Tokens are captured from the render callback, not read back out of
|
|
||||||
the injected `[name="cf-turnstile-response"]` input.
|
|
||||||
|
|
||||||
Note that the site key is currently **hardcoded** in `public/index.html`.
|
|
||||||
`TURNSTILE_SITE_KEY` exists in OpenBao but is not read by any code.
|
|
||||||
|
|
||||||
## Encryption at rest
|
## Encryption at rest
|
||||||
|
|
||||||
`src/utils/crypto.js` provides AES-256-GCM helpers. Key loaded from
|
`src/utils/crypto.js` provides AES-256-GCM helpers. Key loaded from
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,14 @@
|
||||||
// SESSION PERSISTENCE — full logout → login → still on the same
|
// SESSION PERSISTENCE — full logout → login → still on the same
|
||||||
// tab + same sub-pill.
|
// tab + same sub-pill.
|
||||||
//
|
//
|
||||||
// The test does a programmatic logout (clear the ped_auth cookie,
|
// The UI's login form is gated by a Cloudflare Turnstile token
|
||||||
// same effect server-side as clicking Logout) followed by a fresh
|
// whose site key is hardcoded in index.html, which can't be
|
||||||
// programmatic login. This exercises the same localStorage
|
// completed in the e2e container (Turnstile rejects the non-prod
|
||||||
// persistence path a real logout/login would.
|
// origin). So the test does a programmatic logout (clear the
|
||||||
//
|
// ped_auth cookie, same effect server-side as clicking Logout)
|
||||||
// (Historically this was a workaround for the Turnstile challenge on
|
// followed by a fresh programmatic login — this exercises the
|
||||||
// the login form, which could not be completed in the e2e container.
|
// same localStorage persistence path a real logout/login would,
|
||||||
// Login is no longer gated, but driving it programmatically keeps
|
// without depending on the bot challenge.
|
||||||
// the test focused on persistence rather than form mechanics.)
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
const { test, expect, E2E_BASE, loginAs } = require('../fixtures');
|
const { test, expect, E2E_BASE, loginAs } = require('../fixtures');
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ android {
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
// Version values below are overwritten by scripts/release.sh from
|
// Version values below are overwritten by scripts/release.sh from
|
||||||
// the root package.json. versionCode auto-increments per release.
|
// the root package.json. versionCode auto-increments per release.
|
||||||
versionCode 714015
|
versionCode 714014
|
||||||
versionName "7.14.15"
|
versionName "7.14.14"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import android.print.PrintDocumentAdapter;
|
||||||
import android.print.PrintManager;
|
import android.print.PrintManager;
|
||||||
import android.provider.MediaStore;
|
import android.provider.MediaStore;
|
||||||
import android.util.Base64;
|
import android.util.Base64;
|
||||||
import android.webkit.CookieManager;
|
|
||||||
import android.webkit.PermissionRequest;
|
import android.webkit.PermissionRequest;
|
||||||
import android.webkit.WebChromeClient;
|
import android.webkit.WebChromeClient;
|
||||||
import android.webkit.WebViewClient;
|
import android.webkit.WebViewClient;
|
||||||
|
|
@ -48,9 +47,6 @@ public class MainActivity extends BridgeActivity {
|
||||||
new String[]{ Manifest.permission.RECORD_AUDIO }, MIC_PERMISSION_CODE);
|
new String[]{ Manifest.permission.RECORD_AUDIO }, MIC_PERMISSION_CODE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow the Cloudflare Turnstile iframe to use storage.
|
|
||||||
setupThirdPartyCookies();
|
|
||||||
|
|
||||||
// Setup WebView mic permission granting
|
// Setup WebView mic permission granting
|
||||||
setupWebViewPermissions();
|
setupWebViewPermissions();
|
||||||
|
|
||||||
|
|
@ -64,26 +60,6 @@ public class MainActivity extends BridgeActivity {
|
||||||
setupFileBridge();
|
setupFileBridge();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Third-Party Cookies ────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Android WebView blocks third-party cookies by default (unlike Chrome,
|
|
||||||
// which still allows them for now). Cloudflare Turnstile runs inside a
|
|
||||||
// cross-origin iframe from challenges.cloudflare.com and needs its own
|
|
||||||
// storage to run and persist a challenge — without this the widget
|
|
||||||
// silently stalls or errors and never emits a token, so registration and
|
|
||||||
// password reset are impossible from inside the app.
|
|
||||||
//
|
|
||||||
// This is scoped to our own WebView, which only ever loads the PedScribe
|
|
||||||
// origin (see allowNavigation in capacitor.config.json), so it is not a
|
|
||||||
// general relaxation of the app's cookie policy.
|
|
||||||
|
|
||||||
private void setupThirdPartyCookies() {
|
|
||||||
WebView webView = this.bridge.getWebView();
|
|
||||||
CookieManager cookieManager = CookieManager.getInstance();
|
|
||||||
cookieManager.setAcceptCookie(true);
|
|
||||||
cookieManager.setAcceptThirdPartyCookies(webView, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── WebView Microphone Permission ──────────────────────────
|
// ── WebView Microphone Permission ──────────────────────────
|
||||||
|
|
||||||
private void setupWebViewPermissions() {
|
private void setupWebViewPermissions() {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<item name="windowActionBar">false</item>
|
<item name="windowActionBar">false</item>
|
||||||
<item name="windowNoTitle">true</item>
|
<item name="windowNoTitle">true</item>
|
||||||
<item name="android:background">@color/colorPrimary</item>
|
<item name="android:background">@null</item>
|
||||||
<item name="android:statusBarColor">@color/colorPrimaryDark</item>
|
<item name="android:statusBarColor">@color/colorPrimaryDark</item>
|
||||||
<item name="android:navigationBarColor">@color/colorPrimaryDark</item>
|
<item name="android:navigationBarColor">@color/colorPrimaryDark</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "pedscribe-mobile",
|
"name": "pedscribe-mobile",
|
||||||
"version": "7.14.15",
|
"version": "7.14.14",
|
||||||
"description": "PedScribe native mobile app — Capacitor wrapper for Pediatric AI Scribe",
|
"description": "PedScribe native mobile app — Capacitor wrapper for Pediatric AI Scribe",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "pediatric-ai-scribe",
|
"name": "pediatric-ai-scribe",
|
||||||
"version": "7.14.15",
|
"version": "7.14.14",
|
||||||
"description": "AI-powered pediatric clinical documentation platform",
|
"description": "AI-powered pediatric clinical documentation platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -11,9 +11,6 @@
|
||||||
<button class="wv-subtab-btn" data-subtab="milestones">
|
<button class="wv-subtab-btn" data-subtab="milestones">
|
||||||
<i class="fas fa-baby"></i> Milestones
|
<i class="fas fa-baby"></i> Milestones
|
||||||
</button>
|
</button>
|
||||||
<button class="wv-subtab-btn" data-subtab="lincoln">
|
|
||||||
<i class="fas fa-clipboard-list"></i> Lincoln
|
|
||||||
</button>
|
|
||||||
<button class="wv-subtab-btn" data-subtab="shadess" style="display:none;">
|
<button class="wv-subtab-btn" data-subtab="shadess" style="display:none;">
|
||||||
<i class="fas fa-brain"></i> SSHADESS (12+)
|
<i class="fas fa-brain"></i> SSHADESS (12+)
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -122,65 +119,6 @@
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Lincoln quick-reference sub-panel -->
|
|
||||||
<div id="wv-panel-lincoln" class="wv-subpanel hidden">
|
|
||||||
<div class="card" style="margin-bottom:10px;">
|
|
||||||
<div class="card-header output-header">
|
|
||||||
<h3><i class="fas fa-clipboard-list"></i> Lincoln Well-Child Quick Reference</h3>
|
|
||||||
<div class="output-actions">
|
|
||||||
<button class="btn-sm btn-primary" data-action="copy" data-target="wv-lincoln-reference"><i class="fas fa-copy"></i> Copy</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="wv-lincoln-reference" class="wv-lincoln-reference">
|
|
||||||
<div class="wv-lincoln-grid">
|
|
||||||
<section class="wv-lincoln-card">
|
|
||||||
<h4>Infancy</h4>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Newborn:</strong> POC visit; check for jaundice.</li>
|
|
||||||
<li><strong>2 weeks:</strong> weight gain, newborn screen, umbilicus check.</li>
|
|
||||||
<li><strong>1 month:</strong> maternal PHQ-9.</li>
|
|
||||||
<li><strong>2 months:</strong> Vaxelis, rotavirus, Prevnar.</li>
|
|
||||||
<li><strong>4 months:</strong> Vaxelis, rotavirus, Prevnar.</li>
|
|
||||||
<li><strong>6 months:</strong> routine vaccines and Prevnar; confirm rotavirus eligibility by product and age.</li>
|
|
||||||
<li><strong>9 months:</strong> SWYC; no routine vaccines noted.</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="wv-lincoln-card">
|
|
||||||
<h4>Toddler / Preschool</h4>
|
|
||||||
<ul>
|
|
||||||
<li><strong>12 months:</strong> MMR, varicella, Hep A; CBC and lead.</li>
|
|
||||||
<li><strong>15 months:</strong> Pentacel, Prevnar, influenza.</li>
|
|
||||||
<li><strong>18 months:</strong> POSI, SWYC; Hep A second dose.</li>
|
|
||||||
<li><strong>2 years:</strong> POSI/SWYC; CBC and lead.</li>
|
|
||||||
<li><strong>3 years:</strong> blood pressure check and vision screening; BP is commonly missed and can be added on diagnosis.</li>
|
|
||||||
<li><strong>4 years:</strong> hearing and vision start; ProQuad and Kinrix.</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="wv-lincoln-card">
|
|
||||||
<h4>School Age / Adolescence</h4>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Lipid screening:</strong> AAP screening at 9-11 years and 17-21 years.</li>
|
|
||||||
<li><strong>Depression screening:</strong> begin at 12 years and older.</li>
|
|
||||||
<li><strong>MenB:</strong> discuss Bexsero/MenB at 16-23 years, preferably 16-18 years, when chosen or indicated.</li>
|
|
||||||
<li><strong>Age ≥18 years:</strong> Hep C testing.</li>
|
|
||||||
<li><strong>Cervical cancer screening:</strong> start Pap smear screening at 21 years.</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="wv-lincoln-card">
|
|
||||||
<h4>Catch-Up / Screening Reminders</h4>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Influenza:</strong> if a child 6 months through 8 years needs 2 doses, give doses 4 weeks apart.</li>
|
|
||||||
<li><strong>Lead:</strong> continue lead screening reminders through age 6 years; add diagnosis when needed.</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SSHADESS sub-panel (age 12+) -->
|
<!-- SSHADESS sub-panel (age 12+) -->
|
||||||
<div id="wv-panel-shadess" class="wv-subpanel hidden">
|
<div id="wv-panel-shadess" class="wv-subpanel hidden">
|
||||||
<div class="card" style="margin-bottom:10px;">
|
<div class="card" style="margin-bottom:10px;">
|
||||||
|
|
@ -367,3 +305,4 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -397,21 +397,6 @@ textarea.full-input{resize:vertical;}
|
||||||
.wv-section-title{font-size:14px;font-weight:700;color:var(--g700);margin-bottom:12px;display:flex;align-items:center;gap:8px;}
|
.wv-section-title{font-size:14px;font-weight:700;color:var(--g700);margin-bottom:12px;display:flex;align-items:center;gap:8px;}
|
||||||
.wv-section-title i{color:var(--blue);}
|
.wv-section-title i{color:var(--blue);}
|
||||||
|
|
||||||
/* Lincoln quick reference */
|
|
||||||
.wv-lincoln-reference{padding:14px 16px;background:var(--g50);}
|
|
||||||
.wv-lincoln-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:14px;}
|
|
||||||
.wv-lincoln-card{border:1px solid var(--g100);border-radius:12px;padding:14px 16px;background:white;box-shadow:0 1px 2px rgba(15,23,42,0.04);}
|
|
||||||
.wv-lincoln-card h4{margin:0 0 10px;font-size:14px;color:var(--g800);}
|
|
||||||
.wv-lincoln-card ul{margin:0;padding-left:18px;color:var(--g700);font-size:13px;line-height:1.65;}
|
|
||||||
.wv-lincoln-card li{margin-bottom:6px;}
|
|
||||||
.wv-lincoln-card li:last-child{margin-bottom:0;}
|
|
||||||
@media(max-width:640px){
|
|
||||||
.wv-lincoln-reference{padding:10px;}
|
|
||||||
.wv-lincoln-grid{grid-template-columns:1fr;gap:10px;}
|
|
||||||
.wv-lincoln-card{padding:12px;}
|
|
||||||
.wv-lincoln-card ul{font-size:12.5px;line-height:1.55;}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Billing */
|
/* Billing */
|
||||||
.wv-billing-grid{display:flex;flex-wrap:wrap;gap:12px;align-items:center;}
|
.wv-billing-grid{display:flex;flex-wrap:wrap;gap:12px;align-items:center;}
|
||||||
.wv-billing-cell{display:flex;align-items:center;gap:8px;}
|
.wv-billing-cell{display:flex;align-items:center;gap:8px;}
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,7 @@
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"
|
||||||
integrity="sha384-/o6I2CkkWC//PSjvWC/eYN7l3xM3tJm8ZzVkCOfp//W05QcE3mlGskpoHB6XqI+B" crossorigin="anonymous" referrerpolicy="no-referrer">
|
integrity="sha384-/o6I2CkkWC//PSjvWC/eYN7l3xM3tJm8ZzVkCOfp//W05QcE3mlGskpoHB6XqI+B" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||||
<!-- Explicit render mode: the register/forgot widgets live inside forms that
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||||
start hidden, and Turnstile's implicit auto-render does not reliably
|
|
||||||
complete a challenge inside a display:none container. auth.js renders
|
|
||||||
each widget the first time its form is shown. -->
|
|
||||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.6/purify.min.js"
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.6/purify.min.js"
|
||||||
integrity="sha384-+VfUPEb0PdtChMwmBcBmykRMDd+v6D/oFmB3rZM/puCMDYcIvF968OimRh4KQY9a"
|
integrity="sha384-+VfUPEb0PdtChMwmBcBmykRMDd+v6D/oFmB3rZM/puCMDYcIvF968OimRh4KQY9a"
|
||||||
crossorigin="anonymous" referrerpolicy="no-referrer" defer></script>
|
crossorigin="anonymous" referrerpolicy="no-referrer" defer></script>
|
||||||
|
|
@ -74,6 +70,7 @@
|
||||||
<label>2FA Code</label>
|
<label>2FA Code</label>
|
||||||
<input type="text" id="login-totp" placeholder="6-digit code" maxlength="6">
|
<input type="text" id="login-totp" placeholder="6-digit code" maxlength="6">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cf-turnstile" id="turnstile-login" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div>
|
||||||
<button type="submit" class="btn-auth" id="btn-local-login">Sign In</button>
|
<button type="submit" class="btn-auth" id="btn-local-login">Sign In</button>
|
||||||
<div id="sso-divider" class="hidden" style="display:none;text-align:center;margin:16px 0 12px;position:relative;">
|
<div id="sso-divider" class="hidden" style="display:none;text-align:center;margin:16px 0 12px;position:relative;">
|
||||||
<span style="background:white;padding:0 12px;color:#9ca3af;font-size:12px;position:relative;z-index:1;">or</span>
|
<span style="background:white;padding:0 12px;color:#9ca3af;font-size:12px;position:relative;z-index:1;">or</span>
|
||||||
|
|
@ -107,7 +104,7 @@
|
||||||
<label>Password (8+ characters)</label>
|
<label>Password (8+ characters)</label>
|
||||||
<input type="password" id="reg-password" required minlength="8" placeholder="••••••••">
|
<input type="password" id="reg-password" required minlength="8" placeholder="••••••••">
|
||||||
</div>
|
</div>
|
||||||
<div id="turnstile-register" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6"></div>
|
<div class="cf-turnstile" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div>
|
||||||
<button type="submit" class="btn-auth">Create Account</button>
|
<button type="submit" class="btn-auth">Create Account</button>
|
||||||
<div class="auth-links">
|
<div class="auth-links">
|
||||||
<a href="#" id="show-login">Back to sign in</a>
|
<a href="#" id="show-login">Back to sign in</a>
|
||||||
|
|
@ -121,7 +118,7 @@
|
||||||
<label>Email</label>
|
<label>Email</label>
|
||||||
<input type="email" id="forgot-email" required placeholder="your@email.com">
|
<input type="email" id="forgot-email" required placeholder="your@email.com">
|
||||||
</div>
|
</div>
|
||||||
<div id="turnstile-forgot" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6"></div>
|
<div class="cf-turnstile" id="turnstile-forgot" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div>
|
||||||
<button type="submit" class="btn-auth">Send Reset Link</button>
|
<button type="submit" class="btn-auth">Send Reset Link</button>
|
||||||
<div class="auth-links">
|
<div class="auth-links">
|
||||||
<a href="#" id="show-login-2">Back to sign in</a>
|
<a href="#" id="show-login-2">Back to sign in</a>
|
||||||
|
|
|
||||||
|
|
@ -214,71 +214,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- CLOUDFLARE TURNSTILE ----
|
|
||||||
// Gates registration and password reset. Login is deliberately NOT gated:
|
|
||||||
// it is already covered by a 10-per-15-min rate limit and a constant-time
|
|
||||||
// credential check, and the widget is unreliable inside the Capacitor
|
|
||||||
// WebView — which locked mobile users out of the app entirely.
|
|
||||||
//
|
|
||||||
// Tokens are captured from the render callback rather than read back out of
|
|
||||||
// the injected [name="cf-turnstile-response"] input. That lookup is easy to
|
|
||||||
// leave unscoped, which is exactly how the register form ended up
|
|
||||||
// submitting the login widget's token (single-use, 5-minute expiry).
|
|
||||||
//
|
|
||||||
// Rendering is explicit and deferred until the owning form is visible:
|
|
||||||
// both widgets live in forms that start at display:none, and Turnstile does
|
|
||||||
// not reliably complete a challenge inside a hidden container.
|
|
||||||
var turnstileWidgets = {
|
|
||||||
register: { el: 'turnstile-register', id: null, token: '', pending: false },
|
|
||||||
forgot: { el: 'turnstile-forgot', id: null, token: '', pending: false }
|
|
||||||
};
|
|
||||||
var turnstileReady = false;
|
|
||||||
|
|
||||||
// api.js?render=explicit invokes this once the Turnstile API is available.
|
|
||||||
window.onloadTurnstileCallback = function() {
|
|
||||||
turnstileReady = true;
|
|
||||||
Object.keys(turnstileWidgets).forEach(function(name) {
|
|
||||||
// Catch up on any form shown before the script finished loading.
|
|
||||||
if (turnstileWidgets[name].pending) renderTurnstile(name);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
function renderTurnstile(name) {
|
|
||||||
var w = turnstileWidgets[name];
|
|
||||||
if (!w || w.id !== null) return; // already rendered
|
|
||||||
var el = document.getElementById(w.el);
|
|
||||||
if (!el) return;
|
|
||||||
if (!turnstileReady || !window.turnstile) { w.pending = true; return; }
|
|
||||||
w.pending = false;
|
|
||||||
w.id = window.turnstile.render(el, {
|
|
||||||
sitekey: el.getAttribute('data-sitekey'),
|
|
||||||
theme: 'light',
|
|
||||||
callback: function(token) { w.token = token; },
|
|
||||||
'expired-callback': function() { w.token = ''; },
|
|
||||||
'timeout-callback': function() { w.token = ''; },
|
|
||||||
// Without this a widget failure is silent and the user only ever sees
|
|
||||||
// the generic "complete the verification" toast with no way to tell
|
|
||||||
// whether the challenge failed, expired, or never loaded at all.
|
|
||||||
'error-callback': function(code) {
|
|
||||||
w.token = '';
|
|
||||||
console.error('[Auth] Turnstile error on ' + name + ' widget:', code);
|
|
||||||
showToast('Verification unavailable (' + (code || 'error') + '). Check your connection and try again.', 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function turnstileToken(name) {
|
|
||||||
var w = turnstileWidgets[name];
|
|
||||||
return w ? w.token : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetTurnstile(name) {
|
|
||||||
var w = turnstileWidgets[name];
|
|
||||||
if (!w) return;
|
|
||||||
w.token = '';
|
|
||||||
if (w.id !== null && window.turnstile) window.turnstile.reset(w.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- HELPER FUNCTIONS ----
|
// ---- HELPER FUNCTIONS ----
|
||||||
|
|
||||||
function showLoginForm() {
|
function showLoginForm() {
|
||||||
|
|
@ -291,14 +226,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
if (loginForm) loginForm.style.display = 'none';
|
if (loginForm) loginForm.style.display = 'none';
|
||||||
if (registerForm) registerForm.style.display = 'block';
|
if (registerForm) registerForm.style.display = 'block';
|
||||||
if (forgotForm) forgotForm.style.display = 'none';
|
if (forgotForm) forgotForm.style.display = 'none';
|
||||||
renderTurnstile('register');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showForgotForm() {
|
function showForgotForm() {
|
||||||
if (loginForm) loginForm.style.display = 'none';
|
if (loginForm) loginForm.style.display = 'none';
|
||||||
if (registerForm) registerForm.style.display = 'none';
|
if (registerForm) registerForm.style.display = 'none';
|
||||||
if (forgotForm) forgotForm.style.display = 'block';
|
if (forgotForm) forgotForm.style.display = 'block';
|
||||||
renderTurnstile('forgot');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function enterApp(user, token) {
|
function enterApp(user, token) {
|
||||||
|
|
@ -594,7 +527,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
// ---- BIOMETRIC LOGIN BUTTON ----
|
// ---- BIOMETRIC LOGIN BUTTON ----
|
||||||
// Reads the email + password from the OS-secured keychain (gated behind
|
// Reads the email + password from the OS-secured keychain (gated behind
|
||||||
// Face ID / Touch ID / fingerprint) and fills the login form. Submits the
|
// Face ID / Touch ID / fingerprint) and fills the login form. Submits the
|
||||||
// form so all the existing flow (2FA prompt, error handling,
|
// form so all the existing flow (turnstile, 2FA prompt, error handling,
|
||||||
// session storage) runs unchanged. If biometric verification fails, the
|
// session storage) runs unchanged. If biometric verification fails, the
|
||||||
// user just gets a toast and falls through to typing the password.
|
// user just gets a toast and falls through to typing the password.
|
||||||
var bioBtn = document.getElementById('btn-bio-login');
|
var bioBtn = document.getElementById('btn-bio-login');
|
||||||
|
|
@ -611,7 +544,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
if (emailEl) emailEl.value = creds.username;
|
if (emailEl) emailEl.value = creds.username;
|
||||||
if (pwEl) pwEl.value = creds.password;
|
if (pwEl) pwEl.value = creds.password;
|
||||||
// Trigger the same submit path as the password form so all the
|
// Trigger the same submit path as the password form so all the
|
||||||
// existing handling (2FA, session storage, etc.) runs unchanged.
|
// existing handling (turnstile token, 2FA, session storage, etc.)
|
||||||
|
// runs unchanged. If turnstile hasn't auto-solved yet the form
|
||||||
|
// will toast "Please complete the verification" — same as a
|
||||||
|
// manual login attempt before turnstile resolves.
|
||||||
if (loginForm && typeof loginForm.requestSubmit === 'function') loginForm.requestSubmit();
|
if (loginForm && typeof loginForm.requestSubmit === 'function') loginForm.requestSubmit();
|
||||||
else if (loginForm) loginForm.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
else if (loginForm) loginForm.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
||||||
})
|
})
|
||||||
|
|
@ -641,9 +577,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cloudflare Turnstile
|
||||||
|
var loginTurnstile = document.querySelector('#login-form [name="cf-turnstile-response"]');
|
||||||
|
var loginToken = loginTurnstile ? loginTurnstile.value : '';
|
||||||
|
if (!loginToken) {
|
||||||
|
showToast('Please complete the verification', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
showLoading('Signing in...');
|
showLoading('Signing in...');
|
||||||
|
|
||||||
var body = { email: email, password: password };
|
var body = { email: email, password: password, turnstileToken: loginToken };
|
||||||
if (totpCode) body.totpCode = totpCode;
|
if (totpCode) body.totpCode = totpCode;
|
||||||
|
|
||||||
fetch('/api/auth/login', {
|
fetch('/api/auth/login', {
|
||||||
|
|
@ -693,12 +637,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error || 'Login failed', 'error');
|
showToast(data.error || 'Login failed', 'error');
|
||||||
|
if (window.turnstile) turnstile.reset('#turnstile-login');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) {
|
||||||
hideLoading();
|
hideLoading();
|
||||||
console.error('[Auth] Login error:', err);
|
console.error('[Auth] Login error:', err);
|
||||||
showToast('Connection error', 'error');
|
showToast('Connection error', 'error');
|
||||||
|
if (window.turnstile) turnstile.reset('#turnstile-login');
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -757,8 +703,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cloudflare Turnstile verification
|
// Cloudflare Turnstile verification
|
||||||
var regToken = turnstileToken('register');
|
var turnstileResponse = document.querySelector('[name="cf-turnstile-response"]');
|
||||||
if (!regToken) {
|
var turnstileToken = turnstileResponse ? turnstileResponse.value : '';
|
||||||
|
if (!turnstileToken) {
|
||||||
showToast('Please complete the verification challenge', 'error');
|
showToast('Please complete the verification challenge', 'error');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -768,7 +715,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
fetch('/api/auth/register', {
|
fetch('/api/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name: name, email: email, password: password, turnstileToken: regToken })
|
body: JSON.stringify({ name: name, email: email, password: password, turnstileToken: turnstileToken })
|
||||||
})
|
})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
|
|
@ -782,20 +729,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
showToast(data.message || 'Account created!', 'success');
|
showToast(data.message || 'Account created!', 'success');
|
||||||
} else if (data.success && data.needsVerification) {
|
} else if (data.success && data.needsVerification) {
|
||||||
showToast(data.message || 'Check email to verify', 'success');
|
showToast(data.message || 'Check email to verify', 'success');
|
||||||
// The token was just consumed server-side — clear it so coming back
|
|
||||||
// to this form doesn't resubmit a spent one.
|
|
||||||
resetTurnstile('register');
|
|
||||||
showLoginForm();
|
showLoginForm();
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error || 'Registration failed', 'error');
|
showToast(data.error || 'Registration failed', 'error');
|
||||||
resetTurnstile('register');
|
if (window.turnstile) turnstile.reset();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) {
|
||||||
hideLoading();
|
hideLoading();
|
||||||
console.error('[Auth] Register error:', err);
|
console.error('[Auth] Register error:', err);
|
||||||
showToast('Connection error', 'error');
|
showToast('Connection error', 'error');
|
||||||
resetTurnstile('register');
|
if (window.turnstile) turnstile.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -812,7 +756,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
if (!email) { showToast('Enter email', 'error'); return false; }
|
if (!email) { showToast('Enter email', 'error'); return false; }
|
||||||
|
|
||||||
// Cloudflare Turnstile
|
// Cloudflare Turnstile
|
||||||
var forgotToken = turnstileToken('forgot');
|
var forgotTurnstile = document.querySelector('#forgot-form [name="cf-turnstile-response"]');
|
||||||
|
var forgotToken = forgotTurnstile ? forgotTurnstile.value : '';
|
||||||
if (!forgotToken) {
|
if (!forgotToken) {
|
||||||
showToast('Please complete the verification', 'error');
|
showToast('Please complete the verification', 'error');
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -829,13 +774,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
hideLoading();
|
hideLoading();
|
||||||
showToast(data.message || 'Check your email', 'success');
|
showToast(data.message || 'Check your email', 'success');
|
||||||
resetTurnstile('forgot');
|
|
||||||
showLoginForm();
|
showLoginForm();
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) {
|
||||||
hideLoading();
|
hideLoading();
|
||||||
showToast('Error', 'error');
|
showToast('Error', 'error');
|
||||||
resetTurnstile('forgot');
|
if (window.turnstile) turnstile.reset('#turnstile-forgot');
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ var { authMiddleware, adminMiddleware } = require('../middleware/auth');
|
||||||
var PROMPTS = require('../utils/prompts');
|
var PROMPTS = require('../utils/prompts');
|
||||||
var logger = require('../utils/logger');
|
var logger = require('../utils/logger');
|
||||||
var { gatewayUrl } = require('../utils/errors');
|
var { gatewayUrl } = require('../utils/errors');
|
||||||
var { getTTSEnvProvider, getLiteLLMTTSDiscoveryItems, getLiteLLMTTSRequestOptions, getLiteLLMTTSVoicesForModel, isLiteLLMTTSVoiceCompatible, getTTSProvider } = require('../utils/ttsProvider');
|
var { getTTSEnvProvider, getLiteLLMTTSDiscoveryItems, getTTSProvider, getTTSVoiceLists } = require('../utils/ttsProvider');
|
||||||
var { getLiteLLMHeaders, getLiteLLMAdminHeaders } = require('../utils/litellm');
|
var { getLiteLLMHeaders, getLiteLLMAdminHeaders } = require('../utils/litellm');
|
||||||
var { getSTTDependencies, getLiteLLMSTTModels, getSTTModelLists, getSTTProvider } = require('../utils/sttProvider');
|
var { getSTTDependencies, getLiteLLMSTTModels, getSTTModelLists, getSTTProvider } = require('../utils/sttProvider');
|
||||||
var { getLiteLLMEmbeddingModels } = require('../utils/embeddings');
|
var { getLiteLLMEmbeddingModels } = require('../utils/embeddings');
|
||||||
|
|
@ -550,17 +550,12 @@ router.get('/config/tts', async function(req, res) {
|
||||||
var dbModel = await db.getSetting('tts.model') || '';
|
var dbModel = await db.getSetting('tts.model') || '';
|
||||||
var envVoice = process.env.LITELLM_TTS_VOICE || '';
|
var envVoice = process.env.LITELLM_TTS_VOICE || '';
|
||||||
var envModel = process.env.LITELLM_TTS_MODEL || '';
|
var envModel = process.env.LITELLM_TTS_MODEL || '';
|
||||||
var currentModel = dbModel || envModel;
|
|
||||||
var voices = getLiteLLMTTSVoicesForModel(currentModel, { currentVoice: dbVoice });
|
|
||||||
var currentVoice = [dbVoice, envVoice, voices[0]].find(function(voice) {
|
|
||||||
return isLiteLLMTTSVoiceCompatible(currentModel, voice);
|
|
||||||
}) || '';
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
provider: activeProvider,
|
provider: activeProvider,
|
||||||
envProvider: envProvider,
|
envProvider: envProvider,
|
||||||
currentVoice: currentVoice,
|
currentVoice: dbVoice || envVoice,
|
||||||
currentModel: currentModel,
|
currentModel: dbModel || envModel,
|
||||||
dbVoice: dbVoice,
|
dbVoice: dbVoice,
|
||||||
dbModel: dbModel,
|
dbModel: dbModel,
|
||||||
envVoice: envVoice,
|
envVoice: envVoice,
|
||||||
|
|
@ -568,9 +563,7 @@ router.get('/config/tts', async function(req, res) {
|
||||||
configured: {
|
configured: {
|
||||||
litellm: !!process.env.LITELLM_API_BASE
|
litellm: !!process.env.LITELLM_API_BASE
|
||||||
},
|
},
|
||||||
voices: {
|
voices: getTTSVoiceLists()
|
||||||
litellm: voices
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
|
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
|
||||||
});
|
});
|
||||||
|
|
@ -623,15 +616,11 @@ router.post('/config/tts/test', async function(req, res) {
|
||||||
var adminModel = await db.getSetting('tts.model') || '';
|
var adminModel = await db.getSetting('tts.model') || '';
|
||||||
var adminVoice = await db.getSetting('tts.voice') || '';
|
var adminVoice = await db.getSetting('tts.voice') || '';
|
||||||
var ttsModel = adminModel || process.env.LITELLM_TTS_MODEL || '';
|
var ttsModel = adminModel || process.env.LITELLM_TTS_MODEL || '';
|
||||||
var defaultVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: adminVoice });
|
var usedVoice = voice || adminVoice || process.env.LITELLM_TTS_VOICE || '';
|
||||||
var usedVoice = [voice, adminVoice, process.env.LITELLM_TTS_VOICE || '', defaultVoices[0]].find(function(candidate) {
|
|
||||||
return isLiteLLMTTSVoiceCompatible(ttsModel, candidate);
|
|
||||||
}) || '';
|
|
||||||
if (!ttsModel) return res.json({ success: false, error: 'No LiteLLM TTS model configured' });
|
if (!ttsModel) return res.json({ success: false, error: 'No LiteLLM TTS model configured' });
|
||||||
var payload = Object.assign({ model: ttsModel, voice: usedVoice, input: text }, getLiteLLMTTSRequestOptions(ttsModel));
|
|
||||||
|
|
||||||
var ttsResp = await axios.post(gatewayUrl('/audio/speech'),
|
var ttsResp = await axios.post(gatewayUrl('/audio/speech'),
|
||||||
payload,
|
{ model: ttsModel, voice: usedVoice, input: text },
|
||||||
{ headers: getLiteLLMHeaders('application/json'), responseType: 'arraybuffer', timeout: 60000 }
|
{ headers: getLiteLLMHeaders('application/json'), responseType: 'arraybuffer', timeout: 60000 }
|
||||||
);
|
);
|
||||||
var buffer = Buffer.from(ttsResp.data);
|
var buffer = Buffer.from(ttsResp.data);
|
||||||
|
|
|
||||||
|
|
@ -299,15 +299,19 @@ router.post('/resend-verification', async (req, res) => {
|
||||||
// ============================================================
|
// ============================================================
|
||||||
router.post('/login', async (req, res) => {
|
router.post('/login', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
var { email, password, totpCode } = req.body;
|
var { email, password, totpCode, turnstileToken } = req.body;
|
||||||
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
|
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
|
||||||
|
|
||||||
// No Turnstile on login. The widget could not reliably complete a
|
// Cloudflare Turnstile verification
|
||||||
// challenge inside the Capacitor WebView, which locked mobile users out.
|
if (process.env.TURNSTILE_SECRET_KEY) {
|
||||||
// Brute-force cover here comes from the 10-per-15-min per-IP rate limit
|
if (!turnstileToken) return res.status(400).json({ error: 'Please complete the verification' });
|
||||||
// (server.js), the constant-time bcrypt comparison below, and TOTP 2FA.
|
var tsRes = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
|
||||||
// Registration and password reset — the endpoints that actually attract
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
// bots — are still gated.
|
body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: turnstileToken, remoteip: req.ip })
|
||||||
|
});
|
||||||
|
var tsData = await tsRes.json();
|
||||||
|
if (!tsData.success) return res.status(400).json({ error: 'Verification failed. Please try again.' });
|
||||||
|
}
|
||||||
|
|
||||||
var user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
|
var user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||||
// Enumeration-resistant: always run bcrypt to keep timing constant, and return
|
// Enumeration-resistant: always run bcrypt to keep timing constant, and return
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ const router = express.Router();
|
||||||
const { authMiddleware } = require('../middleware/auth');
|
const { authMiddleware } = require('../middleware/auth');
|
||||||
var logger = require('../utils/logger');
|
var logger = require('../utils/logger');
|
||||||
var { gatewayUrl } = require('../utils/errors');
|
var { gatewayUrl } = require('../utils/errors');
|
||||||
var { getLiteLLMTTSRequestOptions, getLiteLLMTTSVoicesForModel, isLiteLLMTTSVoiceCompatible, getTTSProvider } = require('../utils/ttsProvider');
|
var { getTTSProvider } = require('../utils/ttsProvider');
|
||||||
var { getLiteLLMHeaders } = require('../utils/litellm');
|
var { getLiteLLMHeaders } = require('../utils/litellm');
|
||||||
|
|
||||||
// TTS is intentionally routed only through LiteLLM. Provider-specific voice
|
// TTS is intentionally routed only through LiteLLM. Provider-specific voice
|
||||||
|
|
@ -22,22 +22,19 @@ router.post('/text-to-speech', authMiddleware, async (req, res) => {
|
||||||
var userVoice = userPrefs?.tts_voice;
|
var userVoice = userPrefs?.tts_voice;
|
||||||
var adminVoice = await db.getSetting('tts.voice') || '';
|
var adminVoice = await db.getSetting('tts.voice') || '';
|
||||||
var adminModel = await db.getSetting('tts.model') || '';
|
var adminModel = await db.getSetting('tts.model') || '';
|
||||||
|
var resolvedVoice = userVoice || adminVoice || '';
|
||||||
|
|
||||||
if (ttsProvider !== 'litellm' || !process.env.LITELLM_API_BASE) {
|
if (ttsProvider !== 'litellm' || !process.env.LITELLM_API_BASE) {
|
||||||
return res.status(400).json({ error: 'TTS not configured. Set LITELLM_API_BASE.' });
|
return res.status(400).json({ error: 'TTS not configured. Set LITELLM_API_BASE.' });
|
||||||
}
|
}
|
||||||
var ttsModel = adminModel || process.env.LITELLM_TTS_MODEL || '';
|
var ttsModel = adminModel || process.env.LITELLM_TTS_MODEL || '';
|
||||||
var defaultVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: adminVoice });
|
var ttsVoice = resolvedVoice || process.env.LITELLM_TTS_VOICE || '';
|
||||||
var ttsVoice = [userVoice, adminVoice, process.env.LITELLM_TTS_VOICE || '', defaultVoices[0]].find(function(voice) {
|
|
||||||
return isLiteLLMTTSVoiceCompatible(ttsModel, voice);
|
|
||||||
}) || '';
|
|
||||||
if (!ttsModel) return res.status(400).json({ error: 'No LiteLLM TTS model configured.' });
|
if (!ttsModel) return res.status(400).json({ error: 'No LiteLLM TTS model configured.' });
|
||||||
var payload = Object.assign({ model: ttsModel, input: text, voice: ttsVoice }, getLiteLLMTTSRequestOptions(ttsModel));
|
|
||||||
|
|
||||||
var ttsResp = await fetch(gatewayUrl('/audio/speech'), {
|
var ttsResp = await fetch(gatewayUrl('/audio/speech'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getLiteLLMHeaders('application/json'),
|
headers: getLiteLLMHeaders('application/json'),
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify({ model: ttsModel, input: text, voice: ttsVoice })
|
||||||
});
|
});
|
||||||
if (!ttsResp.ok) {
|
if (!ttsResp.ok) {
|
||||||
var errBody = await ttsResp.text();
|
var errBody = await ttsResp.text();
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ var router = express.Router();
|
||||||
var db = require('../db/database');
|
var db = require('../db/database');
|
||||||
var { authMiddleware } = require('../middleware/auth');
|
var { authMiddleware } = require('../middleware/auth');
|
||||||
var { getSTTModelLists, getSTTProvider } = require('../utils/sttProvider');
|
var { getSTTModelLists, getSTTProvider } = require('../utils/sttProvider');
|
||||||
var { getLiteLLMTTSVoicesForModel, getTTSProvider } = require('../utils/ttsProvider');
|
var { getTTSProvider, getTTSVoiceLists } = require('../utils/ttsProvider');
|
||||||
|
|
||||||
router.use(authMiddleware);
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
|
@ -49,18 +49,14 @@ router.get('/preferences/options', async function(req, res) {
|
||||||
var provider = getSTTProvider();
|
var provider = getSTTProvider();
|
||||||
var ttsProvider = getTTSProvider();
|
var ttsProvider = getTTSProvider();
|
||||||
|
|
||||||
var dbModel = await db.getSetting('tts.model') || '';
|
|
||||||
var dbVoice = await db.getSetting('tts.voice') || '';
|
|
||||||
var ttsModel = dbModel || process.env.LITELLM_TTS_MODEL || '';
|
|
||||||
var sttModels = getSTTModelLists().litellm.map(function(model) { return { value: model, label: model }; });
|
var sttModels = getSTTModelLists().litellm.map(function(model) { return { value: model, label: model }; });
|
||||||
var ttsVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: dbVoice }).map(function(voice) { return { value: voice, label: voice }; });
|
var ttsVoices = getTTSVoiceLists().litellm.map(function(voice) { return { value: voice, label: voice }; });
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
sttProvider: provider || 'none',
|
sttProvider: provider || 'none',
|
||||||
sttModels: sttModels,
|
sttModels: sttModels,
|
||||||
ttsProvider: ttsProvider || 'browser',
|
ttsProvider: ttsProvider || 'browser',
|
||||||
ttsModel: ttsModel || null,
|
|
||||||
ttsVoices: ttsVoices
|
ttsVoices: ttsVoices
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,20 +7,6 @@ function parseList(value) {
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
var KITTEN_TTS_VOICES = ['Bella', 'Jasper', 'Luna', 'Bruno', 'Rosie', 'Hugo', 'Kiki', 'Leo'];
|
|
||||||
var SUPERTONIC_TTS_VOICES = ['F1', 'F2', 'F3', 'F4', 'F5', 'M1', 'M2', 'M3', 'M4', 'M5'];
|
|
||||||
var GROQ_ORPHEUS_ENGLISH_VOICES = ['autumn', 'diana', 'hannah', 'austin', 'daniel', 'troy'];
|
|
||||||
var GROQ_ORPHEUS_ARABIC_VOICES = ['abdullah', 'fahad', 'sultan', 'lulwa', 'noura', 'aisha'];
|
|
||||||
|
|
||||||
function uniqueList(values) {
|
|
||||||
var seen = new Set();
|
|
||||||
return (values || []).filter(function(value) {
|
|
||||||
if (!value || seen.has(value)) return false;
|
|
||||||
seen.add(value);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTTSProvider() {
|
function getTTSProvider() {
|
||||||
var env = process.env.TTS_PROVIDER;
|
var env = process.env.TTS_PROVIDER;
|
||||||
if (env === 'litellm') return 'litellm';
|
if (env === 'litellm') return 'litellm';
|
||||||
|
|
@ -34,58 +20,10 @@ function getTTSEnvProvider() {
|
||||||
|
|
||||||
function getTTSVoiceLists() {
|
function getTTSVoiceLists() {
|
||||||
return {
|
return {
|
||||||
litellm: uniqueList(parseList(process.env.LITELLM_TTS_VOICES).concat(KITTEN_TTS_VOICES, SUPERTONIC_TTS_VOICES, GROQ_ORPHEUS_ENGLISH_VOICES, GROQ_ORPHEUS_ARABIC_VOICES))
|
litellm: parseList(process.env.LITELLM_TTS_VOICES)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLiteLLMTTSModelFamily(model) {
|
|
||||||
var id = String(model || '').toLowerCase();
|
|
||||||
if (id === 'local-kitten-tts') return 'kitten';
|
|
||||||
if (id === 'local-supertonic-tts') return 'supertonic';
|
|
||||||
if (id === 'local-kokoro-tts') return 'kokoro';
|
|
||||||
if (id === 'groq-orpheus-english' || id === 'canopylabs/orpheus-v1-english') return 'groq-orpheus-english';
|
|
||||||
if (id === 'groq-orpheus-arabic-saudi' || id === 'canopylabs/orpheus-arabic-saudi') return 'groq-orpheus-arabic';
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLiteLLMTTSRequestOptions(model) {
|
|
||||||
var family = getLiteLLMTTSModelFamily(model);
|
|
||||||
if (family === 'groq-orpheus-english' || family === 'groq-orpheus-arabic') {
|
|
||||||
return { response_format: 'wav' };
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLiteLLMTTSVoiceCompatible(model, voice) {
|
|
||||||
if (!voice) return true;
|
|
||||||
var family = getLiteLLMTTSModelFamily(model);
|
|
||||||
if (family === 'kitten') return KITTEN_TTS_VOICES.indexOf(voice) !== -1;
|
|
||||||
if (family === 'supertonic') return SUPERTONIC_TTS_VOICES.indexOf(voice) !== -1;
|
|
||||||
if (family === 'groq-orpheus-english') return GROQ_ORPHEUS_ENGLISH_VOICES.indexOf(String(voice).toLowerCase()) !== -1;
|
|
||||||
if (family === 'groq-orpheus-arabic') return GROQ_ORPHEUS_ARABIC_VOICES.indexOf(String(voice).toLowerCase()) !== -1;
|
|
||||||
if (family === 'kokoro') {
|
|
||||||
return KITTEN_TTS_VOICES.indexOf(voice) === -1 && SUPERTONIC_TTS_VOICES.indexOf(voice) === -1 &&
|
|
||||||
GROQ_ORPHEUS_ENGLISH_VOICES.indexOf(String(voice).toLowerCase()) === -1 && GROQ_ORPHEUS_ARABIC_VOICES.indexOf(String(voice).toLowerCase()) === -1;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLiteLLMTTSVoicesForModel(model, opts) {
|
|
||||||
opts = opts || {};
|
|
||||||
var family = getLiteLLMTTSModelFamily(model);
|
|
||||||
var voices = [];
|
|
||||||
if (family === 'kitten') voices = KITTEN_TTS_VOICES.slice();
|
|
||||||
else if (family === 'supertonic') voices = SUPERTONIC_TTS_VOICES.slice();
|
|
||||||
else if (family === 'groq-orpheus-english') voices = GROQ_ORPHEUS_ENGLISH_VOICES.slice();
|
|
||||||
else if (family === 'groq-orpheus-arabic') voices = GROQ_ORPHEUS_ARABIC_VOICES.slice();
|
|
||||||
else voices = parseList(process.env.LITELLM_TTS_VOICES);
|
|
||||||
|
|
||||||
[opts.currentVoice, process.env.LITELLM_TTS_VOICE].forEach(function(voice) {
|
|
||||||
if (isLiteLLMTTSVoiceCompatible(model, voice)) voices.push(voice);
|
|
||||||
});
|
|
||||||
return uniqueList(voices.filter(function(voice) { return isLiteLLMTTSVoiceCompatible(model, voice); }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLiteLLMTTSModel(model) {
|
function isLiteLLMTTSModel(model) {
|
||||||
var mode = model && model.model_info && model.model_info.mode ? String(model.model_info.mode) : '';
|
var mode = model && model.model_info && model.model_info.mode ? String(model.model_info.mode) : '';
|
||||||
return mode === 'audio_speech';
|
return mode === 'audio_speech';
|
||||||
|
|
@ -118,26 +56,6 @@ function getLiteLLMTTSDiscoveryItems(models, opts) {
|
||||||
getTTSVoiceLists().litellm.forEach(function(voice) {
|
getTTSVoiceLists().litellm.forEach(function(voice) {
|
||||||
pushUniqueTTSItem(items, { id: voice, name: voice, source: 'configured-voice-list', kind: 'voice' });
|
pushUniqueTTSItem(items, { id: voice, name: voice, source: 'configured-voice-list', kind: 'voice' });
|
||||||
});
|
});
|
||||||
if (getLiteLLMTTSModels(models).indexOf('local-kitten-tts') !== -1 || opts.currentModel === 'local-kitten-tts') {
|
|
||||||
KITTEN_TTS_VOICES.forEach(function(voice) {
|
|
||||||
pushUniqueTTSItem(items, { id: voice, name: 'Kitten ' + voice, source: 'local-kitten-tts', kind: 'voice' });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (getLiteLLMTTSModels(models).indexOf('local-supertonic-tts') !== -1 || opts.currentModel === 'local-supertonic-tts') {
|
|
||||||
SUPERTONIC_TTS_VOICES.forEach(function(voice) {
|
|
||||||
pushUniqueTTSItem(items, { id: voice, name: 'Supertonic ' + voice, source: 'local-supertonic-tts', kind: 'voice' });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (getLiteLLMTTSModels(models).indexOf('groq-orpheus-english') !== -1 || opts.currentModel === 'groq-orpheus-english') {
|
|
||||||
GROQ_ORPHEUS_ENGLISH_VOICES.forEach(function(voice) {
|
|
||||||
pushUniqueTTSItem(items, { id: voice, name: 'Groq Orpheus ' + voice, source: 'groq-orpheus-english', kind: 'voice' });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (getLiteLLMTTSModels(models).indexOf('groq-orpheus-arabic-saudi') !== -1 || opts.currentModel === 'groq-orpheus-arabic-saudi') {
|
|
||||||
GROQ_ORPHEUS_ARABIC_VOICES.forEach(function(voice) {
|
|
||||||
pushUniqueTTSItem(items, { id: voice, name: 'Groq Orpheus Arabic ' + voice, source: 'groq-orpheus-arabic-saudi', kind: 'voice' });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,10 +64,7 @@ module.exports = {
|
||||||
getLiteLLMTTSDiscoveryItems,
|
getLiteLLMTTSDiscoveryItems,
|
||||||
getLiteLLMHeaders,
|
getLiteLLMHeaders,
|
||||||
getLiteLLMTTSModels,
|
getLiteLLMTTSModels,
|
||||||
getLiteLLMTTSRequestOptions,
|
|
||||||
getLiteLLMTTSVoicesForModel,
|
|
||||||
getTTSProvider,
|
getTTSProvider,
|
||||||
getTTSVoiceLists,
|
getTTSVoiceLists,
|
||||||
isLiteLLMTTSVoiceCompatible,
|
|
||||||
isLiteLLMTTSModel
|
isLiteLLMTTSModel
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const { test } = require('node:test');
|
const { test } = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
const ENV_KEYS = ['TTS_PROVIDER', 'LITELLM_API_BASE', 'LITELLM_API_KEY', 'LITELLM_MASTER_KEY', 'LITELLM_TTS_VOICES', 'LITELLM_TTS_VOICE'];
|
const ENV_KEYS = ['TTS_PROVIDER', 'LITELLM_API_BASE', 'LITELLM_API_KEY', 'LITELLM_MASTER_KEY', 'LITELLM_TTS_VOICES'];
|
||||||
|
|
||||||
function withEnv(overrides, fn) {
|
function withEnv(overrides, fn) {
|
||||||
const previous = {};
|
const previous = {};
|
||||||
|
|
@ -36,11 +36,10 @@ test('TTS provider ignores non-LiteLLM provider overrides', () => {
|
||||||
test('LiteLLM TTS voice list comes from configured voice catalog', () => {
|
test('LiteLLM TTS voice list comes from configured voice catalog', () => {
|
||||||
const ttsProvider = require('../src/utils/ttsProvider');
|
const ttsProvider = require('../src/utils/ttsProvider');
|
||||||
withEnv({ LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam, sherpa/kokoro:af_bella' }, () => {
|
withEnv({ LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam, sherpa/kokoro:af_bella' }, () => {
|
||||||
const voices = ttsProvider.getTTSVoiceLists().litellm;
|
assert.deepEqual(ttsProvider.getTTSVoiceLists().litellm, [
|
||||||
assert.equal(voices[0], 'sherpa/kokoro:am_adam');
|
'sherpa/kokoro:am_adam',
|
||||||
assert.equal(voices[1], 'sherpa/kokoro:af_bella');
|
'sherpa/kokoro:af_bella'
|
||||||
assert.equal(voices.includes('Jasper'), true);
|
]);
|
||||||
assert.equal(voices.includes('F1'), true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -93,19 +92,6 @@ test('LiteLLM TTS discovery includes metadata models and configured fallbacks',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('LiteLLM TTS discovery expands Kitten and Supertonic voices', () => {
|
|
||||||
const ttsProvider = require('../src/utils/ttsProvider');
|
|
||||||
withEnv({}, () => {
|
|
||||||
const items = ttsProvider.getLiteLLMTTSDiscoveryItems([
|
|
||||||
{ model_name: 'local-kitten-tts', model_info: { mode: 'audio_speech' } },
|
|
||||||
{ model_name: 'local-supertonic-tts', model_info: { mode: 'audio_speech' } }
|
|
||||||
], {});
|
|
||||||
assert.equal(items.some(function(item) { return item.id === 'local-kitten-tts' && item.kind === 'model'; }), true);
|
|
||||||
assert.equal(items.some(function(item) { return item.id === 'Jasper' && item.kind === 'voice'; }), true);
|
|
||||||
assert.equal(items.some(function(item) { return item.id === 'F1' && item.kind === 'voice'; }), true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('LiteLLM TTS discovery still shows configured model if metadata lookup fails', () => {
|
test('LiteLLM TTS discovery still shows configured model if metadata lookup fails', () => {
|
||||||
const ttsProvider = require('../src/utils/ttsProvider');
|
const ttsProvider = require('../src/utils/ttsProvider');
|
||||||
assert.deepEqual(ttsProvider.getLiteLLMTTSDiscoveryItems([], {
|
assert.deepEqual(ttsProvider.getLiteLLMTTSDiscoveryItems([], {
|
||||||
|
|
@ -117,38 +103,6 @@ test('LiteLLM TTS discovery still shows configured model if metadata lookup fail
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('LiteLLM TTS voices are scoped to the active local model', () => {
|
|
||||||
const ttsProvider = require('../src/utils/ttsProvider');
|
|
||||||
withEnv({ LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam,sherpa/kokoro:af_bella', LITELLM_TTS_VOICE: 'sherpa/kokoro:am_adam' }, () => {
|
|
||||||
assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('local-kitten-tts'), ['Bella', 'Jasper', 'Luna', 'Bruno', 'Rosie', 'Hugo', 'Kiki', 'Leo']);
|
|
||||||
assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('local-supertonic-tts'), ['F1', 'F2', 'F3', 'F4', 'F5', 'M1', 'M2', 'M3', 'M4', 'M5']);
|
|
||||||
assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('local-kokoro-tts'), ['sherpa/kokoro:am_adam', 'sherpa/kokoro:af_bella']);
|
|
||||||
assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('groq-orpheus-english'), ['autumn', 'diana', 'hannah', 'austin', 'daniel', 'troy']);
|
|
||||||
assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('canopylabs/orpheus-arabic-saudi'), ['abdullah', 'fahad', 'sultan', 'lulwa', 'noura', 'aisha']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('LiteLLM TTS compatibility rejects cross-model local voices', () => {
|
|
||||||
const ttsProvider = require('../src/utils/ttsProvider');
|
|
||||||
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-kokoro-tts', 'Bella'), false);
|
|
||||||
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-kokoro-tts', 'M1'), false);
|
|
||||||
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-kitten-tts', 'Bella'), true);
|
|
||||||
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-kitten-tts', 'M1'), false);
|
|
||||||
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-supertonic-tts', 'M1'), true);
|
|
||||||
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-supertonic-tts', 'Bella'), false);
|
|
||||||
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-english', 'hannah'), true);
|
|
||||||
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-english', 'sherpa/kokoro:af_bella'), false);
|
|
||||||
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-arabic-saudi', 'aisha'), true);
|
|
||||||
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-arabic-saudi', 'troy'), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Groq Orpheus TTS requests force wav response format', () => {
|
|
||||||
const ttsProvider = require('../src/utils/ttsProvider');
|
|
||||||
assert.deepEqual(ttsProvider.getLiteLLMTTSRequestOptions('groq-orpheus-english'), { response_format: 'wav' });
|
|
||||||
assert.deepEqual(ttsProvider.getLiteLLMTTSRequestOptions('canopylabs/orpheus-arabic-saudi'), { response_format: 'wav' });
|
|
||||||
assert.deepEqual(ttsProvider.getLiteLLMTTSRequestOptions('local-kokoro-tts'), {});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('LiteLLM headers include optional content type without exposing key value', () => {
|
test('LiteLLM headers include optional content type without exposing key value', () => {
|
||||||
const ttsProvider = require('../src/utils/ttsProvider');
|
const ttsProvider = require('../src/utils/ttsProvider');
|
||||||
withEnv({ LITELLM_API_KEY: 'secret-token' }, () => {
|
withEnv({ LITELLM_API_KEY: 'secret-token' }, () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue