feat: Phase 0.5 — remote feed sync via Bearer token auth

Server (bincio/serve/server.py):
- Add _require_auth: accepts session cookie OR Authorization: Bearer token
- POST /api/auth/token: same as /api/auth/login but returns token in body
  (password used once, not stored; mobile stores only the session token)
- GET /api/feed: auth-gated; reads _merged/index.json for the user and
  returns the activities array as JSON

Mobile:
- db/sync.ts: syncFeed(db) fetches /api/feed, upserts each summary into
  local SQLite as origin='remote'; skips locally-imported activities
- db/queries.ts: add upsertRemoteActivity (INSERT ... ON CONFLICT DO UPDATE
  WHERE origin='remote' — never overwrites local imports); fix feed sort
  order to started_at DESC instead of insertion order
- settings.tsx: Connect section — password field (not persisted) + Connect
  button calls POST /api/auth/token and stores token; Disconnect clears it
- index.tsx: ↓ Sync button + pull-to-refresh both trigger syncFeed; cloud
  badge on remote activities; empty state updated
This commit is contained in:
Davide Scaini
2026-04-24 12:07:49 +02:00
parent 79c572bf8b
commit 44b2878b14
7 changed files with 9991 additions and 72 deletions
+49 -1
View File
@@ -22,7 +22,7 @@ from typing import Any, Optional
log = logging.getLogger("bincio.serve")
from fastapi import Cookie, FastAPI, File, Form, HTTPException, Request, Response, UploadFile
from fastapi import Cookie, Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile
from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
@@ -281,6 +281,24 @@ def _require_admin(bincio_session: Optional[str] = Cookie(default=None)) -> User
return user
def _require_auth(
request: Request,
bincio_session: Optional[str] = Cookie(default=None),
) -> User:
"""Accept session cookie (web) OR Authorization: Bearer token (mobile)."""
token = bincio_session
if not token:
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
token = auth[7:]
if not token:
raise HTTPException(401, "Not authenticated")
user = get_session(_get_db(), token)
if not user:
raise HTTPException(401, "Invalid or expired session")
return user
def _set_session_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=_SESSION_COOKIE,
@@ -499,6 +517,36 @@ async def logout(bincio_session: Optional[str] = Cookie(default=None)) -> JSONRe
return resp
@app.post("/api/auth/token")
async def get_token(login_req: LoginRequest, request: Request) -> JSONResponse:
"""Mobile auth: same as /api/auth/login but returns the token in the body instead of a cookie."""
ip = request.client.host if request.client else "unknown"
_check_rate_limit(ip, _login_attempts, _LOGIN_RATE_LIMIT, "Too many login attempts. Try again later.")
handle = login_req.handle.strip().lower()
user = authenticate(_get_db(), handle, login_req.password)
if not user:
raise HTTPException(401, "Invalid credentials")
token = create_session(_get_db(), handle)
return JSONResponse({
"ok": True,
"token": token,
"handle": user.handle,
"display_name": user.display_name,
})
@app.get("/api/feed")
async def get_feed(user: User = Depends(_require_auth)) -> JSONResponse:
"""Return the authenticated user's activity summaries (mobile feed sync)."""
dd = _get_data_dir()
user_dir = dd / user.handle
for index_path in (user_dir / "_merged" / "index.json", user_dir / "index.json"):
if index_path.exists():
index = json.loads(index_path.read_text())
return JSONResponse({"activities": index.get("activities", [])})
return JSONResponse({"activities": []})
@app.post("/api/auth/reset-password", response_model=GenericResponse)
async def reset_password(reset_req: ResetPasswordRequest) -> JSONResponse:
"""Validate a reset code and set a new password. Public endpoint."""