Revert "refactor: replace external Groq TTS proxy with built-in API route"
This reverts commit 41b9793537.
This commit is contained in:
parent
9a929d98cb
commit
4ce1874d78
9 changed files with 226 additions and 17 deletions
|
|
@ -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
52
scripts/groq-tts-proxy.py
Normal 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)
|
||||
|
|
@ -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
40
src/app/api/auth/route.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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
6
src/instrumentation.ts
Normal 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
24
src/lib/auth.ts
Normal 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
40
src/middleware.ts
Normal 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).*)'],
|
||||
};
|
||||
Loading…
Reference in a new issue