diff --git a/app/src/components/ServerTab/CloudSection.tsx b/app/src/components/ServerTab/CloudSection.tsx new file mode 100644 index 00000000..bbede3f4 --- /dev/null +++ b/app/src/components/ServerTab/CloudSection.tsx @@ -0,0 +1,131 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Cloud, Loader2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { useToast } from '@/components/ui/use-toast'; +import { apiClient } from '@/lib/api/client'; +import { SettingRow, SettingSection } from './SettingRow'; + +// "Log in with browser" device pairing. The backend opens the system browser +// and completes the code exchange; here we just kick it off and poll status +// until the link goes live. The API key never touches the frontend. +export function CloudSection() { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [polling, setPolling] = useState(false); + + const { data: status } = useQuery({ + queryKey: ['cloud-status'], + queryFn: () => apiClient.getCloudStatus(), + refetchInterval: polling ? 2000 : false, + }); + + const connected = status?.connected ?? false; + + // Once the browser flow completes, stop polling and celebrate. + useEffect(() => { + if (connected && polling) { + setPolling(false); + toast({ + title: 'Connected to Voicebox Cloud', + description: `Linked as ${status?.device_name ?? 'this device'}.`, + }); + } + }, [connected, polling, status?.device_name, toast]); + + const startLogin = useMutation({ + mutationFn: () => apiClient.startCloudLogin(), + onSuccess: () => { + setPolling(true); + toast({ + title: 'Continue in your browser', + description: 'Authorize this device, then return here.', + }); + }, + onError: (error: Error) => + toast({ + title: 'Could not start sign-in', + description: error.message, + variant: 'destructive', + }), + }); + + const disconnect = useMutation({ + mutationFn: () => apiClient.disconnectCloud(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['cloud-status'] }); + toast({ + title: 'Disconnected', + description: 'This device is no longer linked. The key stays valid until revoked in your account.', + }); + }, + onError: (error: Error) => + toast({ title: 'Could not disconnect', description: error.message, variant: 'destructive' }), + }); + + const busy = startLogin.isPending || polling; + + return ( + + disconnect.mutate()} + size="sm" + variant="outline" + > + {disconnect.isPending ? ( + + ) : ( + 'Disconnect' + )} + + ) : ( + + ) + } + /> + + {connected && ( + + + Open account dashboard ↗ + + + )} + + ); +} diff --git a/app/src/components/ServerTab/GeneralPage.tsx b/app/src/components/ServerTab/GeneralPage.tsx index 70d35090..0d8d7af7 100644 --- a/app/src/components/ServerTab/GeneralPage.tsx +++ b/app/src/components/ServerTab/GeneralPage.tsx @@ -14,6 +14,7 @@ import { useAutoUpdater } from '@/hooks/useAutoUpdater'; import { useServerHealth } from '@/lib/hooks/useServer'; import { usePlatform } from '@/platform/PlatformContext'; import { useServerStore } from '@/stores/serverStore'; +import { CloudSection } from './CloudSection'; import { LanguageSelect } from './LanguageSelect'; import { SettingRow, SettingSection } from './SettingRow'; import { ThemeSelect } from './ThemeSelect'; @@ -207,6 +208,8 @@ export function GeneralPage() { /> + + {platform.metadata.isTauri && } diff --git a/app/src/lib/api/client.ts b/app/src/lib/api/client.ts index a8d030af..8090a0cd 100644 --- a/app/src/lib/api/client.ts +++ b/app/src/lib/api/client.ts @@ -50,6 +50,8 @@ import type { MCPClientBinding, MCPClientBindingListResponse, MCPClientBindingUpsert, + CloudLoginStartResponse, + CloudStatus, } from './types'; function formatErrorDetail(detail: unknown, fallback: string): string { @@ -920,6 +922,21 @@ class ApiClient { return response.blob(); } + + // Cloud (backup & sync) — browser-based device login. startCloudLogin opens + // the system browser server-side; the UI then polls getCloudStatus until the + // backend completes the exchange and the link goes live. + async getCloudStatus(): Promise { + return this.request('/cloud/status'); + } + + async startCloudLogin(): Promise { + return this.request('/cloud/login/start', { method: 'POST' }); + } + + async disconnectCloud(): Promise { + return this.request('/cloud/disconnect', { method: 'POST' }); + } } export const apiClient = new ApiClient(); diff --git a/app/src/lib/api/types.ts b/app/src/lib/api/types.ts index 37ca4667..9a2c005f 100644 --- a/app/src/lib/api/types.ts +++ b/app/src/lib/api/types.ts @@ -521,3 +521,17 @@ export interface MCPClientBindingUpsert { export interface MCPClientBindingListResponse { items: MCPClientBinding[]; } + +/* ─── Cloud (backup & sync) ───────────────────────────────────────────── */ + +export interface CloudLoginStartResponse { + authorize_url: string; +} + +export interface CloudStatus { + connected: boolean; + device_name: string | null; + account_user_id: string | null; + key_prefix: string | null; + connected_at: string | null; +} diff --git a/backend/config.py b/backend/config.py index 1929edbd..8d2df51a 100644 --- a/backend/config.py +++ b/backend/config.py @@ -138,3 +138,17 @@ def get_models_dir() -> Path: path = _data_dir / "models" path.mkdir(parents=True, exist_ok=True) return path + + +# Voicebox Cloud (backup & sync). Two hosts: the web app owns auth + device +# pairing (voicebox.sh), the API owns sync + account endpoints +# (api.voicebox.sh). Override both for local development, e.g. +# VOICEBOX_CLOUD_URL=http://localhost:17592 VOICEBOX_CLOUD_API_URL=http://localhost:17593 +def get_cloud_web_url() -> str: + """Base URL of the Voicebox Cloud web app (auth + /connect + exchange).""" + return os.environ.get("VOICEBOX_CLOUD_URL", "https://voicebox.sh").rstrip("/") + + +def get_cloud_api_url() -> str: + """Base URL of the Voicebox Cloud API (bearer-authenticated sync/account).""" + return os.environ.get("VOICEBOX_CLOUD_API_URL", "https://api.voicebox.sh").rstrip("/") diff --git a/backend/database/__init__.py b/backend/database/__init__.py index bfb4b124..fd1252bf 100644 --- a/backend/database/__init__.py +++ b/backend/database/__init__.py @@ -11,6 +11,7 @@ from .models import ( Capture, CaptureSettings, ChannelDeviceMapping, + CloudSettings, EffectPreset, Generation, GenerationSettings, @@ -32,6 +33,7 @@ __all__ = [ "Capture", "CaptureSettings", "ChannelDeviceMapping", + "CloudSettings", "EffectPreset", "Generation", "GenerationSettings", diff --git a/backend/database/models.py b/backend/database/models.py index 6ef2213e..b85a55b1 100644 --- a/backend/database/models.py +++ b/backend/database/models.py @@ -234,6 +234,28 @@ class GenerationSettings(Base): updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) +class CloudSettings(Base): + """Singleton row holding the link to a Voicebox Cloud account. + + Populated by the "Log in with browser" pairing flow (see services/cloud.py): + the browser hands back a one-time code, which the backend exchanges for an + ``api_key`` it stores here. The key is a bearer credential for + api.voicebox.sh — auth only, never an encryption key (E2E key material lives + elsewhere). Stored in the local app database alongside the user's other data; + moving it to the OS keychain is a future hardening step. The ``id`` is + always 1; a null ``api_key`` means "not connected". + """ + + __tablename__ = "cloud_settings" + + id = Column(Integer, primary_key=True, default=1) + api_key = Column(String, nullable=True) + device_name = Column(String, nullable=True) + account_user_id = Column(String, nullable=True) + connected_at = Column(DateTime, nullable=True) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + class MCPClientBinding(Base): """Per-MCP-client settings (voice profile, engine, personality default). diff --git a/backend/models.py b/backend/models.py index 06f321ac..4f608f40 100644 --- a/backend/models.py +++ b/backend/models.py @@ -793,3 +793,23 @@ class AvailableEffectsResponse(BaseModel): """Response listing all available effect types.""" effects: List[AvailableEffect] + + +# ─── Cloud (backup & sync) ────────────────────────────────────────────── + + +class CloudLoginStartResponse(BaseModel): + """Returned when the desktop kicks off browser login. The backend has + already opened the browser; the URL is included for fallback/debugging.""" + + authorize_url: str + + +class CloudStatusResponse(BaseModel): + """Current link between this device and a Voicebox Cloud account.""" + + connected: bool + device_name: Optional[str] = None + account_user_id: Optional[str] = None + key_prefix: Optional[str] = None + connected_at: Optional[datetime] = None diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py index 35563aaa..ac6b015b 100644 --- a/backend/routes/__init__.py +++ b/backend/routes/__init__.py @@ -23,6 +23,7 @@ def register_routers(app: FastAPI) -> None: from .speak import router as speak_router from .mcp_bindings import router as mcp_bindings_router from .events import router as events_router + from .cloud import router as cloud_router app.include_router(health_router) app.include_router(profiles_router) @@ -42,3 +43,4 @@ def register_routers(app: FastAPI) -> None: app.include_router(speak_router) app.include_router(mcp_bindings_router) app.include_router(events_router) + app.include_router(cloud_router) diff --git a/backend/routes/cloud.py b/backend/routes/cloud.py new file mode 100644 index 00000000..3abf2ef5 --- /dev/null +++ b/backend/routes/cloud.py @@ -0,0 +1,75 @@ +"""Voicebox Cloud device login routes. + +The browser-based pairing flow: + 1. POST /cloud/login/start — opens the browser to the cloud authorize page. + 2. GET /cloud/callback — the browser lands here with a one-time code; + the backend exchanges it for an API key. + 3. GET /cloud/status — the UI polls this to learn when it connected. + 4. POST /cloud/disconnect — forget the local credential. +""" + +import socket + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from .. import models +from ..database import get_db +from ..services import cloud as cloud_service + +router = APIRouter(prefix="/cloud", tags=["cloud"]) + + +def _callback_url(request: Request) -> str: + # Always loopback — the cloud only redirects codes to 127.0.0.1/localhost. + port = request.url.port or 17493 + return f"http://127.0.0.1:{port}/cloud/callback" + + +@router.post("/login/start", response_model=models.CloudLoginStartResponse) +async def start_cloud_login(request: Request): + device_name = socket.gethostname() or "Desktop" + authorize_url = cloud_service.start_login(_callback_url(request), device_name) + return models.CloudLoginStartResponse(authorize_url=authorize_url) + + +@router.get("/callback", response_class=HTMLResponse) +async def cloud_callback( + request: Request, + code: str = "", + state: str = "", + db: Session = Depends(get_db), +): + ok, message = await cloud_service.handle_callback(db, code=code, state=state) + heading = "You're connected" if ok else "Couldn't connect" + accent = "#16a34a" if ok else "#dc2626" + sub = ( + "Voicebox is now linked to your account. You can close this tab and return to the app." + if ok + else message + ) + html = f""" + + +Voicebox Cloud + +

