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") 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.responses import FileResponse, RedirectResponse, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
@@ -281,6 +281,24 @@ def _require_admin(bincio_session: Optional[str] = Cookie(default=None)) -> User
return 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: def _set_session_cookie(response: Response, token: str) -> None:
response.set_cookie( response.set_cookie(
key=_SESSION_COOKIE, key=_SESSION_COOKIE,
@@ -499,6 +517,36 @@ async def logout(bincio_session: Optional[str] = Cookie(default=None)) -> JSONRe
return resp 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) @app.post("/api/auth/reset-password", response_model=GenericResponse)
async def reset_password(reset_req: ResetPasswordRequest) -> JSONResponse: async def reset_password(reset_req: ResetPasswordRequest) -> JSONResponse:
"""Validate a reset code and set a new password. Public endpoint.""" """Validate a reset code and set a new password. Public endpoint."""
+73 -23
View File
@@ -1,43 +1,78 @@
import { useSQLiteContext } from 'expo-sqlite';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native'; import { useCallback, useState } from 'react';
import { FlatList, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native';
import { useActivities, type ActivitySummary } from '@/db/queries'; import { useActivities, type ActivitySummary } from '@/db/queries';
import { syncFeed } from '@/db/sync';
export default function FeedScreen() { export default function FeedScreen() {
const db = useSQLiteContext();
const activities = useActivities(); const activities = useActivities();
const [syncing, setSyncing] = useState(false);
const [syncMsg, setSyncMsg] = useState<string | null>(null);
const doSync = useCallback(async () => {
setSyncing(true);
setSyncMsg(null);
const result = await syncFeed(db);
setSyncing(false);
if (result.error) {
setSyncMsg(result.error);
} else if (result.synced === 0) {
setSyncMsg('Already up to date');
} else {
setSyncMsg(`${result.synced} ${result.synced === 1 ? 'activity' : 'activities'} synced`);
}
setTimeout(() => setSyncMsg(null), 3500);
}, [db]);
if (activities.length === 0) {
return ( return (
<View style={styles.container}>
<View style={styles.headerRow}>
<Text style={styles.header}>Feed</Text>
<Pressable
style={[styles.syncButton, syncing && styles.syncButtonDisabled]}
onPress={syncing ? undefined : doSync}
>
<Text style={styles.syncText}>{syncing ? 'Syncing…' : '↓ Sync'}</Text>
</Pressable>
</View>
{syncMsg && (
<Text style={styles.syncMsg}>{syncMsg}</Text>
)}
{activities.length === 0 && !syncing ? (
<View style={styles.empty}> <View style={styles.empty}>
<Text style={styles.emptyIcon}>🚴</Text> <Text style={styles.emptyIcon}>🚴</Text>
<Text style={styles.emptyTitle}>No activities yet</Text> <Text style={styles.emptyTitle}>No activities yet</Text>
<Text style={styles.emptyBody}> <Text style={styles.emptyBody}>
Go to Import to add a FIT, GPX, or TCX file. Import a file or tap Sync to pull from your instance.
</Text> </Text>
</View> </View>
); ) : (
}
return (
<View style={styles.container}>
<Text style={styles.header}>Feed</Text>
<FlatList <FlatList
data={activities} data={activities}
keyExtractor={(a) => a.id} keyExtractor={(a) => a.id}
renderItem={({ item }) => <ActivityCard activity={item} />} renderItem={({ item }) => <ActivityCard activity={item} />}
contentContainerStyle={styles.list} contentContainerStyle={styles.list}
refreshControl={
<RefreshControl
refreshing={syncing}
onRefresh={doSync}
tintColor="#60a5fa"
/> />
}
/>
)}
</View> </View>
); );
} }
function ActivityCard({ activity }: { activity: ActivitySummary }) { function ActivityCard({ activity }: { activity: ActivitySummary }) {
const router = useRouter(); const router = useRouter();
const km = activity.distance_m != null const km = activity.distance_m != null ? (activity.distance_m / 1000).toFixed(1) : null;
? (activity.distance_m / 1000).toFixed(1) const elev = activity.elevation_gain_m != null ? Math.round(activity.elevation_gain_m) : null;
: null;
const elev = activity.elevation_gain_m != null
? Math.round(activity.elevation_gain_m)
: null;
const date = new Date(activity.started_at).toLocaleDateString(undefined, { const date = new Date(activity.started_at).toLocaleDateString(undefined, {
day: 'numeric', month: 'short', year: 'numeric', day: 'numeric', month: 'short', year: 'numeric',
}); });
@@ -51,9 +86,10 @@ function ActivityCard({ activity }: { activity: ActivitySummary }) {
<Text style={styles.sportIcon}>{sportIcon(activity.sport)}</Text> <Text style={styles.sportIcon}>{sportIcon(activity.sport)}</Text>
<View style={styles.cardMeta}> <View style={styles.cardMeta}>
<Text style={styles.cardDate}>{date}</Text> <Text style={styles.cardDate}>{date}</Text>
{!activity.synced_at && activity.origin === 'local' && ( {activity.origin === 'remote'
<Text style={styles.unsyncedBadge}>local</Text> ? <Text style={styles.remoteBadge}>cloud</Text>
)} : !activity.synced_at && <Text style={styles.localBadge}>local</Text>
}
</View> </View>
</View> </View>
<Text style={styles.cardTitle} numberOfLines={1}>{activity.title}</Text> <Text style={styles.cardTitle} numberOfLines={1}>{activity.title}</Text>
@@ -83,10 +119,21 @@ function sportIcon(sport: string): string {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#09090b' }, container: { flex: 1, backgroundColor: '#09090b' },
header: { headerRow: {
color: '#fff', fontSize: 22, fontWeight: '700', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
paddingHorizontal: 16, paddingTop: 60, paddingBottom: 12, paddingHorizontal: 16, paddingTop: 60, paddingBottom: 12,
}, },
header: { color: '#fff', fontSize: 22, fontWeight: '700' },
syncButton: {
backgroundColor: '#1e3a5f', borderRadius: 8,
paddingHorizontal: 14, paddingVertical: 7,
},
syncButtonDisabled: { opacity: 0.5 },
syncText: { color: '#60a5fa', fontSize: 13, fontWeight: '600' },
syncMsg: {
color: '#a1a1aa', fontSize: 12, textAlign: 'center',
paddingHorizontal: 16, paddingBottom: 8,
},
list: { padding: 16, gap: 12 }, list: { padding: 16, gap: 12 },
card: { card: {
backgroundColor: '#18181b', borderRadius: 12, backgroundColor: '#18181b', borderRadius: 12,
@@ -96,7 +143,11 @@ const styles = StyleSheet.create({
sportIcon: { fontSize: 20 }, sportIcon: { fontSize: 20 },
cardMeta: { flexDirection: 'row', alignItems: 'center', gap: 8 }, cardMeta: { flexDirection: 'row', alignItems: 'center', gap: 8 },
cardDate: { color: '#71717a', fontSize: 12 }, cardDate: { color: '#71717a', fontSize: 12 },
unsyncedBadge: { remoteBadge: {
color: '#60a5fa', fontSize: 10, borderWidth: 1,
borderColor: '#1e3a5f', borderRadius: 4, paddingHorizontal: 4,
},
localBadge: {
color: '#a1a1aa', fontSize: 10, borderWidth: 1, color: '#a1a1aa', fontSize: 10, borderWidth: 1,
borderColor: '#3f3f46', borderRadius: 4, paddingHorizontal: 4, borderColor: '#3f3f46', borderRadius: 4, paddingHorizontal: 4,
}, },
@@ -106,8 +157,7 @@ const styles = StyleSheet.create({
statValue: { color: '#f4f4f5', fontSize: 16, fontWeight: '600' }, statValue: { color: '#f4f4f5', fontSize: 16, fontWeight: '600' },
statLabel: { color: '#71717a', fontSize: 12 }, statLabel: { color: '#71717a', fontSize: 12 },
empty: { empty: {
flex: 1, backgroundColor: '#09090b', flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32,
alignItems: 'center', justifyContent: 'center', padding: 32,
}, },
emptyIcon: { fontSize: 48, marginBottom: 16 }, emptyIcon: { fontSize: 48, marginBottom: 16 },
emptyTitle: { color: '#f4f4f5', fontSize: 18, fontWeight: '600', marginBottom: 8 }, emptyTitle: { color: '#f4f4f5', fontSize: 18, fontWeight: '600', marginBottom: 8 },
+108 -12
View File
@@ -1,7 +1,7 @@
import { useSQLiteContext } from 'expo-sqlite'; import { useSQLiteContext } from 'expo-sqlite';
import { useState } from 'react'; import { useState } from 'react';
import { import {
Platform, Pressable, ScrollView, StyleSheet, ActivityIndicator, Platform, Pressable, ScrollView, StyleSheet,
Text, TextInput, View, Text, TextInput, View,
} from 'react-native'; } from 'react-native';
import { getSetting, setSetting, useSetting } from '@/db/queries'; import { getSetting, setSetting, useSetting } from '@/db/queries';
@@ -12,12 +12,17 @@ export default function SettingsScreen() {
const storedUrl = useSetting('instance_url') ?? ''; const storedUrl = useSetting('instance_url') ?? '';
const storedHandle = useSetting('handle') ?? ''; const storedHandle = useSetting('handle') ?? '';
const storedPath = useSetting('auto_import_path') ?? ''; const storedPath = useSetting('auto_import_path') ?? '';
const storedToken = useSetting('api_token');
const [instanceUrl, setInstanceUrl] = useState(storedUrl); const [instanceUrl, setInstanceUrl] = useState(storedUrl);
const [handle, setHandle] = useState(storedHandle); const [handle, setHandle] = useState(storedHandle);
const [autoPath, setAutoPath] = useState(storedPath); const [autoPath, setAutoPath] = useState(storedPath);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const [password, setPassword] = useState('');
const [connecting, setConnecting] = useState(false);
const [connectMsg, setConnectMsg] = useState<{ ok: boolean; text: string } | null>(null);
async function save() { async function save() {
await setSetting(db, 'instance_url', instanceUrl.trim()); await setSetting(db, 'instance_url', instanceUrl.trim());
await setSetting(db, 'handle', handle.trim()); await setSetting(db, 'handle', handle.trim());
@@ -28,11 +33,51 @@ export default function SettingsScreen() {
setTimeout(() => setSaved(false), 2000); setTimeout(() => setSaved(false), 2000);
} }
async function connect() {
const url = instanceUrl.trim().replace(/\/$/, '');
const h = handle.trim();
if (!url || !h || !password) {
setConnectMsg({ ok: false, text: 'Fill in URL, handle, and password first.' });
return;
}
setConnecting(true);
setConnectMsg(null);
try {
const resp = await fetch(`${url}/api/auth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ handle: h, password }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
setConnectMsg({ ok: false, text: err.detail ?? `Error ${resp.status}` });
return;
}
const data = await resp.json();
await setSetting(db, 'instance_url', url);
await setSetting(db, 'handle', h);
await setSetting(db, 'api_token', data.token);
setPassword('');
setConnectMsg({ ok: true, text: `Connected as ${data.display_name || h}` });
} catch {
setConnectMsg({ ok: false, text: 'Could not reach instance — check the URL.' });
} finally {
setConnecting(false);
}
}
async function disconnect() {
await setSetting(db, 'api_token', '');
setConnectMsg(null);
}
const isConnected = !!storedToken;
return ( return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}> <ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.header}>Settings</Text> <Text style={styles.header}>Settings</Text>
<Section title="Instance (optional)"> <Section title="Instance">
<Field <Field
label="Instance URL" label="Instance URL"
placeholder="https://bincio.org" placeholder="https://bincio.org"
@@ -49,8 +94,53 @@ export default function SettingsScreen() {
autoCapitalize="none" autoCapitalize="none"
/> />
<Text style={styles.hint}> <Text style={styles.hint}>
Leave blank to use the app without a remote instance. When set, you can Connect to a Bincio instance to sync your activities. Leave blank to use
push activities to the instance and pull the web feed. the app offline only.
</Text>
</Section>
<Pressable style={styles.saveButton} onPress={save}>
<Text style={styles.saveButtonText}>
{saved ? '✓ Saved' : 'Save'}
</Text>
</Pressable>
<Section title="Connection">
{isConnected ? (
<>
<Row label="Status" value={`Connected as ${storedHandle || '—'}`} />
<Pressable style={styles.disconnectButton} onPress={disconnect}>
<Text style={styles.disconnectText}>Disconnect</Text>
</Pressable>
</>
) : (
<>
<Field
label="Password"
placeholder="••••••••"
value={password}
onChangeText={setPassword}
autoCapitalize="none"
secureTextEntry
/>
<Pressable
style={[styles.connectButton, connecting && styles.buttonDisabled]}
onPress={connecting ? undefined : connect}
>
{connecting
? <ActivityIndicator color="#fff" size="small" />
: <Text style={styles.connectText}>Connect</Text>}
</Pressable>
</>
)}
{connectMsg && (
<Text style={connectMsg.ok ? styles.msgOk : styles.msgErr}>
{connectMsg.text}
</Text>
)}
<Text style={styles.hint}>
Your password is used once to obtain a session token, then forgotten.
The token is stored locally and sent with each sync request.
</Text> </Text>
</Section> </Section>
@@ -70,16 +160,9 @@ export default function SettingsScreen() {
</Section> </Section>
)} )}
<Pressable style={styles.saveButton} onPress={save}>
<Text style={styles.saveButtonText}>
{saved ? '✓ Saved' : 'Save'}
</Text>
</Pressable>
<Section title="About"> <Section title="About">
<Row label="Version" value="0.1.0 (Phase 0)" /> <Row label="Version" value="0.1.0 (Phase 0.5)" />
<Row label="Schema" value="BAS 1.0" /> <Row label="Schema" value="BAS 1.0" />
<Row label="Extraction" value="Pyodide (Phase 1)" />
</Section> </Section>
</ScrollView> </ScrollView>
); );
@@ -156,4 +239,17 @@ const styles = StyleSheet.create({
paddingVertical: 14, alignItems: 'center', marginBottom: 28, paddingVertical: 14, alignItems: 'center', marginBottom: 28,
}, },
saveButtonText: { color: '#fff', fontWeight: '600', fontSize: 16 }, saveButtonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
connectButton: {
backgroundColor: '#059669', borderRadius: 8, margin: 12,
paddingVertical: 12, alignItems: 'center',
},
connectText: { color: '#fff', fontWeight: '600', fontSize: 15 },
buttonDisabled: { opacity: 0.5 },
disconnectButton: {
margin: 12, paddingVertical: 10, alignItems: 'center',
borderRadius: 8, borderWidth: 1, borderColor: '#3f3f46',
},
disconnectText: { color: '#71717a', fontSize: 14 },
msgOk: { color: '#86efac', fontSize: 13, paddingHorizontal: 12, paddingBottom: 10 },
msgErr: { color: '#fca5a5', fontSize: 13, paddingHorizontal: 12, paddingBottom: 10 },
}); });
+19 -1
View File
@@ -52,7 +52,7 @@ export function useActivities(): ActivitySummary[] {
json_extract(detail_json, '$.duration_s') AS duration_s, json_extract(detail_json, '$.duration_s') AS duration_s,
json_extract(detail_json, '$.elevation_gain_m') AS elevation_gain_m json_extract(detail_json, '$.elevation_gain_m') AS elevation_gain_m
FROM activities FROM activities
ORDER BY created_at DESC ORDER BY json_extract(detail_json, '$.started_at') DESC
`); `);
return rows; return rows;
} }
@@ -85,6 +85,24 @@ export async function insertActivity(
); );
} }
export async function upsertRemoteActivity(
db: ReturnType<typeof useSQLiteContext>,
id: string,
detailJson: string,
): Promise<boolean> {
const now = Math.floor(Date.now() / 1000);
const result = await db.runAsync(
`INSERT INTO activities (id, source_hash, detail_json, origin, synced_at)
VALUES (?, ?, ?, 'remote', ?)
ON CONFLICT(id) DO UPDATE SET
detail_json = excluded.detail_json,
synced_at = excluded.synced_at
WHERE origin = 'remote'`,
[id, id, detailJson, now],
);
return result.changes > 0;
}
// ── Settings ─────────────────────────────────────────────────────────────── // ── Settings ───────────────────────────────────────────────────────────────
export async function getSetting( export async function getSetting(
+65
View File
@@ -0,0 +1,65 @@
import type { SQLiteDatabase } from 'expo-sqlite';
import { getSetting, upsertRemoteActivity } from './queries';
export type SyncResult = { synced: number; error?: string };
export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
const instanceUrl = (await getSetting(db, 'instance_url'))?.replace(/\/$/, '');
const token = await getSetting(db, 'api_token');
if (!instanceUrl || !token) {
return { synced: 0, error: 'No instance configured — add one in Settings.' };
}
let resp: Response;
try {
resp = await fetch(`${instanceUrl}/api/feed`, {
headers: { Authorization: `Bearer ${token}` },
});
} catch {
return { synced: 0, error: 'Could not reach instance — check your connection.' };
}
if (resp.status === 401) {
return { synced: 0, error: 'Session expired — reconnect in Settings.' };
}
if (!resp.ok) {
return { synced: 0, error: `Server error (${resp.status})` };
}
const data: { activities?: RemoteSummary[] } = await resp.json();
const activities = data.activities ?? [];
let synced = 0;
for (const a of activities) {
const detailJson = JSON.stringify({
id: a.id,
title: a.title ?? a.id,
sport: a.sport ?? null,
started_at: a.started_at ?? null,
distance_m: a.distance_m ?? null,
moving_time_s: a.moving_time_s ?? null,
elevation_gain_m: a.elevation_gain_m ?? null,
avg_speed_kmh: a.avg_speed_kmh ?? null,
avg_hr_bpm: a.avg_hr_bpm ?? null,
avg_power_w: a.avg_power_w ?? null,
});
const changed = await upsertRemoteActivity(db, a.id, detailJson);
if (changed) synced++;
}
return { synced };
}
type RemoteSummary = {
id: string;
title?: string;
sport?: string;
started_at?: string;
distance_m?: number | null;
moving_time_s?: number | null;
elevation_gain_m?: number | null;
avg_speed_kmh?: number | null;
avg_hr_bpm?: number | null;
avg_power_w?: number | null;
};
+9641
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -3,13 +3,14 @@
"compilerOptions": { "compilerOptions": {
"strict": true, "strict": true,
"paths": { "paths": {
"@/*": ["./*"] "@/*": [
"./*"
]
} }
}, },
"include": [ "include": [
"**/*.ts", "**/*.ts",
"**/*.tsx", "**/*.tsx",
".expo/types/**/*.d.ts", ".expo/types/**/*.d.ts"
"expo-env.d.ts"
] ]
} }