diff --git a/scripts/README.md b/scripts/README.md index 217ef82..4fbf8dc 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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) diff --git a/scripts/groq-tts-proxy.py b/scripts/groq-tts-proxy.py new file mode 100644 index 0000000..5f58cf1 --- /dev/null +++ b/scripts/groq-tts-proxy.py @@ -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) diff --git a/scripts/openreader-webui.sh b/scripts/openreader-webui.sh index 0fb38c5..bd12503 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 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" diff --git a/src/app/api/auth/route.ts b/src/app/api/auth/route.ts new file mode 100644 index 0000000..6e28f30 --- /dev/null +++ b/src/app/api/auth/route.ts @@ -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; +} diff --git a/src/app/api/groq-tts/voices/route.ts b/src/app/api/groq-tts/voices/route.ts deleted file mode 100644 index 88cdf56..0000000 --- a/src/app/api/groq-tts/voices/route.ts +++ /dev/null @@ -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 }); -} diff --git a/src/app/api/summarize/route.ts b/src/app/api/summarize/route.ts index 7471db8..1d08afb 100644 --- a/src/app/api/summarize/route.ts +++ b/src/app/api/summarize/route.ts @@ -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 = { 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) { diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..9209ac1 --- /dev/null +++ b/src/instrumentation.ts @@ -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(); + } +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..8a3f617 --- /dev/null +++ b/src/lib/auth.ts @@ -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'); +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..b52addc --- /dev/null +++ b/src/middleware.ts @@ -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).*)'], +};