diff --git a/.forgejo/workflows/deploy-api.yml b/.forgejo/workflows/deploy-api.yml index cd46818..6d2e071 100644 --- a/.forgejo/workflows/deploy-api.yml +++ b/.forgejo/workflows/deploy-api.yml @@ -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" diff --git a/.gitignore b/.gitignore index a7054a2..eb8e9cf 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,7 @@ 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 new file mode 100644 index 0000000..62f495d --- /dev/null +++ b/clio-android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/clio-api/main.py b/clio-api/main.py index 405f676..5218b1b 100644 --- a/clio-api/main.py +++ b/clio-api/main.py @@ -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). diff --git a/clio-ui/components/App.jsx b/clio-ui/components/App.jsx index 2673e25..0af2466 100644 --- a/clio-ui/components/App.jsx +++ b/clio-ui/components/App.jsx @@ -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 ( +