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
|
## Files
|
||||||
|
|
||||||
- `openreader-webui.sh` - Main startup script
|
- `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`)
|
- `.env` - Environment variables (contains `GROQ_API_KEY`)
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
@ -22,16 +23,11 @@ http://localhost:3003
|
||||||
```text
|
```text
|
||||||
OpenReader WebUI (:3003)
|
OpenReader WebUI (:3003)
|
||||||
↓
|
↓
|
||||||
Built-in /api/groq-tts route
|
Groq TTS Proxy (localhost:8880)
|
||||||
↓
|
↓
|
||||||
Groq API (canopylabs/orpheus-v1-english)
|
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
|
## Available Voices
|
||||||
|
|
||||||
- troy, austin, daniel (male)
|
- 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
|
#!/bin/bash
|
||||||
# OpenReader WebUI - TTS Document Reader
|
# OpenReader WebUI - TTS Document Reader
|
||||||
# URL: http://localhost:3003
|
# URL: http://localhost:3003
|
||||||
# Uses Groq Orpheus TTS via built-in /api/groq-tts route
|
# Uses Groq Orpheus TTS via local proxy
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
|
@ -19,7 +19,8 @@ else
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Stop existing service
|
# Stop existing services
|
||||||
|
fuser -k 8880/tcp 2>/dev/null || true
|
||||||
fuser -k 3003/tcp 2>/dev/null || true
|
fuser -k 3003/tcp 2>/dev/null || true
|
||||||
|
|
||||||
# Wait for port to be released
|
# Wait for port to be released
|
||||||
|
|
@ -37,16 +38,31 @@ if fuser 3003/tcp 2>/dev/null; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
# Build and run OpenReader WebUI
|
||||||
cd "$PROJECT_DIR"
|
cd "$PROJECT_DIR"
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm build
|
pnpm build
|
||||||
|
|
||||||
|
# Set environment variables for the app
|
||||||
|
export API_KEY=none
|
||||||
|
export API_BASE=http://localhost:8880/v1
|
||||||
|
|
||||||
# Start the app
|
# Start the app
|
||||||
nohup pnpm start > /tmp/openreader.log 2>&1 &
|
nohup pnpm start > /tmp/openreader.log 2>&1 &
|
||||||
disown
|
disown
|
||||||
|
|
||||||
echo "OpenReader WebUI started at http://localhost:3003"
|
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 "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 { createAnthropic } from '@ai-sdk/anthropic';
|
||||||
import { createGroq } from '@ai-sdk/groq';
|
import { createGroq } from '@ai-sdk/groq';
|
||||||
import type { SummarizeRequest, SummarizeResponse, SummarizeError } from '@/types/summary';
|
import type { SummarizeRequest, SummarizeResponse, SummarizeError } from '@/types/summary';
|
||||||
|
import { getAuthToken } from '@/lib/auth';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
|
@ -74,6 +75,40 @@ function validateBaseUrl(baseUrl: string, provider: string): string | null {
|
||||||
return 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> = {
|
const SYSTEM_PROMPTS: Record<string, string> = {
|
||||||
current_page: `You are a helpful assistant that summarizes text content.
|
current_page: `You are a helpful assistant that summarizes text content.
|
||||||
Provide a clear, concise summary of the current page content provided.
|
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) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
// Authentication check - must pass before processing any other headers
|
||||||
|
const authError = authenticateRequest(req);
|
||||||
|
if (authError) {
|
||||||
|
return authError;
|
||||||
|
}
|
||||||
|
|
||||||
// Get configuration from headers
|
// Get configuration from headers
|
||||||
const provider = req.headers.get('x-summary-provider') || 'openai';
|
const provider = req.headers.get('x-summary-provider') || 'openai';
|
||||||
const requestedBaseUrl = req.headers.get('x-summary-base-url') || '';
|
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
|
// 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') || '';
|
let apiKey = req.headers.get('x-summary-api-key') || '';
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
switch (provider) {
|
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