Compare commits

..

No commits in common. "6c7b94adcec5120ab9a5124d81ccffd0b745e58b" and "56d8fb4d565dc4f7b71a022c9f292ae792556d64" have entirely different histories.

6 changed files with 29 additions and 222 deletions

View file

@ -34,12 +34,5 @@ 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/
sleep 2
curl -s https://recordings.hallocks.xyz/health || echo "Health check failed"
run: ls -la /work/hosting/clio/clio-api/

1
.gitignore vendored
View file

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

View file

@ -1,7 +0,0 @@
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,21 +15,12 @@ from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, UploadFile
from fastapi import 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
@ -101,7 +92,7 @@ async def health_check():
return {"status": "ok"}
@app.get("/api/v1/info", dependencies=[Depends(verify_api_key)])
@app.get("/api/v1/info")
async def server_info():
"""Server configuration info."""
return {
@ -119,17 +110,21 @@ async def get_public_key():
# Upload (existing functionality)
@app.post("/api/v1/recordings/upload", dependencies=[Depends(verify_api_key)])
@app.post("/api/v1/recordings/upload")
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.
Requires X-API-Key header if API_KEY is configured.
"""
# 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")
@ -197,7 +192,7 @@ async def upload_recording(
# Recordings API
@app.get("/api/v1/recordings", dependencies=[Depends(verify_api_key)])
@app.get("/api/v1/recordings")
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)"),
@ -224,7 +219,7 @@ async def list_recordings(
}
@app.get("/api/v1/recordings/{recording_id}", dependencies=[Depends(verify_api_key)])
@app.get("/api/v1/recordings/{recording_id}")
async def get_recording(recording_id: int):
"""Get recording details with transcript segments and highlights."""
recording = database.get_recording(recording_id)
@ -233,7 +228,7 @@ async def get_recording(recording_id: int):
return recording
@app.get("/api/v1/recordings/{recording_id}/audio", dependencies=[Depends(verify_api_key)])
@app.get("/api/v1/recordings/{recording_id}/audio")
async def get_recording_audio(recording_id: int):
"""Stream the audio file for a recording."""
recording = database.get_recording(recording_id)
@ -261,7 +256,7 @@ async def get_recording_audio(recording_id: int):
# Import API
@app.post("/api/v1/import/transcriptions", dependencies=[Depends(verify_api_key)])
@app.post("/api/v1/import/transcriptions")
async def import_transcriptions(request: ImportRequest):
"""
Import transcription JSON files from a directory.
@ -291,7 +286,7 @@ async def import_transcriptions(request: ImportRequest):
return result
@app.post("/api/v1/import/audio", dependencies=[Depends(verify_api_key)])
@app.post("/api/v1/import/audio")
async def import_audio(request: ImportAudioRequest):
"""
Import audio files from a directory (without transcriptions).
@ -311,7 +306,7 @@ async def import_audio(request: ImportAudioRequest):
return result
@app.post("/api/v1/import/link-audio", dependencies=[Depends(verify_api_key)])
@app.post("/api/v1/import/link-audio")
async def link_audio(audio_dir: str = Query(..., description="Directory containing audio files")):
"""
Link audio files to existing transcription records.
@ -328,7 +323,7 @@ async def link_audio(audio_dir: str = Query(..., description="Directory containi
# Highlights API
@app.get("/api/v1/highlights", dependencies=[Depends(verify_api_key)])
@app.get("/api/v1/highlights")
async def list_highlights(
recording_id: Optional[int] = Query(None),
tag: Optional[str] = Query(None),
@ -343,7 +338,7 @@ async def list_highlights(
return {"highlights": highlights, "count": len(highlights)}
@app.post("/api/v1/highlights", dependencies=[Depends(verify_api_key)])
@app.post("/api/v1/highlights")
async def create_highlight(highlight: HighlightCreate):
"""Create a new highlight/bookmark."""
# Verify recording exists
@ -363,7 +358,7 @@ async def create_highlight(highlight: HighlightCreate):
return {"id": highlight_id, "message": "Highlight created"}
@app.delete("/api/v1/highlights/{highlight_id}", dependencies=[Depends(verify_api_key)])
@app.delete("/api/v1/highlights/{highlight_id}")
async def delete_highlight(highlight_id: int):
"""Delete a highlight."""
success = database.delete_highlight(highlight_id)
@ -374,7 +369,7 @@ async def delete_highlight(highlight_id: int):
# Sources/Devices API
@app.get("/api/v1/sources", dependencies=[Depends(verify_api_key)])
@app.get("/api/v1/sources")
async def list_sources():
"""Get summary of all recording sources."""
sources = database.list_sources()
@ -383,7 +378,7 @@ async def list_sources():
# Legacy endpoint (for backwards compatibility)
@app.get("/api/v1/recordings/files", dependencies=[Depends(verify_api_key)])
@app.get("/api/v1/recordings/files")
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,36 +3,6 @@ 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';
@ -100,110 +70,14 @@ 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);
@ -272,7 +146,7 @@ function App() {
async function fetchRecordingDetail(recordingId) {
setLoadingDetail(true);
try {
const res = await authFetch(`${API_BASE}/api/v1/recordings/${recordingId}`);
const res = await fetch(`${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);
@ -315,9 +189,9 @@ function App() {
async function fetchData() {
try {
const [recordingsRes, sourcesRes, highlightsRes] = await Promise.all([
authFetch(`${API_BASE}/api/v1/recordings?limit=1000`),
authFetch(`${API_BASE}/api/v1/sources`),
authFetch(`${API_BASE}/api/v1/highlights?limit=1000`),
fetch(`${API_BASE}/api/v1/recordings?limit=1000`),
fetch(`${API_BASE}/api/v1/sources`),
fetch(`${API_BASE}/api/v1/highlights?limit=1000`),
]);
if (!recordingsRes.ok) throw new Error('Failed to fetch recordings');
@ -382,19 +256,6 @@ 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)' }}>
@ -502,8 +363,5 @@ 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,8 +12,6 @@ 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);
@ -22,35 +20,8 @@ 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);
// 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]);
// Audio URL from API
const audioUrl = `${API_BASE}/api/v1/recordings/${r.id}/audio`;
// Track recording ID to detect when recording changes
const lastRecordingId = useRefRD(null);
@ -62,7 +33,6 @@ 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]);
@ -220,7 +190,7 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
const endSecs = highlightEndTime; // null for point highlight
try {
const res = await window.authFetch(`${API_BASE}/api/v1/highlights`, {
const res = await fetch(`${API_BASE}/api/v1/highlights`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@ -260,7 +230,7 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
const numericId = hid.startsWith('h-') ? hid.slice(2) : hid;
try {
const res = await window.authFetch(`${API_BASE}/api/v1/highlights/${numericId}`, {
const res = await fetch(`${API_BASE}/api/v1/highlights/${numericId}`, {
method: 'DELETE',
});
if (!res.ok && res.status !== 404) {
@ -474,8 +444,7 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
</div>
{/* Hidden audio element */}
{audioBlobUrl && <audio ref={audioRef} src={audioBlobUrl} preload="metadata" />}
{audioLoading && <div style={{ color: 'var(--text3)', fontSize: 11, fontFamily: 'var(--font-mono)' }}>Loading audio...</div>}
<audio ref={audioRef} src={audioUrl} preload="metadata" />
{/* Controls */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>