diff --git a/scripts/README.md b/scripts/README.md index 7f6d035..065787c 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -5,7 +5,6 @@ Scripts for running OpenReader WebUI with Groq Orpheus TTS. ## Files - `openreader-webui.sh` - Main startup script -- `groq-tts-proxy.py` - FastAPI proxy that adds `/v1/audio/voices` endpoint for Groq - `.env` - Environment variables (contains `GROQ_API_KEY`) ## Usage @@ -21,13 +20,18 @@ http://localhost:3003 ## Architecture ``` -OpenReader WebUI (Docker :3003) +OpenReader WebUI (:3003) ↓ -Groq TTS Proxy (localhost:8880) +Built-in /api/groq-tts route ↓ Groq API (canopylabs/orpheus-v1-english) ``` +## API Endpoints + +- `POST /api/groq-tts` - Generate speech from text +- `GET /api/groq-tts/voices` - List available voices + ## Available Voices - troy, austin, daniel (male) diff --git a/scripts/groq-tts-proxy.py b/scripts/groq-tts-proxy.py deleted file mode 100644 index 5f58cf1..0000000 --- a/scripts/groq-tts-proxy.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Groq TTS Proxy - OpenAI-compatible TTS server using Groq Orpheus API""" -from fastapi import FastAPI, Request, Response, HTTPException -import httpx -import os - -app = FastAPI(title="Groq TTS Proxy") - -GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "") -GROQ_BASE = "https://api.groq.com/openai/v1" - -VOICES = ["troy", "austin", "daniel", "autumn", "diana", "hannah"] - -@app.get("/") -async def root(): - return {"status": "ok", "service": "Groq TTS Proxy"} - -@app.get("/v1/audio/voices") -@app.get("/audio/voices") -@app.get("/v1/voices") -@app.get("/voices") -async def get_voices(): - return {"voices": VOICES} - -@app.post("/v1/audio/speech") -@app.post("/audio/speech") -async def speech(request: Request): - body = await request.json() - - model = body.get("model", "") - if not model.startswith("canopylabs/"): - body["model"] = "canopylabs/orpheus-v1-english" - - if body.get("voice") not in VOICES: - body["voice"] = "troy" - - # Groq requires response_format - if "response_format" not in body: - body["response_format"] = "wav" - - async with httpx.AsyncClient(timeout=120) as client: - resp = await client.post( - f"{GROQ_BASE}/audio/speech", - json=body, - headers={"Authorization": f"Bearer {GROQ_API_KEY}"} - ) - if resp.status_code != 200: - raise HTTPException(status_code=resp.status_code, detail=resp.text) - return Response(content=resp.content, media_type="audio/wav") - -if __name__ == "__main__": - import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8880) diff --git a/scripts/openreader-webui.sh b/scripts/openreader-webui.sh index 804dd8e..98a9ee4 100755 --- a/scripts/openreader-webui.sh +++ b/scripts/openreader-webui.sh @@ -1,7 +1,7 @@ #!/bin/bash # OpenReader WebUI - TTS Document Reader # URL: http://localhost:3003 -# Uses Groq Orpheus TTS via local proxy +# Uses Groq Orpheus TTS via built-in /api/groq-tts route set -e @@ -13,11 +13,10 @@ PROJECT_DIR="$(dirname "$SCRIPT_DIR")" source "$SCRIPT_DIR/.env" export GROQ_API_KEY -# Stop existing services -fuser -k 8880/tcp 2>/dev/null || true +# Stop existing service fuser -k 3003/tcp 2>/dev/null || true -# Wait for ports to be released +# Wait for port to be released for i in {1..10}; do if ! fuser 3003/tcp 2>/dev/null; then break @@ -32,31 +31,16 @@ if fuser 3003/tcp 2>/dev/null; then exit 1 fi -# Start Groq TTS proxy (adds /voices endpoint for OpenReader) -nohup python3 "$SCRIPT_DIR/groq-tts-proxy.py" > /tmp/groq-proxy.log 2>&1 & -disown -sleep 2 - -# Verify proxy is running -if ! curl -s http://localhost:8880/ > /dev/null; then - echo "ERROR: Groq TTS proxy failed to start" - exit 1 -fi - # Build and run OpenReader WebUI cd "$PROJECT_DIR" pnpm install pnpm build -# Set environment variables for the app -export API_KEY=none -export API_BASE=http://localhost:8880/v1 - # Start the app nohup pnpm start > /tmp/openreader.log 2>&1 & disown echo "OpenReader WebUI started at http://localhost:3003" -echo "Groq TTS proxy running on port 8880" +echo "Groq TTS available at /api/groq-tts" echo "Available voices: troy, austin, daniel, autumn, diana, hannah" -echo "Logs: /tmp/openreader.log and /tmp/groq-proxy.log" +echo "Logs: /tmp/openreader.log" diff --git a/src/app/api/auth/route.ts b/src/app/api/auth/route.ts deleted file mode 100644 index 6e28f30..0000000 --- a/src/app/api/auth/route.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getAuthToken } from '@/lib/auth'; - -function isValidReturnTo(returnTo: string): boolean { - // Only allow relative paths starting with / - // Reject absolute URLs, protocol-relative URLs, and other schemes - if (!returnTo.startsWith('/')) return false; - if (returnTo.startsWith('//')) return false; - // Reject URLs with encoded characters that could bypass checks - if (returnTo.includes('%')) { - try { - const decoded = decodeURIComponent(returnTo); - if (decoded.startsWith('//') || decoded.includes('://')) return false; - } catch { - return false; - } - } - return true; -} - -export async function GET(request: NextRequest) { - const token = request.nextUrl.searchParams.get('token'); - const returnTo = request.nextUrl.searchParams.get('returnTo') || '/'; - - if (token !== getAuthToken()) { - return NextResponse.json({ error: 'Invalid token' }, { status: 401 }); - } - - // Validate returnTo to prevent open redirect attacks - const safeReturnTo = isValidReturnTo(returnTo) ? returnTo : '/'; - const response = NextResponse.redirect(new URL(safeReturnTo, request.url)); - response.cookies.set('auth_session', token, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 60 * 24 * 30, - path: '/', - }); - return response; -} diff --git a/src/app/api/groq-tts/route.ts b/src/app/api/groq-tts/route.ts new file mode 100644 index 0000000..9694a78 --- /dev/null +++ b/src/app/api/groq-tts/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; + +const GROQ_BASE = 'https://api.groq.com/openai/v1'; +const VOICES = ['troy', 'austin', 'daniel', 'autumn', 'diana', 'hannah']; + +export async function POST(req: NextRequest) { + try { + const apiKey = process.env.GROQ_API_KEY; + if (!apiKey) { + return NextResponse.json( + { error: 'GROQ_API_KEY not configured' }, + { status: 500 } + ); + } + + const body = await req.json(); + + // Set default model if not provided or not a canopylabs model + let model = body.model || ''; + if (!model.startsWith('canopylabs/')) { + model = 'canopylabs/orpheus-v1-english'; + } + + // Validate voice + const voice = VOICES.includes(body.voice) ? body.voice : 'troy'; + + // Groq requires response_format + const responseFormat = body.response_format || 'wav'; + + const groqBody = { + model, + voice, + input: body.input, + response_format: responseFormat, + }; + + const response = await fetch(`${GROQ_BASE}/audio/speech`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify(groqBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + return NextResponse.json( + { error: errorText }, + { status: response.status } + ); + } + + const audioBuffer = await response.arrayBuffer(); + const contentType = responseFormat === 'mp3' ? 'audio/mpeg' : 'audio/wav'; + + return new NextResponse(audioBuffer, { + status: 200, + headers: { + 'Content-Type': contentType, + }, + }); + } catch (error) { + console.error('Groq TTS error:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to generate speech' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/groq-tts/voices/route.ts b/src/app/api/groq-tts/voices/route.ts new file mode 100644 index 0000000..88cdf56 --- /dev/null +++ b/src/app/api/groq-tts/voices/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from 'next/server'; + +const VOICES = ['troy', 'austin', 'daniel', 'autumn', 'diana', 'hannah']; + +export async function GET() { + return NextResponse.json({ voices: VOICES }); +} diff --git a/src/app/api/summarize/route.ts b/src/app/api/summarize/route.ts index 565b26b..a1ed9f0 100644 --- a/src/app/api/summarize/route.ts +++ b/src/app/api/summarize/route.ts @@ -4,7 +4,6 @@ import { createOpenAI } from '@ai-sdk/openai'; import { createAnthropic } from '@ai-sdk/anthropic'; import { createGroq } from '@ai-sdk/groq'; import type { SummarizeRequest, SummarizeResponse, SummarizeError } from '@/types/summary'; -import { getAuthToken } from '@/lib/auth'; export const runtime = 'nodejs'; @@ -75,40 +74,6 @@ function validateBaseUrl(baseUrl: string, provider: string): string | null { return null; } -// Authenticate request - returns error response if auth fails, null if auth passes -function authenticateRequest(req: NextRequest): NextResponse | null { - // Auth disabled by default - set AUTH_ENABLED=true to enable - if (process.env.AUTH_ENABLED !== 'true') { - return null; - } - - // Check for valid auth_session cookie - const sessionCookie = req.cookies.get('auth_session')?.value; - if (sessionCookie) { - const validToken = getAuthToken(); - if (sessionCookie === validToken) { - return null; // Auth passed - } - } - - // Check for Authorization header (Bearer token) - const authHeader = req.headers.get('Authorization'); - if (authHeader?.startsWith('Bearer ')) { - const bearerToken = authHeader.slice(7); - const validToken = getAuthToken(); - if (bearerToken === validToken) { - return null; // Auth passed - } - } - - // Auth failed - const errorBody: SummarizeError = { - code: 'UNAUTHORIZED', - message: 'Authentication required. Please provide a valid session cookie or Authorization header.', - }; - return NextResponse.json(errorBody, { status: 401 }); -} - const SYSTEM_PROMPTS: Record = { current_page: `You are a helpful assistant that summarizes text content. Provide a clear, concise summary of the current page content provided. @@ -144,12 +109,6 @@ Do not include any preamble like "Here is a summary" - just provide the summary export async function POST(req: NextRequest) { try { - // Authentication check - must pass before processing any other headers - const authError = authenticateRequest(req); - if (authError) { - return authError; - } - // Get configuration from headers const provider = req.headers.get('x-summary-provider') || 'openai'; const requestedBaseUrl = req.headers.get('x-summary-base-url') || ''; @@ -162,7 +121,6 @@ export async function POST(req: NextRequest) { } // Get API key from headers or environment variables based on provider - // API key selection only occurs after authentication has passed let apiKey = req.headers.get('x-summary-api-key') || ''; if (!apiKey) { switch (provider) { diff --git a/src/instrumentation.ts b/src/instrumentation.ts deleted file mode 100644 index 9209ac1..0000000 --- a/src/instrumentation.ts +++ /dev/null @@ -1,6 +0,0 @@ -export async function register() { - if (process.env.NEXT_RUNTIME === 'nodejs' && process.env.AUTH_ENABLED === 'true') { - const { printAuthUrl } = await import('@/lib/auth'); - printAuthUrl(); - } -} diff --git a/src/lib/auth.ts b/src/lib/auth.ts deleted file mode 100644 index 8a3f617..0000000 --- a/src/lib/auth.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { randomBytes } from 'crypto'; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; -import path from 'path'; - -const TOKEN_FILE = path.join(process.cwd(), '.data', '.auth-token'); - -export function getAuthToken(): string { - const dir = path.dirname(TOKEN_FILE); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - - if (existsSync(TOKEN_FILE)) { - return readFileSync(TOKEN_FILE, 'utf-8').trim(); - } - - const token = randomBytes(32).toString('base64url'); - writeFileSync(TOKEN_FILE, token); - return token; -} - -export function printAuthUrl() { - const token = getAuthToken(); - const port = process.env.PORT || 3000; - console.log('\nšŸ” Auth URL: http://localhost:' + port + '?token=' + token + '\n'); -} diff --git a/src/middleware.ts b/src/middleware.ts deleted file mode 100644 index b52addc..0000000 --- a/src/middleware.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { NextResponse } from 'next/server'; -import type { NextRequest } from 'next/server'; - -export function middleware(request: NextRequest) { - // Auth disabled by default - set AUTH_ENABLED=true to enable - if (process.env.AUTH_ENABLED !== 'true') { - return NextResponse.next(); - } - - const { pathname, searchParams } = request.nextUrl; - - // Skip static files and auth endpoint - if (pathname.startsWith('/_next') || pathname.startsWith('/api/auth') || pathname.match(/\.(ico|png|svg|jpg|jpeg|gif|webp|json)$/)) { - return NextResponse.next(); - } - - // Token in URL -> redirect to auth API - const token = searchParams.get('token'); - if (token) { - const authUrl = new URL('/api/auth', request.url); - authUrl.searchParams.set('token', token); - authUrl.searchParams.set('returnTo', pathname); - return NextResponse.redirect(authUrl); - } - - // Valid cookie -> allow - if (request.cookies.get('auth_session')?.value) { - return NextResponse.next(); - } - - // Unauthorized - return new NextResponse('Unauthorized - add ?token=YOUR_TOKEN to URL (check server logs)', { - status: 401, - headers: { 'Content-Type': 'text/plain' }, - }); -} - -export const config = { - matcher: ['/((?!_next/static|_next/image).*)'], -};