Files
synq/server/app/main.py
2026-05-18 14:46:37 -04:00

134 lines
4.6 KiB
Python

import logging
import os
import secrets
from pathlib import Path
from contextlib import asynccontextmanager
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
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("synq")
@asynccontextmanager
async def lifespan(_: FastAPI):
_load_token()
yield
app = FastAPI(title="synq", version="0.1.0", lifespan=lifespan)
_store: IdempotencyStore | None = None
_token_cache: str | None = None
def _load_token() -> str:
global _token_cache
if _token_cache:
return _token_cache
env_token = os.environ.get("PHONE_CAPTURE_TOKEN", "").strip()
if env_token and env_token != "change-me" and env_token != "change-me-long-random-token":
_token_cache = env_token
return _token_cache
token_file = Path(os.environ.get("PHONE_CAPTURE_DB_PATH", "/data/capture.sqlite3")).parent / "token.txt"
if token_file.exists():
stored = token_file.read_text().strip()
if stored:
_token_cache = stored
logger.info("synq token loaded from %s", token_file)
return _token_cache
generated = secrets.token_hex(32)
token_file.parent.mkdir(parents=True, exist_ok=True)
token_file.write_text(generated)
logger.info("")
logger.info("=" * 60)
logger.info(" SYNQ TOKEN (paste into app settings):")
logger.info(" %s", generated)
logger.info("=" * 60)
logger.info("")
_token_cache = generated
return _token_cache
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 _load_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:
logger.warning("rejected request: invalid token from %s", request.client.host if request.client else "unknown")
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:
org_file = Path(org_path)
org_file.parent.mkdir(parents=True, exist_ok=True)
if not org_file.exists():
org_file.write_text("#+title: synq captures\n#+startup: overview\n\n", encoding="utf-8")
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()
fields = [str(e.get("loc", "")) for e in errors]
logger.warning("rejected payload: validation failed fields=%s from=%s", fields, request.client.host if request.client else "unknown")
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)