Compare commits

...

2 commits

Author SHA1 Message Date
Your Name
6c7b94adce added it
Some checks failed
Deploy API / deploy (push) Failing after 1s
Deploy UI / deploy (push) Successful in 1s
2026-05-19 20:24:58 -04:00
Your Name
176439c1df Add API key authentication to all endpoints 2026-05-19 20:19:02 -04:00
6 changed files with 222 additions and 29 deletions

View file

@ -34,5 +34,12 @@ jobs:
cd /work/hosting/clio/clio-api
/run/current-system/sw/bin/uv sync
- name: Restart service
run: |
sudo /run/current-system/sw/bin/systemctl restart phone-recorder
- name: Verify deployment
run: ls -la /work/hosting/clio/clio-api/
run: |
ls -la /work/hosting/clio/clio-api/
sleep 2
curl -s https://recordings.hallocks.xyz/health || echo "Health check failed"

1
.gitignore vendored
View file

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

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View file

@ -15,12 +15,21 @@ from datetime import datetime
from pathlib import Path
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.responses import FileResponse, JSONResponse
from pydantic import BaseModel
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 database
import importer
@ -92,7 +101,7 @@ async def health_check():
return {"status": "ok"}
@app.get("/api/v1/info")
@app.get("/api/v1/info", dependencies=[Depends(verify_api_key)])
async def server_info():
"""Server configuration info."""
return {
@ -110,21 +119,17 @@ async def get_public_key():
# Upload (existing functionality)
@app.post("/api/v1/recordings/upload")
@app.post("/api/v1/recordings/upload", dependencies=[Depends(verify_api_key)])
async def upload_recording(
file: UploadFile = File(...),
x_api_key: str = Header(None, alias="X-API-Key"),
):
"""
Receives an encrypted recording, decrypts it, and stores it.
The file is expected to be in the SREC encrypted format.
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:
raise HTTPException(status_code=400, detail="No filename provided")
@ -192,7 +197,7 @@ async def upload_recording(
# Recordings API
@app.get("/api/v1/recordings")
@app.get("/api/v1/recordings", dependencies=[Depends(verify_api_key)])
async def list_recordings(
source: Optional[str] = Query(None, description="Filter by source name"),
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):
"""Get recording details with transcript segments and highlights."""
recording = database.get_recording(recording_id)
@ -228,7 +233,7 @@ async def get_recording(recording_id: int):
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):
"""Stream the audio file for a recording."""
recording = database.get_recording(recording_id)
@ -256,7 +261,7 @@ async def get_recording_audio(recording_id: int):
# 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):
"""
Import transcription JSON files from a directory.
@ -286,7 +291,7 @@ async def import_transcriptions(request: ImportRequest):
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):
"""
Import audio files from a directory (without transcriptions).
@ -306,7 +311,7 @@ async def import_audio(request: ImportAudioRequest):
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")):
"""
Link audio files to existing transcription records.
@ -323,7 +328,7 @@ async def link_audio(audio_dir: str = Query(..., description="Directory containi
# Highlights API
@app.get("/api/v1/highlights")
@app.get("/api/v1/highlights", dependencies=[Depends(verify_api_key)])
async def list_highlights(
recording_id: Optional[int] = Query(None),
tag: Optional[str] = Query(None),
@ -338,7 +343,7 @@ async def list_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):
"""Create a new highlight/bookmark."""
# Verify recording exists
@ -358,7 +363,7 @@ async def create_highlight(highlight: HighlightCreate):
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):
"""Delete a highlight."""
success = database.delete_highlight(highlight_id)
@ -369,7 +374,7 @@ async def delete_highlight(highlight_id: int):
# Sources/Devices API
@app.get("/api/v1/sources")
@app.get("/api/v1/sources", dependencies=[Depends(verify_api_key)])
async def list_sources():
"""Get summary of all recording sources."""
sources = database.list_sources()
@ -378,7 +383,7 @@ async def list_sources():
# 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)")):
"""
Lists recording files on disk (legacy endpoint).

View file

@ -3,6 +3,36 @@ const { useState: useStateApp, useEffect: useEffectApp, useRef: useRefApp } = Re
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
function parseHash() {
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() {
const [data, setData] = useStateApp({ recordings: [], highlights: [], devices: {} });
const [loading, setLoading] = useStateApp(true);
const [error, setError] = useStateApp(null);
const [needsAuth, setNeedsAuth] = useStateApp(!getStoredApiKey());
// Lift highlights state so adding/removing works across views
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
const initialRoute = parseHash();
const [view, setViewState] = useStateApp(initialRoute.view);
@ -146,7 +272,7 @@ function App() {
async function fetchRecordingDetail(recordingId) {
setLoadingDetail(true);
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');
const rec = await res.json();
const transformed = transformRecording(rec);
@ -189,9 +315,9 @@ function App() {
async function fetchData() {
try {
const [recordingsRes, sourcesRes, highlightsRes] = await Promise.all([
fetch(`${API_BASE}/api/v1/recordings?limit=1000`),
fetch(`${API_BASE}/api/v1/sources`),
fetch(`${API_BASE}/api/v1/highlights?limit=1000`),
authFetch(`${API_BASE}/api/v1/recordings?limit=1000`),
authFetch(`${API_BASE}/api/v1/sources`),
authFetch(`${API_BASE}/api/v1/highlights?limit=1000`),
]);
if (!recordingsRes.ok) throw new Error('Failed to fetch recordings');
@ -256,6 +382,19 @@ function App() {
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) {
return (
<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'));
root.render(<App />);

View file

@ -12,6 +12,8 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
const [showAddHighlight, setShowAddHighlight] = useStateRD(false);
const [newHighlightTag, setNewHighlightTag] = useStateRD('important');
const [newHighlightNote, setNewHighlightNote] = useStateRD('');
const [audioBlobUrl, setAudioBlobUrl] = useStateRD(null);
const [audioLoading, setAudioLoading] = useStateRD(true);
const audioRef = 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 recHighlights = allHighlights.filter(h => h.recordingId === r.id);
// Audio URL from API
const audioUrl = `${API_BASE}/api/v1/recordings/${r.id}/audio`;
// Fetch audio with auth and create blob URL
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
const lastRecordingId = useRefRD(null);
@ -33,6 +62,7 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
hasAutoPlayed.current = false;
lastRecordingId.current = r.id;
setAudioDuration(null); // Reset to use metadata until audio loads
setAudioBlobUrl(null); // Clear old blob URL
}
}, [r.id]);
@ -190,7 +220,7 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
const endSecs = highlightEndTime; // null for point highlight
try {
const res = await fetch(`${API_BASE}/api/v1/highlights`, {
const res = await window.authFetch(`${API_BASE}/api/v1/highlights`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@ -230,7 +260,7 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
const numericId = hid.startsWith('h-') ? hid.slice(2) : hid;
try {
const res = await fetch(`${API_BASE}/api/v1/highlights/${numericId}`, {
const res = await window.authFetch(`${API_BASE}/api/v1/highlights/${numericId}`, {
method: 'DELETE',
});
if (!res.ok && res.status !== 404) {
@ -444,7 +474,8 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
</div>
{/* 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 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>