Harden auth/upload, fix PR-delete cascade and sync backfill
- OIDC: require signed short-lived state on login callback; reject missing userinfo sub (account-takeover guard); validate token exchange + userinfo responses - Upload: safe zip extraction (path-traversal + zip-bomb cap), streamed size-capped writes, sanitised filenames - Garmin: increasing lookback resets last_sync_at for one-time backfill - Activities: delete/reprocess remove PersonalRecord rows (no FK cascade) - Profile: validate /weight limit; sync lookback UI copy - Dashboard: sleep shading uses same day as charted body battery Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import get_current_user
|
from app.core.security import get_current_user
|
||||||
from app.models.user import User, Activity, ActivityDataPoint, ActivityLap
|
from app.models.user import User, Activity, ActivityDataPoint, ActivityLap, PersonalRecord
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -266,6 +266,10 @@ async def delete_activity(
|
|||||||
activity = result.scalar_one_or_none()
|
activity = result.scalar_one_or_none()
|
||||||
if not activity:
|
if not activity:
|
||||||
raise HTTPException(status_code=404, detail="Activity not found")
|
raise HTTPException(status_code=404, detail="Activity not found")
|
||||||
|
# PersonalRecord.activity_id has no cascade, so remove the activity's PR rows
|
||||||
|
# first or the delete fails the FK constraint. (segment_efforts cascade in DB;
|
||||||
|
# data_points/laps cascade via the ORM relationship.)
|
||||||
|
await db.execute(delete(PersonalRecord).where(PersonalRecord.activity_id == activity_id))
|
||||||
await db.delete(activity)
|
await db.delete(activity)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
@@ -297,6 +301,8 @@ async def reprocess_activity(
|
|||||||
|
|
||||||
await db.execute(delete(ActivityDataPoint).where(ActivityDataPoint.activity_id == activity_id))
|
await db.execute(delete(ActivityDataPoint).where(ActivityDataPoint.activity_id == activity_id))
|
||||||
await db.execute(delete(ActivityLap).where(ActivityLap.activity_id == activity_id))
|
await db.execute(delete(ActivityLap).where(ActivityLap.activity_id == activity_id))
|
||||||
|
# Drop PR rows referencing this activity (no cascade); the re-parse re-computes them.
|
||||||
|
await db.execute(delete(PersonalRecord).where(PersonalRecord.activity_id == activity_id))
|
||||||
await db.delete(activity)
|
await db.delete(activity)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|||||||
+38
-1
@@ -19,6 +19,7 @@ router = APIRouter()
|
|||||||
# to a normal sign-in), so the callback attaches the passkey to a known user
|
# to a normal sign-in), so the callback attaches the passkey to a known user
|
||||||
# instead of creating/looking-up by identity.
|
# instead of creating/looking-up by identity.
|
||||||
LINK_STATE_PURPOSE = "pocketid-link"
|
LINK_STATE_PURPOSE = "pocketid-link"
|
||||||
|
LOGIN_STATE_PURPOSE = "pocketid-login"
|
||||||
|
|
||||||
|
|
||||||
def _make_link_state(user_id: int) -> str:
|
def _make_link_state(user_id: int) -> str:
|
||||||
@@ -29,6 +30,25 @@ def _make_link_state(user_id: int) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_login_state() -> str:
|
||||||
|
"""Signed, short-lived CSRF token proving the login flow started from this app."""
|
||||||
|
return create_access_token(
|
||||||
|
{"sub": "login", "purpose": LOGIN_STATE_PURPOSE},
|
||||||
|
expires_delta=timedelta(minutes=10),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_login_state(state: Optional[str]) -> bool:
|
||||||
|
"""True if `state` is a valid, unexpired login-state token we issued."""
|
||||||
|
if not state:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(state, settings.secret_key, algorithms=[settings.algorithm])
|
||||||
|
return payload.get("purpose") == LOGIN_STATE_PURPOSE
|
||||||
|
except JWTError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _decode_link_state(state: Optional[str]) -> Optional[int]:
|
def _decode_link_state(state: Optional[str]) -> Optional[int]:
|
||||||
"""Return the user id from a valid link-state token, else None."""
|
"""Return the user id from a valid link-state token, else None."""
|
||||||
if not state:
|
if not state:
|
||||||
@@ -157,6 +177,7 @@ async def pocketid_login_url(db: AsyncSession = Depends(get_db)):
|
|||||||
"redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback",
|
"redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback",
|
||||||
"response_type": "code",
|
"response_type": "code",
|
||||||
"scope": "openid profile email groups",
|
"scope": "openid profile email groups",
|
||||||
|
"state": _make_login_state(),
|
||||||
}
|
}
|
||||||
return {"url": f"{issuer}/authorize?{urlencode(params)}"}
|
return {"url": f"{issuer}/authorize?{urlencode(params)}"}
|
||||||
|
|
||||||
@@ -202,10 +223,15 @@ async def pocketid_callback(code: str, state: Optional[str] = None, db: AsyncSes
|
|||||||
print(f"PocketID token exchange failed ({resp.status_code}): {resp.text}")
|
print(f"PocketID token exchange failed ({resp.status_code}): {resp.text}")
|
||||||
raise HTTPException(status_code=400, detail="Token exchange failed")
|
raise HTTPException(status_code=400, detail="Token exchange failed")
|
||||||
tokens = resp.json()
|
tokens = resp.json()
|
||||||
|
access_token = tokens.get("access_token")
|
||||||
|
if not access_token:
|
||||||
|
raise HTTPException(status_code=400, detail="Token exchange failed")
|
||||||
userinfo_resp = await client.get(
|
userinfo_resp = await client.get(
|
||||||
f"{issuer}/api/oidc/userinfo",
|
f"{issuer}/api/oidc/userinfo",
|
||||||
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
)
|
)
|
||||||
|
if userinfo_resp.status_code != 200:
|
||||||
|
raise HTTPException(status_code=400, detail="Failed to fetch user info")
|
||||||
userinfo = userinfo_resp.json()
|
userinfo = userinfo_resp.json()
|
||||||
|
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
@@ -214,6 +240,12 @@ async def pocketid_callback(code: str, state: Optional[str] = None, db: AsyncSes
|
|||||||
email = userinfo.get("email")
|
email = userinfo.get("email")
|
||||||
preferred_username = userinfo.get("preferred_username") or email
|
preferred_username = userinfo.get("preferred_username") or email
|
||||||
|
|
||||||
|
# A missing subject means we cannot identify the user. Never continue, or the
|
||||||
|
# `pocketid_sub == sub` (== None → IS NULL) lookups below would match any
|
||||||
|
# password-only account and log the caller in as someone else.
|
||||||
|
if not sub:
|
||||||
|
return RedirectResponse(url="/login?auth_error=no_identity")
|
||||||
|
|
||||||
# ── Explicit account-link flow ──────────────────────────────────────────
|
# ── Explicit account-link flow ──────────────────────────────────────────
|
||||||
# Initiated by an already-authenticated user from their profile. Attach the
|
# Initiated by an already-authenticated user from their profile. Attach the
|
||||||
# passkey to that account. No group gating here: this is identity linking,
|
# passkey to that account. No group gating here: this is identity linking,
|
||||||
@@ -238,6 +270,11 @@ async def pocketid_callback(code: str, state: Optional[str] = None, db: AsyncSes
|
|||||||
target.email = email
|
target.email = email
|
||||||
return RedirectResponse(url="/profile?linked=1")
|
return RedirectResponse(url="/profile?linked=1")
|
||||||
|
|
||||||
|
# Normal sign-in: require the signed, short-lived state we issued in
|
||||||
|
# /pocketid/login-url, so the callback can't be driven by an injected code.
|
||||||
|
if not _valid_login_state(state):
|
||||||
|
return RedirectResponse(url="/login?auth_error=invalid_state")
|
||||||
|
|
||||||
# Group gating: if an allowed group is configured, the user must be in it.
|
# Group gating: if an allowed group is configured, the user must be in it.
|
||||||
allowed_group = await _get_allowed_group(db)
|
allowed_group = await _get_allowed_group(db)
|
||||||
if allowed_group:
|
if allowed_group:
|
||||||
|
|||||||
@@ -37,6 +37,17 @@ class GarminConfigOut(BaseModel):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
def _wants_more_history(old: int, new: int) -> bool:
|
||||||
|
"""True if `new` lookback requests older data than `old` (-1 = all-time)."""
|
||||||
|
if new == old:
|
||||||
|
return False
|
||||||
|
if new == -1: # all-time requested where it wasn't before
|
||||||
|
return True
|
||||||
|
if old == -1: # was all-time, now finite → narrower, not more
|
||||||
|
return False
|
||||||
|
return new > old
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config", response_model=GarminConfigOut)
|
@router.get("/config", response_model=GarminConfigOut)
|
||||||
async def get_config(
|
async def get_config(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -111,6 +122,16 @@ async def save_config(
|
|||||||
if not cfg:
|
if not cfg:
|
||||||
raise HTTPException(status_code=400, detail="No Garmin account connected — password required for first-time setup")
|
raise HTTPException(status_code=400, detail="No Garmin account connected — password required for first-time setup")
|
||||||
|
|
||||||
|
# If the user is now asking for MORE history than before, reset last_sync_at so
|
||||||
|
# the next sync treats it as a first sync and does a one-time backfill of the
|
||||||
|
# wider lookback window (then resumes cheap incremental syncs). Scheduled syncs
|
||||||
|
# otherwise only refresh the last day or two, so without this an increased
|
||||||
|
# lookback would never actually fetch the older data.
|
||||||
|
old_lookback = cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30
|
||||||
|
if _wants_more_history(old_lookback, body.sync_lookback_days):
|
||||||
|
cfg.last_sync_at = None
|
||||||
|
cfg.last_sync_status = "Lookback increased — backfill on next sync"
|
||||||
|
|
||||||
cfg.sync_enabled = body.sync_enabled
|
cfg.sync_enabled = body.sync_enabled
|
||||||
cfg.sync_activities = body.sync_activities
|
cfg.sync_activities = body.sync_activities
|
||||||
cfg.sync_wellness = body.sync_wellness
|
cfg.sync_wellness = body.sync_wellness
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, desc
|
from sqlalchemy import select, desc
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -192,7 +192,7 @@ class WeightOut(BaseModel):
|
|||||||
|
|
||||||
@router.get("/weight", response_model=List[WeightOut])
|
@router.get("/weight", response_model=List[WeightOut])
|
||||||
async def list_weight(
|
async def list_weight(
|
||||||
limit: int = 365,
|
limit: int = Query(365, ge=1, le=2000),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
|||||||
+82
-39
@@ -1,5 +1,4 @@
|
|||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, BackgroundTasks
|
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, BackgroundTasks
|
||||||
@@ -13,18 +12,68 @@ from app.workers.tasks import process_activity_file, process_garmin_health_zip
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
ALLOWED_EXTENSIONS = {".fit", ".gpx", ".zip"}
|
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB upload cap
|
||||||
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB
|
MAX_EXTRACT_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB total uncompressed cap (zip-bomb guard)
|
||||||
|
_CHUNK = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_name(filename: str) -> str:
|
||||||
|
"""Reduce an uploaded filename to a safe basename — no path traversal."""
|
||||||
|
name = os.path.basename((filename or "").replace("\\", "/"))
|
||||||
|
if not name or name in (".", ".."):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
def save_upload(upload: UploadFile, dest_dir: Path) -> Path:
|
def save_upload(upload: UploadFile, dest_dir: Path) -> Path:
|
||||||
|
"""Stream an upload to disk under dest_dir, enforcing the size cap."""
|
||||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
dest = dest_dir / upload.filename
|
dest = dest_dir / _safe_name(upload.filename)
|
||||||
|
size = 0
|
||||||
with open(dest, "wb") as f:
|
with open(dest, "wb") as f:
|
||||||
shutil.copyfileobj(upload.file, f)
|
while True:
|
||||||
|
chunk = upload.file.read(_CHUNK)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
size += len(chunk)
|
||||||
|
if size > MAX_FILE_SIZE:
|
||||||
|
f.close()
|
||||||
|
dest.unlink(missing_ok=True)
|
||||||
|
raise HTTPException(status_code=413, detail="File exceeds the 500 MB limit")
|
||||||
|
f.write(chunk)
|
||||||
return dest
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_extract(zf: zipfile.ZipFile, dest_dir: Path) -> list[Path]:
|
||||||
|
"""Extract a zip safely: skip path-traversal members, cap total uncompressed
|
||||||
|
bytes (zip-bomb guard). Returns the list of extracted regular-file paths."""
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest_root = dest_dir.resolve()
|
||||||
|
total = 0
|
||||||
|
extracted: list[Path] = []
|
||||||
|
for info in zf.infolist():
|
||||||
|
if info.is_dir():
|
||||||
|
continue
|
||||||
|
target = (dest_root / info.filename).resolve()
|
||||||
|
# Reject absolute paths and ../ traversal: the target must stay under dest_root.
|
||||||
|
if target != dest_root and dest_root not in target.parents:
|
||||||
|
continue
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with zf.open(info) as src, open(target, "wb") as out:
|
||||||
|
while True:
|
||||||
|
chunk = src.read(_CHUNK)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
total += len(chunk)
|
||||||
|
if total > MAX_EXTRACT_SIZE:
|
||||||
|
out.close()
|
||||||
|
target.unlink(missing_ok=True)
|
||||||
|
raise HTTPException(status_code=413, detail="Archive expands beyond the size limit")
|
||||||
|
out.write(chunk)
|
||||||
|
extracted.append(target)
|
||||||
|
return extracted
|
||||||
|
|
||||||
|
|
||||||
@router.post("/activity")
|
@router.post("/activity")
|
||||||
async def upload_activity(
|
async def upload_activity(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
@@ -62,35 +111,31 @@ async def upload_garmin_export(
|
|||||||
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "exports"
|
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "exports"
|
||||||
dest = save_upload(file, dest_dir)
|
dest = save_upload(file, dest_dir)
|
||||||
|
|
||||||
# Extract and queue all FIT files
|
# Extract (safely) and queue all FIT files
|
||||||
extract_dir = dest_dir / f"garmin_{dest.stem}"
|
extract_dir = dest_dir / f"garmin_{dest.stem}"
|
||||||
extract_dir.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
task_ids = []
|
task_ids = []
|
||||||
with zipfile.ZipFile(dest) as zf:
|
with zipfile.ZipFile(dest) as zf:
|
||||||
zf.extractall(extract_dir)
|
extracted = _safe_extract(zf, extract_dir)
|
||||||
for name in zf.namelist():
|
|
||||||
lower = name.lower()
|
for path in extracted:
|
||||||
if lower.endswith(".fit"):
|
suffix = path.suffix.lower()
|
||||||
fit_path = extract_dir / name
|
if suffix == ".fit":
|
||||||
task = process_activity_file.delay(str(fit_path), current_user.id, "fit")
|
task = process_activity_file.delay(str(path), current_user.id, "fit")
|
||||||
task_ids.append(task.id)
|
task_ids.append(task.id)
|
||||||
elif lower.endswith(".zip"):
|
elif suffix == ".zip":
|
||||||
# Garmin exports nest activity FIT files inside sub-zips
|
# Garmin exports nest activity FIT files inside sub-zips
|
||||||
# (e.g. DI-Connect-Uploaded-Files/UploadedFiles_*_Part*.zip)
|
# (e.g. DI-Connect-Uploaded-Files/UploadedFiles_*_Part*.zip)
|
||||||
nested_zip_path = extract_dir / name
|
nested_extract = path.parent / path.stem
|
||||||
nested_extract = nested_zip_path.parent / nested_zip_path.stem
|
try:
|
||||||
nested_extract.mkdir(exist_ok=True)
|
with zipfile.ZipFile(path) as nzf:
|
||||||
try:
|
nested = _safe_extract(nzf, nested_extract)
|
||||||
with zipfile.ZipFile(nested_zip_path) as nzf:
|
except zipfile.BadZipFile:
|
||||||
nzf.extractall(nested_extract)
|
nested = []
|
||||||
for nested_name in nzf.namelist():
|
for np in nested:
|
||||||
if nested_name.lower().endswith(".fit"):
|
if np.suffix.lower() == ".fit":
|
||||||
fit_path = nested_extract / nested_name
|
task = process_activity_file.delay(str(np), current_user.id, "fit")
|
||||||
task = process_activity_file.delay(str(fit_path), current_user.id, "fit")
|
task_ids.append(task.id)
|
||||||
task_ids.append(task.id)
|
|
||||||
except zipfile.BadZipFile:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Queue health/wellness data extraction
|
# Queue health/wellness data extraction
|
||||||
health_task = process_garmin_health_zip.delay(str(dest), current_user.id)
|
health_task = process_garmin_health_zip.delay(str(dest), current_user.id)
|
||||||
@@ -116,18 +161,16 @@ async def upload_strava_export(
|
|||||||
dest = save_upload(file, dest_dir)
|
dest = save_upload(file, dest_dir)
|
||||||
|
|
||||||
extract_dir = dest_dir / f"strava_{dest.stem}"
|
extract_dir = dest_dir / f"strava_{dest.stem}"
|
||||||
extract_dir.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
task_ids = []
|
task_ids = []
|
||||||
with zipfile.ZipFile(dest) as zf:
|
with zipfile.ZipFile(dest) as zf:
|
||||||
zf.extractall(extract_dir)
|
extracted = _safe_extract(zf, extract_dir)
|
||||||
for name in zf.namelist():
|
|
||||||
lower = name.lower()
|
for path in extracted:
|
||||||
if lower.endswith(".fit") or lower.endswith(".gpx"):
|
suffix = path.suffix.lower()
|
||||||
file_path = extract_dir / name
|
if suffix in (".fit", ".gpx"):
|
||||||
ext = Path(name).suffix[1:]
|
task = process_activity_file.delay(str(path), current_user.id, suffix[1:])
|
||||||
task = process_activity_file.delay(str(file_path), current_user.id, ext)
|
task_ids.append(task.id)
|
||||||
task_ids.append(task.id)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "queued",
|
"status": "queued",
|
||||||
|
|||||||
@@ -168,8 +168,11 @@ export default function DashboardPage() {
|
|||||||
date: rows[0]?.date ? rows[0].date.slice(0, 10) : null, // intraday endpoint wants YYYY-MM-DD
|
date: rows[0]?.date ? rows[0].date.slice(0, 10) : null, // intraday endpoint wants YYYY-MM-DD
|
||||||
resting_hr: pick('resting_hr'),
|
resting_hr: pick('resting_hr'),
|
||||||
sleep_duration_s: pick('sleep_duration_s'),
|
sleep_duration_s: pick('sleep_duration_s'),
|
||||||
sleep_start: pick('sleep_start'),
|
// Sleep window must come from the SAME day as `date` (the day whose intraday
|
||||||
sleep_end: pick('sleep_end'),
|
// body battery we chart), not the latest non-null — otherwise the sleep
|
||||||
|
// shading is aligned to a different night. Null here just means "no shading".
|
||||||
|
sleep_start: rows[0]?.sleep_start ?? null,
|
||||||
|
sleep_end: rows[0]?.sleep_end ?? null,
|
||||||
hrv_nightly_avg: pick('hrv_nightly_avg'),
|
hrv_nightly_avg: pick('hrv_nightly_avg'),
|
||||||
sleep_score: pick('sleep_score'),
|
sleep_score: pick('sleep_score'),
|
||||||
steps: pick('steps'),
|
steps: pick('steps'),
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const AUTH_ERRORS = {
|
|||||||
not_authorized: "Your account isn't permitted to access MileVault — ask the admin to add you to the allowed group.",
|
not_authorized: "Your account isn't permitted to access MileVault — ask the admin to add you to the allowed group.",
|
||||||
passkey_in_use: "That passkey is already linked to another account. Sign in to that account, or have an admin remove it on the Users page, then try linking again.",
|
passkey_in_use: "That passkey is already linked to another account. Sign in to that account, or have an admin remove it on the Users page, then try linking again.",
|
||||||
link_failed: "Couldn't link the passkey. Please try again.",
|
link_failed: "Couldn't link the passkey. Please try again.",
|
||||||
|
invalid_state: "Your sign-in link expired or was invalid. Please try signing in again.",
|
||||||
|
no_identity: "Couldn't read your identity from the provider. Please try again.",
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
|||||||
@@ -363,7 +363,7 @@ export default function ProfilePage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="Initial sync lookback days" hint="How far back to pull on the FIRST sync only (-1 = all history back to 2010). After that, scheduled syncs just refresh the last few days. To re-pull old history later, disconnect and reconnect.">
|
<Field label="Sync lookback days" hint="How far back to pull on the first sync (-1 = all history back to 2010). After that, scheduled syncs only refresh the last day or two. Increasing this value triggers a one-time backfill of the extra history on the next sync.">
|
||||||
<Input type="number" value={gcForm.sync_lookback_days} min={-1}
|
<Input type="number" value={gcForm.sync_lookback_days} min={-1}
|
||||||
onChange={e => setGcForm(f => ({ ...f, sync_lookback_days: e.target.value }))} />
|
onChange={e => setGcForm(f => ({ ...f, sync_lookback_days: e.target.value }))} />
|
||||||
{(() => { const n = parseInt(gcForm.sync_lookback_days, 10); return n > 365 && n !== -1 })() && (
|
{(() => { const n = parseInt(gcForm.sync_lookback_days, 10); return n > 365 && n !== -1 })() && (
|
||||||
|
|||||||
Reference in New Issue
Block a user