Add API key authentication to all endpoints

This commit is contained in:
Your Name 2026-05-19 20:19:02 -04:00
parent 56d8fb4d56
commit 176439c1df
4 changed files with 207 additions and 28 deletions

1
.gitignore vendored
View file

@ -71,6 +71,7 @@ local.properties
# Claude settings (local) # Claude settings (local)
.claude/ .claude/
**/.claude/
# Data directories # Data directories
data/ data/

View file

@ -15,12 +15,21 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from fastapi import FastAPI, File, Header, HTTPException, Query, UploadFile from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
import config import config
async def verify_api_key(x_api_key: str = Header(None, alias="X-API-Key")):
"""Verify API key for protected endpoints."""
if not config.API_KEY:
raise HTTPException(status_code=500, detail="API_KEY not configured on server")
if x_api_key != config.API_KEY:
raise HTTPException(status_code=401, detail="Invalid or missing API key")
return x_api_key
import crypto import crypto
import database import database
import importer import importer
@ -92,7 +101,7 @@ async def health_check():
return {"status": "ok"} return {"status": "ok"}
@app.get("/api/v1/info") @app.get("/api/v1/info", dependencies=[Depends(verify_api_key)])
async def server_info(): async def server_info():
"""Server configuration info.""" """Server configuration info."""
return { return {
@ -110,21 +119,17 @@ async def get_public_key():
# Upload (existing functionality) # Upload (existing functionality)
@app.post("/api/v1/recordings/upload") @app.post("/api/v1/recordings/upload", dependencies=[Depends(verify_api_key)])
async def upload_recording( async def upload_recording(
file: UploadFile = File(...), file: UploadFile = File(...),
x_api_key: str = Header(None, alias="X-API-Key"),
): ):
""" """
Receives an encrypted recording, decrypts it, and stores it. Receives an encrypted recording, decrypts it, and stores it.
The file is expected to be in the SREC encrypted format. The file is expected to be in the SREC encrypted format.
Returns a SHA-256 hash of the received encrypted file for verification. Returns a SHA-256 hash of the received encrypted file for verification.
Requires X-API-Key header if API_KEY is configured. Requires X-API-Key header.
""" """
# Check API key if configured
if config.API_KEY and x_api_key != config.API_KEY:
raise HTTPException(status_code=401, detail="Invalid or missing API key")
if not file.filename: if not file.filename:
raise HTTPException(status_code=400, detail="No filename provided") raise HTTPException(status_code=400, detail="No filename provided")
@ -192,7 +197,7 @@ async def upload_recording(
# Recordings API # Recordings API
@app.get("/api/v1/recordings") @app.get("/api/v1/recordings", dependencies=[Depends(verify_api_key)])
async def list_recordings( async def list_recordings(
source: Optional[str] = Query(None, description="Filter by source name"), source: Optional[str] = Query(None, description="Filter by source name"),
date_from: Optional[str] = Query(None, description="Start date (YYYY-MM-DD)"), date_from: Optional[str] = Query(None, description="Start date (YYYY-MM-DD)"),
@ -219,7 +224,7 @@ async def list_recordings(
} }
@app.get("/api/v1/recordings/{recording_id}") @app.get("/api/v1/recordings/{recording_id}", dependencies=[Depends(verify_api_key)])
async def get_recording(recording_id: int): async def get_recording(recording_id: int):
"""Get recording details with transcript segments and highlights.""" """Get recording details with transcript segments and highlights."""
recording = database.get_recording(recording_id) recording = database.get_recording(recording_id)
@ -228,7 +233,7 @@ async def get_recording(recording_id: int):
return recording return recording
@app.get("/api/v1/recordings/{recording_id}/audio") @app.get("/api/v1/recordings/{recording_id}/audio", dependencies=[Depends(verify_api_key)])
async def get_recording_audio(recording_id: int): async def get_recording_audio(recording_id: int):
"""Stream the audio file for a recording.""" """Stream the audio file for a recording."""
recording = database.get_recording(recording_id) recording = database.get_recording(recording_id)
@ -256,7 +261,7 @@ async def get_recording_audio(recording_id: int):
# Import API # Import API
@app.post("/api/v1/import/transcriptions") @app.post("/api/v1/import/transcriptions", dependencies=[Depends(verify_api_key)])
async def import_transcriptions(request: ImportRequest): async def import_transcriptions(request: ImportRequest):
""" """
Import transcription JSON files from a directory. Import transcription JSON files from a directory.
@ -286,7 +291,7 @@ async def import_transcriptions(request: ImportRequest):
return result return result
@app.post("/api/v1/import/audio") @app.post("/api/v1/import/audio", dependencies=[Depends(verify_api_key)])
async def import_audio(request: ImportAudioRequest): async def import_audio(request: ImportAudioRequest):
""" """
Import audio files from a directory (without transcriptions). Import audio files from a directory (without transcriptions).
@ -306,7 +311,7 @@ async def import_audio(request: ImportAudioRequest):
return result return result
@app.post("/api/v1/import/link-audio") @app.post("/api/v1/import/link-audio", dependencies=[Depends(verify_api_key)])
async def link_audio(audio_dir: str = Query(..., description="Directory containing audio files")): async def link_audio(audio_dir: str = Query(..., description="Directory containing audio files")):
""" """
Link audio files to existing transcription records. Link audio files to existing transcription records.
@ -323,7 +328,7 @@ async def link_audio(audio_dir: str = Query(..., description="Directory containi
# Highlights API # Highlights API
@app.get("/api/v1/highlights") @app.get("/api/v1/highlights", dependencies=[Depends(verify_api_key)])
async def list_highlights( async def list_highlights(
recording_id: Optional[int] = Query(None), recording_id: Optional[int] = Query(None),
tag: Optional[str] = Query(None), tag: Optional[str] = Query(None),
@ -338,7 +343,7 @@ async def list_highlights(
return {"highlights": highlights, "count": len(highlights)} return {"highlights": highlights, "count": len(highlights)}
@app.post("/api/v1/highlights") @app.post("/api/v1/highlights", dependencies=[Depends(verify_api_key)])
async def create_highlight(highlight: HighlightCreate): async def create_highlight(highlight: HighlightCreate):
"""Create a new highlight/bookmark.""" """Create a new highlight/bookmark."""
# Verify recording exists # Verify recording exists
@ -358,7 +363,7 @@ async def create_highlight(highlight: HighlightCreate):
return {"id": highlight_id, "message": "Highlight created"} return {"id": highlight_id, "message": "Highlight created"}
@app.delete("/api/v1/highlights/{highlight_id}") @app.delete("/api/v1/highlights/{highlight_id}", dependencies=[Depends(verify_api_key)])
async def delete_highlight(highlight_id: int): async def delete_highlight(highlight_id: int):
"""Delete a highlight.""" """Delete a highlight."""
success = database.delete_highlight(highlight_id) success = database.delete_highlight(highlight_id)
@ -369,7 +374,7 @@ async def delete_highlight(highlight_id: int):
# Sources/Devices API # Sources/Devices API
@app.get("/api/v1/sources") @app.get("/api/v1/sources", dependencies=[Depends(verify_api_key)])
async def list_sources(): async def list_sources():
"""Get summary of all recording sources.""" """Get summary of all recording sources."""
sources = database.list_sources() sources = database.list_sources()
@ -378,7 +383,7 @@ async def list_sources():
# Legacy endpoint (for backwards compatibility) # Legacy endpoint (for backwards compatibility)
@app.get("/api/v1/recordings/files") @app.get("/api/v1/recordings/files", dependencies=[Depends(verify_api_key)])
async def list_recording_files(date: Optional[str] = Query(None, description="Filter by date (YYYY-MM-DD)")): async def list_recording_files(date: Optional[str] = Query(None, description="Filter by date (YYYY-MM-DD)")):
""" """
Lists recording files on disk (legacy endpoint). Lists recording files on disk (legacy endpoint).

View file

@ -3,6 +3,36 @@ const { useState: useStateApp, useEffect: useEffectApp, useRef: useRefApp } = Re
const API_BASE = ''; const API_BASE = '';
// API key storage
const API_KEY_STORAGE_KEY = 'clio_api_key';
function getStoredApiKey() {
return localStorage.getItem(API_KEY_STORAGE_KEY);
}
function setStoredApiKey(key) {
localStorage.setItem(API_KEY_STORAGE_KEY, key);
}
function clearStoredApiKey() {
localStorage.removeItem(API_KEY_STORAGE_KEY);
}
// Authenticated fetch wrapper
async function authFetch(url, options = {}) {
const apiKey = getStoredApiKey();
const headers = {
...options.headers,
'X-API-Key': apiKey || '',
};
const response = await fetch(url, { ...options, headers });
if (response.status === 401) {
clearStoredApiKey();
window.dispatchEvent(new Event('auth-required'));
}
return response;
}
// Parse hash into route info // Parse hash into route info
function parseHash() { function parseHash() {
const hash = window.location.hash.slice(1) || '/dashboard'; const hash = window.location.hash.slice(1) || '/dashboard';
@ -70,14 +100,110 @@ function transformRecording(rec) {
}; };
} }
function ApiKeyPrompt({ onSubmit }) {
const [key, setKey] = useStateApp('');
const [error, setError] = useStateApp(null);
const [checking, setChecking] = useStateApp(false);
async function handleSubmit(e) {
e.preventDefault();
if (!key.trim()) return;
setChecking(true);
setError(null);
try {
// Test the key by making a simple request
const res = await fetch(`${API_BASE}/api/v1/sources`, {
headers: { 'X-API-Key': key.trim() }
});
if (res.status === 401) {
setError('Invalid API key');
setChecking(false);
return;
}
if (!res.ok) {
setError('Server error');
setChecking(false);
return;
}
setStoredApiKey(key.trim());
onSubmit();
} catch (err) {
setError('Connection failed');
setChecking(false);
}
}
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '100vh', background: 'var(--bg)',
}}>
<form onSubmit={handleSubmit} style={{
background: 'var(--bg2)', padding: 32, borderRadius: 8,
border: '1px solid var(--border)', width: 320,
}}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 14, marginBottom: 20, color: 'var(--text)' }}>
Enter API Key
</div>
<input
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="API Key"
autoFocus
style={{
width: '100%', padding: '10px 12px', marginBottom: 12,
background: 'var(--bg3)', border: '1px solid var(--border)',
borderRadius: 4, color: 'var(--text)', fontFamily: 'var(--font-mono)',
fontSize: 13, outline: 'none',
}}
/>
{error && (
<div style={{ color: 'var(--red)', fontSize: 12, marginBottom: 12, fontFamily: 'var(--font-mono)' }}>
{error}
</div>
)}
<button
type="submit"
disabled={checking}
style={{
width: '100%', padding: '10px 12px',
background: 'var(--amber)', color: 'var(--bg)',
border: 'none', borderRadius: 4, fontFamily: 'var(--font-mono)',
fontSize: 12, fontWeight: 600, cursor: 'pointer',
opacity: checking ? 0.6 : 1,
}}
>
{checking ? 'Checking...' : 'Continue'}
</button>
</form>
</div>
);
}
function App() { function App() {
const [data, setData] = useStateApp({ recordings: [], highlights: [], devices: {} }); const [data, setData] = useStateApp({ recordings: [], highlights: [], devices: {} });
const [loading, setLoading] = useStateApp(true); const [loading, setLoading] = useStateApp(true);
const [error, setError] = useStateApp(null); const [error, setError] = useStateApp(null);
const [needsAuth, setNeedsAuth] = useStateApp(!getStoredApiKey());
// Lift highlights state so adding/removing works across views // Lift highlights state so adding/removing works across views
const [highlights, setHighlights] = useStateApp([]); const [highlights, setHighlights] = useStateApp([]);
// Listen for auth-required events (401 responses)
useEffectApp(() => {
function handleAuthRequired() {
setNeedsAuth(true);
}
window.addEventListener('auth-required', handleAuthRequired);
return () => window.removeEventListener('auth-required', handleAuthRequired);
}, []);
// Initialize from URL hash // Initialize from URL hash
const initialRoute = parseHash(); const initialRoute = parseHash();
const [view, setViewState] = useStateApp(initialRoute.view); const [view, setViewState] = useStateApp(initialRoute.view);
@ -146,7 +272,7 @@ function App() {
async function fetchRecordingDetail(recordingId) { async function fetchRecordingDetail(recordingId) {
setLoadingDetail(true); setLoadingDetail(true);
try { try {
const res = await fetch(`${API_BASE}/api/v1/recordings/${recordingId}`); const res = await authFetch(`${API_BASE}/api/v1/recordings/${recordingId}`);
if (!res.ok) throw new Error('Failed to fetch recording'); if (!res.ok) throw new Error('Failed to fetch recording');
const rec = await res.json(); const rec = await res.json();
const transformed = transformRecording(rec); const transformed = transformRecording(rec);
@ -189,9 +315,9 @@ function App() {
async function fetchData() { async function fetchData() {
try { try {
const [recordingsRes, sourcesRes, highlightsRes] = await Promise.all([ const [recordingsRes, sourcesRes, highlightsRes] = await Promise.all([
fetch(`${API_BASE}/api/v1/recordings?limit=1000`), authFetch(`${API_BASE}/api/v1/recordings?limit=1000`),
fetch(`${API_BASE}/api/v1/sources`), authFetch(`${API_BASE}/api/v1/sources`),
fetch(`${API_BASE}/api/v1/highlights?limit=1000`), authFetch(`${API_BASE}/api/v1/highlights?limit=1000`),
]); ]);
if (!recordingsRes.ok) throw new Error('Failed to fetch recordings'); if (!recordingsRes.ok) throw new Error('Failed to fetch recordings');
@ -256,6 +382,19 @@ function App() {
fetchData(); fetchData();
}, []); }, []);
// Show API key prompt if not authenticated
if (needsAuth) {
return (
<ApiKeyPrompt onSubmit={() => {
setNeedsAuth(false);
setLoading(true);
// Re-trigger data fetch by incrementing a counter or similar
// For simplicity, just reload the page
window.location.reload();
}} />
);
}
if (loading) { if (loading) {
return ( return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', color: 'var(--text2)' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', color: 'var(--text2)' }}>
@ -363,5 +502,8 @@ function App() {
); );
} }
// Export utilities for other components
Object.assign(window, { authFetch, getStoredApiKey });
const root = ReactDOM.createRoot(document.getElementById('root')); const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />); root.render(<App />);

View file

@ -12,6 +12,8 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
const [showAddHighlight, setShowAddHighlight] = useStateRD(false); const [showAddHighlight, setShowAddHighlight] = useStateRD(false);
const [newHighlightTag, setNewHighlightTag] = useStateRD('important'); const [newHighlightTag, setNewHighlightTag] = useStateRD('important');
const [newHighlightNote, setNewHighlightNote] = useStateRD(''); const [newHighlightNote, setNewHighlightNote] = useStateRD('');
const [audioBlobUrl, setAudioBlobUrl] = useStateRD(null);
const [audioLoading, setAudioLoading] = useStateRD(true);
const audioRef = useRefRD(null); const audioRef = useRefRD(null);
const transcriptRef = useRefRD(null); const transcriptRef = useRefRD(null);
@ -20,8 +22,35 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
const totalSeconds = audioDuration || metadataDuration || 1; // avoid division by zero const totalSeconds = audioDuration || metadataDuration || 1; // avoid division by zero
const recHighlights = allHighlights.filter(h => h.recordingId === r.id); const recHighlights = allHighlights.filter(h => h.recordingId === r.id);
// Audio URL from API // Fetch audio with auth and create blob URL
const audioUrl = `${API_BASE}/api/v1/recordings/${r.id}/audio`; useEffectRD(() => {
let cancelled = false;
let blobUrl = null;
async function fetchAudio() {
setAudioLoading(true);
try {
const res = await window.authFetch(`${API_BASE}/api/v1/recordings/${r.id}/audio`);
if (cancelled) return;
if (!res.ok) throw new Error('Failed to fetch audio');
const blob = await res.blob();
if (cancelled) return;
blobUrl = URL.createObjectURL(blob);
setAudioBlobUrl(blobUrl);
} catch (err) {
console.error('Failed to load audio:', err);
} finally {
if (!cancelled) setAudioLoading(false);
}
}
fetchAudio();
return () => {
cancelled = true;
if (blobUrl) URL.revokeObjectURL(blobUrl);
};
}, [r.id]);
// Track recording ID to detect when recording changes // Track recording ID to detect when recording changes
const lastRecordingId = useRefRD(null); const lastRecordingId = useRefRD(null);
@ -33,6 +62,7 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
hasAutoPlayed.current = false; hasAutoPlayed.current = false;
lastRecordingId.current = r.id; lastRecordingId.current = r.id;
setAudioDuration(null); // Reset to use metadata until audio loads setAudioDuration(null); // Reset to use metadata until audio loads
setAudioBlobUrl(null); // Clear old blob URL
} }
}, [r.id]); }, [r.id]);
@ -190,7 +220,7 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
const endSecs = highlightEndTime; // null for point highlight const endSecs = highlightEndTime; // null for point highlight
try { try {
const res = await fetch(`${API_BASE}/api/v1/highlights`, { const res = await window.authFetch(`${API_BASE}/api/v1/highlights`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@ -230,7 +260,7 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
const numericId = hid.startsWith('h-') ? hid.slice(2) : hid; const numericId = hid.startsWith('h-') ? hid.slice(2) : hid;
try { try {
const res = await fetch(`${API_BASE}/api/v1/highlights/${numericId}`, { const res = await window.authFetch(`${API_BASE}/api/v1/highlights/${numericId}`, {
method: 'DELETE', method: 'DELETE',
}); });
if (!res.ok && res.status !== 404) { if (!res.ok && res.status !== 404) {
@ -444,7 +474,8 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
</div> </div>
{/* Hidden audio element */} {/* Hidden audio element */}
<audio ref={audioRef} src={audioUrl} preload="metadata" /> {audioBlobUrl && <audio ref={audioRef} src={audioBlobUrl} preload="metadata" />}
{audioLoading && <div style={{ color: 'var(--text3)', fontSize: 11, fontFamily: 'var(--font-mono)' }}>Loading audio...</div>}
{/* Controls */} {/* Controls */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>