Revert "refactor: replace external Groq TTS proxy with built-in API route"

This reverts commit 41b9793537.
This commit is contained in:
Sunny 2026-02-07 01:53:12 +00:00
parent 9a929d98cb
commit 4ce1874d78
9 changed files with 226 additions and 17 deletions

View file

@ -5,6 +5,7 @@ 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
@ -22,16 +23,11 @@ http://localhost:3003
```text
OpenReader WebUI (:3003)
Built-in /api/groq-tts route
Groq TTS Proxy (localhost:8880)
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)

52
scripts/groq-tts-proxy.py Normal file
View file

@ -0,0 +1,52 @@
"""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)

View file

@ -1,7 +1,7 @@
#!/bin/bash
# OpenReader WebUI - TTS Document Reader
# URL: http://localhost:3003
# Uses Groq Orpheus TTS via built-in /api/groq-tts route
# Uses Groq Orpheus TTS via local proxy
set -e
@ -19,7 +19,8 @@ else
exit 1
fi
# Stop existing service
# Stop existing services
fuser -k 8880/tcp 2>/dev/null || true
fuser -k 3003/tcp 2>/dev/null || true
# Wait for port to be released
@ -37,16 +38,31 @@ 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 available at /api/groq-tts"
echo "Groq TTS proxy running on port 8880"
echo "Available voices: troy, austin, daniel, autumn, diana, hannah"
echo "Logs: /tmp/openreader.log"
echo "Logs: /tmp/openreader.log and /tmp/groq-proxy.log"

40
src/app/api/auth/route.ts Normal file
View file

@ -0,0 +1,40 @@
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;
}

View file

@ -1,7 +0,0 @@
import { NextResponse } from 'next/server';
const VOICES = ['troy', 'austin', 'daniel', 'autumn', 'diana', 'hannah'];
export async function GET() {
return NextResponse.json({ voices: VOICES });
}

View file

@ -4,6 +4,7 @@ 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';
@ -74,6 +75,40 @@ 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<string, string> = {
current_page: `You are a helpful assistant that summarizes text content.
Provide a clear, concise summary of the current page content provided.
@ -109,6 +144,12 @@ 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') || '';
@ -121,6 +162,7 @@ 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) {

6
src/instrumentation.ts Normal file
View file

@ -0,0 +1,6 @@
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs' && process.env.AUTH_ENABLED === 'true') {
const { printAuthUrl } = await import('@/lib/auth');
printAuthUrl();
}
}

24
src/lib/auth.ts Normal file
View file

@ -0,0 +1,24 @@
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');
}

40
src/middleware.ts Normal file
View file

@ -0,0 +1,40 @@
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).*)'],
};