{heading}

{sub}

""" + return HTMLResponse(content=html, status_code=200 if ok else 400) + + +@router.get("/status", response_model=models.CloudStatusResponse) +async def cloud_status(db: Session = Depends(get_db)): + return models.CloudStatusResponse(**cloud_service.get_status(db)) + + +@router.post("/disconnect", response_model=models.CloudStatusResponse) +async def cloud_disconnect(db: Session = Depends(get_db)): + cloud_service.disconnect(db) + return models.CloudStatusResponse(**cloud_service.get_status(db)) diff --git a/backend/services/cloud.py b/backend/services/cloud.py new file mode 100644 index 00000000..1b3983b4 --- /dev/null +++ b/backend/services/cloud.py @@ -0,0 +1,160 @@ +""" +Voicebox Cloud device login — the "Log in with browser" flow. + +The desktop opens the browser to ``{web}/connect``; the user authorizes while +signed in; the cloud redirects a single-use code back to this backend's loopback +callback. We exchange that code (server-to-server, over TLS) for a ``voicebox_…`` +API key, verify the key against the API, and store it locally. The key never +travels through a browser URL, and an unfinished flow leaves nothing behind. + +The ``state`` we mint and round-trip prevents login-CSRF: a callback whose state +we didn't issue (e.g. an attacker tricking the user into hitting the loopback +callback with their own code) is rejected. +""" + +import logging +import secrets +import time +import webbrowser +from urllib.parse import urlencode + +import httpx +from sqlalchemy.orm import Session + +from .. import config +from ..database import CloudSettings as DBCloudSettings + +logger = logging.getLogger(__name__) + +SINGLETON_ID = 1 +PENDING_TTL_SECONDS = 600 # the whole browser flow must finish within 10 min + +# state -> expiry epoch. In-memory: a single backend process owns the flow, and a +# dropped pairing should simply be restarted. +_pending: dict[str, float] = {} + + +def _prune() -> None: + now = time.time() + for state, expiry in list(_pending.items()): + if expiry < now: + _pending.pop(state, None) + + +def _consume_state(state: str) -> bool: + """Validate and single-use-consume a pending state.""" + _prune() + expiry = _pending.pop(state, None) + return expiry is not None and expiry >= time.time() + + +def start_login(callback_url: str, device_name: str) -> str: + """Mint a state, build the authorize URL, and open the browser. + + Returns the authorize URL (also opened here) so the caller can surface it as + a fallback if the browser didn't open. + """ + state = secrets.token_urlsafe(24) + _prune() + _pending[state] = time.time() + PENDING_TTL_SECONDS + + params = urlencode({"redirect_uri": callback_url, "state": state, "name": device_name}) + authorize_url = f"{config.get_cloud_web_url()}/connect?{params}" + + try: + webbrowser.open(authorize_url) + except Exception: # pragma: no cover - platform dependent + logger.exception("failed to open browser for cloud login") + + return authorize_url + + +async def handle_callback(db: Session, code: str, state: str) -> tuple[bool, str]: + """Exchange the code for an API key and store it. Returns (ok, message).""" + if not _consume_state(state): + return False, "This sign-in link is invalid or has expired. Start again from the app." + if not code: + return False, "Missing authorization code." + + web = config.get_cloud_web_url() + api = config.get_cloud_api_url() + try: + async with httpx.AsyncClient(timeout=15.0) as client: + exchanged = await client.post(f"{web}/api/connect/exchange", json={"code": code}) + if exchanged.status_code != 200: + logger.warning("cloud exchange rejected code: %s", exchanged.status_code) + return False, "Could not complete sign-in — the code was rejected." + payload = exchanged.json() + api_key = payload.get("key") + device_name = payload.get("label") + if not api_key: + return False, "Voicebox Cloud did not return a key." + + # Confirm the freshly minted key actually authenticates the API. + me = await client.get( + f"{api}/v1/account/me", + headers={"Authorization": f"Bearer {api_key}"}, + ) + if me.status_code != 200: + logger.warning("minted key failed verification: %s", me.status_code) + return False, "Sign-in succeeded but the key could not be verified." + account_user_id = (me.json().get("data") or {}).get("userId") + except httpx.HTTPError: + logger.exception("network error during cloud exchange") + return False, "Could not reach Voicebox Cloud. Check your connection and try again." + + _store_key(db, api_key=api_key, device_name=device_name, account_user_id=account_user_id) + logger.info("connected to Voicebox Cloud as device %r", device_name) + return True, "Connected" + + +def _get_or_create_row(db: Session) -> DBCloudSettings: + row = db.query(DBCloudSettings).filter(DBCloudSettings.id == SINGLETON_ID).first() + if row is None: + row = DBCloudSettings(id=SINGLETON_ID) + db.add(row) + db.commit() + db.refresh(row) + return row + + +def _store_key(db: Session, *, api_key: str, device_name: str | None, account_user_id: str | None): + from datetime import datetime + + row = _get_or_create_row(db) + row.api_key = api_key + row.device_name = device_name + row.account_user_id = account_user_id + row.connected_at = datetime.utcnow() + db.commit() + + +def get_status(db: Session) -> dict: + """Local view of the cloud link — never returns the full key.""" + row = _get_or_create_row(db) + connected = bool(row.api_key) + # Prefix only: "voicebox_" (9) + 8 chars, matching the cloud's key_prefix. + key_prefix = row.api_key[:17] if row.api_key else None + return { + "connected": connected, + "device_name": row.device_name if connected else None, + "account_user_id": row.account_user_id if connected else None, + "key_prefix": key_prefix, + "connected_at": row.connected_at if connected else None, + } + + +def disconnect(db: Session) -> None: + """Forget the local credential. The key remains valid on the server until + revoked from the account dashboard — surface that in the UI.""" + row = _get_or_create_row(db) + row.api_key = None + row.account_user_id = None + row.connected_at = None + db.commit() + + +def get_api_key(db: Session) -> str | None: + """The stored bearer key, for the (future) sync client. None if not linked.""" + row = _get_or_create_row(db) + return row.api_key