diff --git a/.forgejo/workflows/deploy-api.yml b/.forgejo/workflows/deploy-api.yml index 6d2e071..cd46818 100644 --- a/.forgejo/workflows/deploy-api.yml +++ b/.forgejo/workflows/deploy-api.yml @@ -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/ diff --git a/.gitignore b/.gitignore index eb8e9cf..a7054a2 100644 --- a/.gitignore +++ b/.gitignore @@ -71,7 +71,6 @@ local.properties # Claude settings (local) .claude/ -**/.claude/ # Data directories data/ diff --git a/clio-android/gradle/wrapper/gradle-wrapper.properties b/clio-android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 62f495d..0000000 --- a/clio-android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -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 diff --git a/clio-api/main.py b/clio-api/main.py index 5218b1b..405f676 100644 --- a/clio-api/main.py +++ b/clio-api/main.py @@ -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). diff --git a/clio-ui/components/App.jsx b/clio-ui/components/App.jsx index 0af2466..2673e25 100644 --- a/clio-ui/components/App.jsx +++ b/clio-ui/components/App.jsx @@ -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 ( -
-
-
- Enter API Key -
- 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 && ( -
- {error} -
- )} - -
-
- ); -} - 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 ( - { - 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 (
@@ -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(); diff --git a/clio-ui/components/RecordingDetail.jsx b/clio-ui/components/RecordingDetail.jsx index 45e22cb..9c939c4 100644 --- a/clio-ui/components/RecordingDetail.jsx +++ b/clio-ui/components/RecordingDetail.jsx @@ -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
{/* Hidden audio element */} - {audioBlobUrl &&