86 lines
2.9 KiB
Python
86 lines
2.9 KiB
Python
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)
|