- SettingsRepository: deviceLabel fallback changed from "android" to Build.MODEL so fresh installs show the actual device name (e.g. "Pixel 8") - server/main.py: _generate_passphrase() replaces secrets.token_hex(32). Picks 3 words from a 512-word embedded list, hyphen-separated (e.g. "coral-drift-lamp"). ~27 bits entropy, readable at a glance. Existing token.txt files are unaffected — only new generation changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
203 lines
9.4 KiB
Python
203 lines
9.4 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):
|
|
token = _load_token()
|
|
logger.info("=" * 60)
|
|
logger.info("synq token: %s", token)
|
|
logger.info("=" * 60)
|
|
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 = _generate_passphrase()
|
|
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 _generate_passphrase(words: int = 3) -> str:
|
|
wordlist = [
|
|
"able","acid","aged","also","area","army","away","baby","back","ball",
|
|
"band","bank","base","bath","bear","beat","been","bell","best","bill",
|
|
"bird","blow","blue","bold","bolt","bone","book","born","both","bowl",
|
|
"bulk","burn","calm","came","card","care","cart","case","cash","cast",
|
|
"cave","cell","chat","chip","city","clam","clay","clip","club","coal",
|
|
"coat","code","coil","cold","come","cook","cool","cope","cord","core",
|
|
"corn","cost","cove","crew","crop","curl","dare","dark","data","date",
|
|
"dawn","days","dead","deal","dean","dear","deck","deep","deer","deft",
|
|
"deny","desk","dial","diet","disc","dish","disk","dive","dock","dome",
|
|
"door","dose","down","draw","drew","drip","drop","drum","dual","dusk",
|
|
"dust","duty","each","earn","ease","east","edge","else","even","ever",
|
|
"evil","exam","fact","fail","fair","fall","fame","farm","fast","fate",
|
|
"feed","feel","feet","fell","felt","file","fill","film","find","fire",
|
|
"firm","fish","fist","flag","flat","flew","flip","flow","foam","fold",
|
|
"folk","fond","font","food","foot","ford","fork","form","fort","free",
|
|
"from","fuel","full","fund","fuse","gain","game","gaze","gear","give",
|
|
"glad","glow","glue","goal","gold","golf","gone","good","grab","gray",
|
|
"grid","grim","grip","grow","gulf","gust","hack","hail","half","hall",
|
|
"halt","hand","hang","hard","harm","harp","hash","haul","have","hawk",
|
|
"head","heal","heap","heat","heel","held","helm","help","herb","here",
|
|
"hide","high","hill","hint","hold","hole","home","hook","hope","horn",
|
|
"host","hour","hull","hunt","hurt","icon","idea","idle","inch","into",
|
|
"iris","iron","isle","item","jade","jail","jazz","jest","join","jump",
|
|
"just","keen","keep","kelp","kern","keys","kick","kill","kind","king",
|
|
"knit","know","lack","lake","lamp","land","lane","lark","lash","last",
|
|
"late","lead","leaf","lean","leap","left","lend","lens","life","lift",
|
|
"like","lime","line","link","lion","list","live","load","loan","lock",
|
|
"loft","long","look","loop","lore","loss","loud","love","luck","lung",
|
|
"lure","mail","main","make","male","malt","many","mark","mask","mast",
|
|
"math","maze","meal","mean","meet","melt","mesh","mild","milk","mill",
|
|
"mind","mine","mint","miss","mist","mode","moon","more","moss","most",
|
|
"move","much","mule","must","nail","name","navy","near","neat","need",
|
|
"nest","news","next","nice","node","none","noon","norm","note","null",
|
|
"numb","oath","obey","odds","once","only","open","oral","over","pace",
|
|
"pack","page","paid","pain","pair","palm","park","part","pass","past",
|
|
"path","pave","peak","peel","peer","pick","pier","pile","pine","pipe",
|
|
"plan","play","plea","plot","plow","plum","plunge","plus","poem","poet",
|
|
"pole","poll","pond","pool","port","pose","post","pour","prey","pull",
|
|
"pump","pure","push","quit","race","rack","raid","rail","rain","ramp",
|
|
"rang","rank","rare","rate","read","real","reed","reel","rely","rent",
|
|
"rest","rice","rich","ride","ring","rise","risk","road","roam","roar",
|
|
"rock","role","roll","roof","root","rope","rose","rout","rule","rush",
|
|
"rust","safe","sage","sail","salt","same","sand","sang","save","scan",
|
|
"seal","seam","seed","seek","self","sell","send","sent","shed","ship",
|
|
"shop","shot","show","shut","sick","side","sign","silk","sing","sink",
|
|
"site","size","skip","slab","slam","slap","slim","slip","slot","slow",
|
|
"snap","snow","soak","soar","sock","soil","sold","sole","some","song",
|
|
"soon","sort","soul","soup","span","spin","spit","spot","spur","star",
|
|
"stay","stem","step","stop","stub","such","suit","sunk","sure","surf",
|
|
"swap","swim","tail","take","talk","tall","tank","tape","task","team",
|
|
"tell","tend","tent","term","test","text","than","then","thin","tide",
|
|
"tile","time","tint","tiny","tips","tire","toad","told","toll","tomb",
|
|
"tool","tops","toss","tour","town","trap","tree","trim","trip","trod",
|
|
"true","tube","tuck","tune","turf","turn","twin","type","upon","used",
|
|
"vary","vast","veil","vein","very","vest","view","vine","void","volt",
|
|
"vote","wade","wake","walk","wall","wand","ward","warm","warp","wary",
|
|
"wave","ways","weld","well","went","west","what","when","wide","wiki",
|
|
"wild","will","wind","wine","wire","wise","wish","with","wolf","wood",
|
|
"word","work","worn","wrap","wren","yard","year","yell","your","zero",
|
|
"zest","zinc","zone","zoom",
|
|
]
|
|
return "-".join(secrets.choice(wordlist) for _ in range(words))
|
|
|
|
|
|
|
|
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)
|