import logging import os from pathlib import Path from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from .models import CaptureRequest, CaptureResponse, HealthResponse from .org_writer import format_capture from .store import IdempotencyStore, get_file_lock logger = logging.getLogger("synq") app = FastAPI(title="synq", version="0.1.0") _store: IdempotencyStore | None = None def get_store() -> IdempotencyStore: global _store if _store is None: db_path = os.environ.get("PHONE_CAPTURE_DB_PATH", "/data/capture.sqlite3") _store = IdempotencyStore(db_path) return _store def _org_path() -> str: return os.environ.get("PHONE_CAPTURE_ORG_PATH", "/data/synq.org") def _token() -> str: return os.environ.get("PHONE_CAPTURE_TOKEN", "") async def check_token(request: Request) -> None: expected = _token() if not expected: raise HTTPException(status_code=500, detail="server token not configured") auth = request.headers.get("authorization", "") scheme, _, provided = auth.partition(" ") if scheme.lower() != "bearer" or provided != expected: raise HTTPException(status_code=401, detail="unauthorized") @app.get("/health", response_model=HealthResponse) async def health() -> HealthResponse: return HealthResponse(ok=True, service="synq", version="0.1.0") @app.post("/capture", response_model=CaptureResponse, dependencies=[Depends(check_token)]) async def capture(payload: CaptureRequest, store: IdempotencyStore = Depends(get_store)) -> CaptureResponse: if store.already_seen(payload.id): logger.info("duplicate capture id=%s", payload.id) return CaptureResponse(ok=True, status="already_seen", id=payload.id) entry = format_capture(payload) org_path = _org_path() lock = get_file_lock() with lock: Path(org_path).parent.mkdir(parents=True, exist_ok=True) with open(org_path, "a", encoding="utf-8") as f: f.write(entry) store.mark_seen(payload.id, payload.created_at) logger.info("accepted capture id=%s", payload.id) return CaptureResponse(ok=True, status="accepted", id=payload.id) @app.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: errors = exc.errors() for err in errors: if "body" in err.get("loc", ()): return JSONResponse(status_code=400, content={"detail": "body must not be empty"}) msg = str(errors[0]["msg"]) if errors else "invalid payload" return JSONResponse(status_code=400, content={"detail": msg}) if __name__ == "__main__": import uvicorn host = os.environ.get("PHONE_CAPTURE_HOST", "0.0.0.0") port = int(os.environ.get("PHONE_CAPTURE_PORT", "8765")) uvicorn.run("app.main:app", host=host, port=port, reload=False)