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:
+49
-1
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user