garsync 0.1.4__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {garsync-0.1.4 → garsync-0.2.0}/PKG-INFO +10 -10
- {garsync-0.1.4 → garsync-0.2.0}/pyproject.toml +23 -19
- {garsync-0.1.4 → garsync-0.2.0}/src/garsync/__init__.py +1 -1
- garsync-0.2.0/src/garsync/api/auth.py +166 -0
- garsync-0.2.0/src/garsync/api/main.py +191 -0
- {garsync-0.1.4 → garsync-0.2.0}/src/garsync/api/routes/activities.py +6 -6
- {garsync-0.1.4 → garsync-0.2.0}/src/garsync/api/routes/biometrics.py +7 -5
- {garsync-0.1.4 → garsync-0.2.0}/src/garsync/api/routes/sleep.py +7 -5
- {garsync-0.1.4 → garsync-0.2.0}/src/garsync/api/routes/stats.py +14 -11
- garsync-0.2.0/src/garsync/api/schemas.py +119 -0
- {garsync-0.1.4 → garsync-0.2.0}/src/garsync/cli.py +9 -9
- {garsync-0.1.4 → garsync-0.2.0}/src/garsync/client.py +4 -5
- {garsync-0.1.4 → garsync-0.2.0}/src/garsync/db/repository.py +27 -28
- garsync-0.2.0/src/garsync/models.py +47 -0
- {garsync-0.1.4 → garsync-0.2.0}/src/garsync/pipeline.py +5 -5
- garsync-0.1.4/src/garsync/api/main.py +0 -83
- garsync-0.1.4/src/garsync/api/schemas.py +0 -121
- garsync-0.1.4/src/garsync/models.py +0 -47
- {garsync-0.1.4 → garsync-0.2.0}/README.md +0 -0
- {garsync-0.1.4 → garsync-0.2.0}/src/garsync/__main__.py +0 -0
- {garsync-0.1.4 → garsync-0.2.0}/src/garsync/api/__init__.py +0 -0
- {garsync-0.1.4 → garsync-0.2.0}/src/garsync/api/deps.py +0 -0
- {garsync-0.1.4 → garsync-0.2.0}/src/garsync/api/routes/__init__.py +0 -0
- {garsync-0.1.4 → garsync-0.2.0}/src/garsync/api/routes/sync.py +0 -0
- {garsync-0.1.4 → garsync-0.2.0}/src/garsync/db/__init__.py +2 -2
- {garsync-0.1.4 → garsync-0.2.0}/src/garsync/db/connection.py +0 -0
- {garsync-0.1.4 → garsync-0.2.0}/src/garsync/db/schema.py +0 -0
- {garsync-0.1.4 → garsync-0.2.0}/src/garsync/exporter.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: garsync
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary:
|
|
5
5
|
Author: Manu
|
|
6
6
|
Author-email: mlorentedev@gmail.com
|
|
@@ -9,16 +9,16 @@ Classifier: Programming Language :: Python :: 3
|
|
|
9
9
|
Classifier: Programming Language :: Python :: 3.12
|
|
10
10
|
Classifier: Programming Language :: Python :: 3.13
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.14
|
|
12
|
-
Requires-Dist: fastapi (>=0.
|
|
13
|
-
Requires-Dist: garminconnect (>=0.
|
|
14
|
-
Requires-Dist: pandas (<
|
|
15
|
-
Requires-Dist: plotly (>=
|
|
16
|
-
Requires-Dist: pydantic (>=2.
|
|
17
|
-
Requires-Dist: rich (>=
|
|
18
|
-
Requires-Dist: streamlit (>=1.
|
|
12
|
+
Requires-Dist: fastapi (>=0.141.1,<1.0.0)
|
|
13
|
+
Requires-Dist: garminconnect (>=0.3.11,<0.4.0)
|
|
14
|
+
Requires-Dist: pandas (<4)
|
|
15
|
+
Requires-Dist: plotly (>=7.0.0,<8.0.0)
|
|
16
|
+
Requires-Dist: pydantic (>=2.13.4,<3.0.0)
|
|
17
|
+
Requires-Dist: rich (>=15.0.0,<16.0.0)
|
|
18
|
+
Requires-Dist: streamlit (>=1.62.0,<2.0.0)
|
|
19
19
|
Requires-Dist: tenacity (>=9.1.4,<10.0.0)
|
|
20
|
-
Requires-Dist: typer (>=0.
|
|
21
|
-
Requires-Dist: uvicorn[standard] (>=0.
|
|
20
|
+
Requires-Dist: typer (>=0.27.1,<0.28.0)
|
|
21
|
+
Requires-Dist: uvicorn[standard] (>=0.52.4,<1.0.0)
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
|
|
24
24
|
# GarSync
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "garsync"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = ""
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "Manu",email = "mlorentedev@gmail.com"}
|
|
@@ -8,16 +8,16 @@ authors = [
|
|
|
8
8
|
readme = "README.md"
|
|
9
9
|
requires-python = ">=3.12"
|
|
10
10
|
dependencies = [
|
|
11
|
-
"garminconnect (>=0.
|
|
12
|
-
"pydantic (>=2.
|
|
11
|
+
"garminconnect (>=0.3.11,<0.4.0)",
|
|
12
|
+
"pydantic (>=2.13.4,<3.0.0)",
|
|
13
13
|
"tenacity (>=9.1.4,<10.0.0)",
|
|
14
|
-
"typer (>=0.
|
|
15
|
-
"rich (>=
|
|
16
|
-
"pandas (<
|
|
17
|
-
"streamlit (>=1.
|
|
18
|
-
"plotly (>=
|
|
19
|
-
"fastapi (>=0.
|
|
20
|
-
"uvicorn[standard] (>=0.
|
|
14
|
+
"typer (>=0.27.1,<0.28.0)",
|
|
15
|
+
"rich (>=15.0.0,<16.0.0)",
|
|
16
|
+
"pandas (<4)",
|
|
17
|
+
"streamlit (>=1.62.0,<2.0.0)",
|
|
18
|
+
"plotly (>=7.0.0,<8.0.0)",
|
|
19
|
+
"fastapi (>=0.141.1,<1.0.0)",
|
|
20
|
+
"uvicorn[standard] (>=0.52.4,<1.0.0)",
|
|
21
21
|
]
|
|
22
22
|
|
|
23
23
|
|
|
@@ -30,15 +30,15 @@ garsync = "garsync.cli:app"
|
|
|
30
30
|
|
|
31
31
|
[dependency-groups]
|
|
32
32
|
dev = [
|
|
33
|
-
"typer (>=0.
|
|
34
|
-
"rich (>=
|
|
35
|
-
"pydantic (>=2.
|
|
36
|
-
"pytest (>=9.
|
|
37
|
-
"pytest-cov (>=7.
|
|
38
|
-
"mypy (>=
|
|
39
|
-
"ruff (>=0.
|
|
40
|
-
"pytest-mock (>=3.
|
|
41
|
-
"httpx (>=0.28.
|
|
33
|
+
"typer (>=0.27.1,<0.28.0)",
|
|
34
|
+
"rich (>=15.0.0,<16.0.0)",
|
|
35
|
+
"pydantic (>=2.13.4,<3.0.0)",
|
|
36
|
+
"pytest (>=9.1.1,<10.0.0)",
|
|
37
|
+
"pytest-cov (>=7.1.0,<8.0.0)",
|
|
38
|
+
"mypy (>=2.3.1,<3.0.0)",
|
|
39
|
+
"ruff (>=0.16.5,<0.17.0)",
|
|
40
|
+
"pytest-mock (>=3.15.1,<4.0.0)",
|
|
41
|
+
"httpx (>=0.28.1,<1.0.0)"
|
|
42
42
|
]
|
|
43
43
|
|
|
44
44
|
[tool.mypy]
|
|
@@ -60,3 +60,7 @@ addopts = "-q"
|
|
|
60
60
|
[tool.ruff]
|
|
61
61
|
target-version = "py312"
|
|
62
62
|
line-length = 100
|
|
63
|
+
|
|
64
|
+
[tool.ruff.lint]
|
|
65
|
+
[tool.ruff.lint.flake8-bugbear]
|
|
66
|
+
extend-immutable-calls = ["fastapi.Depends", "fastapi.Query"]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""SEC-001 auth primitives: signed session cookies, login rate limiter, login page.
|
|
2
|
+
|
|
3
|
+
Stdlib only (hmac/hashlib/secrets) — no new dependencies. The session signing key
|
|
4
|
+
IS the access password: rotating the password invalidates all sessions (accepted
|
|
5
|
+
in specs/SEC-001/proposal.md).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import hmac
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
SESSION_COOKIE_NAME = "garsync_session"
|
|
13
|
+
SESSION_TTL_SECONDS = 7 * 24 * 3600
|
|
14
|
+
|
|
15
|
+
LOGIN_MAX_FAILURES = 5
|
|
16
|
+
LOGIN_WINDOW_SECONDS = 300
|
|
17
|
+
|
|
18
|
+
_LOGIN_PAGE_HTML = """<!doctype html>
|
|
19
|
+
<html lang="en">
|
|
20
|
+
<head>
|
|
21
|
+
<meta charset="UTF-8" />
|
|
22
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
23
|
+
<meta name="robots" content="noindex, nofollow" />
|
|
24
|
+
<meta name="color-scheme" content="dark" />
|
|
25
|
+
<title>Sign in</title>
|
|
26
|
+
<style>
|
|
27
|
+
:root {
|
|
28
|
+
color-scheme: dark;
|
|
29
|
+
--bg: #0f172a;
|
|
30
|
+
--card: #1e293b;
|
|
31
|
+
--line: #334155;
|
|
32
|
+
--text: #f8fafc;
|
|
33
|
+
--muted: #94a3b8;
|
|
34
|
+
--accent: #60a5fa;
|
|
35
|
+
}
|
|
36
|
+
* { box-sizing: border-box; }
|
|
37
|
+
body {
|
|
38
|
+
margin: 0; min-height: 100vh; min-height: 100dvh; padding: 1.5rem 1rem;
|
|
39
|
+
display: flex; align-items: center; justify-content: center;
|
|
40
|
+
background: var(--bg); color: var(--text);
|
|
41
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
42
|
+
font-size: 16px; line-height: 1.5; -webkit-font-smoothing: antialiased;
|
|
43
|
+
}
|
|
44
|
+
main {
|
|
45
|
+
width: 100%; max-width: 22rem; padding: 1.75rem;
|
|
46
|
+
background: var(--card); border: 1px solid var(--line);
|
|
47
|
+
border-radius: 0.75rem; box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.35);
|
|
48
|
+
}
|
|
49
|
+
h1 { margin: 0 0 0.25rem; font-size: 1.5rem; font-weight: 700; color: var(--accent); }
|
|
50
|
+
.hint { margin: 0 0 1.25rem; color: var(--muted); font-size: 0.875rem; }
|
|
51
|
+
label {
|
|
52
|
+
display: block; margin-bottom: 0.375rem;
|
|
53
|
+
font-size: 0.75rem; font-weight: 600; text-transform: uppercase;
|
|
54
|
+
letter-spacing: 0.05em; color: var(--muted);
|
|
55
|
+
}
|
|
56
|
+
input[type="password"] {
|
|
57
|
+
width: 100%; padding: 0.625rem 0.75rem; font: inherit;
|
|
58
|
+
color: var(--text); background: var(--bg);
|
|
59
|
+
border: 1px solid var(--line); border-radius: 0.5rem; letter-spacing: 0.02em;
|
|
60
|
+
}
|
|
61
|
+
input[type="password"]:hover { border-color: var(--muted); }
|
|
62
|
+
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
63
|
+
button {
|
|
64
|
+
width: 100%; margin-top: 1rem; padding: 0.625rem 1rem;
|
|
65
|
+
border: 0; border-radius: 0.5rem; font: inherit; font-weight: 600;
|
|
66
|
+
background: #2563eb; color: var(--text); cursor: pointer;
|
|
67
|
+
}
|
|
68
|
+
button:hover { background: #1d4ed8; }
|
|
69
|
+
.error {
|
|
70
|
+
margin: 0 0 1.25rem; padding: 0.625rem 0.75rem; font-size: 0.875rem;
|
|
71
|
+
color: #fca5a5; background: rgb(127 29 29 / 0.28);
|
|
72
|
+
border: 1px solid #7f1d1d; border-left-width: 4px; border-radius: 0.5rem;
|
|
73
|
+
}
|
|
74
|
+
.error:empty { display: none; }
|
|
75
|
+
footer { margin-top: 1.25rem; color: var(--muted); font-size: 0.75rem; }
|
|
76
|
+
@media (min-width: 40rem) { main { padding: 2rem; } }
|
|
77
|
+
</style>
|
|
78
|
+
</head>
|
|
79
|
+
<body>
|
|
80
|
+
<main>
|
|
81
|
+
<h1>Sign in</h1>
|
|
82
|
+
<p class="hint">This dashboard is private. Enter the access password to continue.</p>
|
|
83
|
+
|
|
84
|
+
<div class="error" role="alert">{{ERROR}}</div>
|
|
85
|
+
|
|
86
|
+
<form method="POST" action="/login">
|
|
87
|
+
<label for="password">Password</label>
|
|
88
|
+
<input
|
|
89
|
+
id="password"
|
|
90
|
+
name="password"
|
|
91
|
+
type="password"
|
|
92
|
+
autocomplete="current-password"
|
|
93
|
+
autocapitalize="off"
|
|
94
|
+
spellcheck="false"
|
|
95
|
+
maxlength="256"
|
|
96
|
+
required
|
|
97
|
+
autofocus
|
|
98
|
+
/>
|
|
99
|
+
<button type="submit">Sign in</button>
|
|
100
|
+
</form>
|
|
101
|
+
|
|
102
|
+
<footer>Repeated failed attempts are temporarily blocked.</footer>
|
|
103
|
+
</main>
|
|
104
|
+
</body>
|
|
105
|
+
</html>
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def render_login_page(error: str = "") -> str:
|
|
110
|
+
"""Render the login page with the error banner filled (empty collapses it)."""
|
|
111
|
+
return _LOGIN_PAGE_HTML.replace("{{ERROR}}", error)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def make_session_token(access_password: str, now: float | None = None) -> str:
|
|
115
|
+
"""Return '<expiry>.<hmac>' — signature keyed by the access password."""
|
|
116
|
+
current = time.time() if now is None else now
|
|
117
|
+
expiry = int(current + SESSION_TTL_SECONDS)
|
|
118
|
+
payload = f"garsync-session|{expiry}"
|
|
119
|
+
sig = hmac.new(access_password.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
|
120
|
+
return f"{expiry}.{sig}"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def verify_session_token(token: str | None, access_password: str, now: float | None = None) -> bool:
|
|
124
|
+
"""Constant-time token verification; False on tamper, bad format or expiry."""
|
|
125
|
+
if not token or not access_password:
|
|
126
|
+
return False
|
|
127
|
+
parts = token.split(".", 1)
|
|
128
|
+
if len(parts) != 2 or not parts[0].isdigit():
|
|
129
|
+
return False
|
|
130
|
+
expiry_str, provided_sig = parts
|
|
131
|
+
expected = hmac.new(
|
|
132
|
+
access_password.encode(),
|
|
133
|
+
f"garsync-session|{expiry_str}".encode(),
|
|
134
|
+
hashlib.sha256,
|
|
135
|
+
).hexdigest()
|
|
136
|
+
if not hmac.compare_digest(provided_sig, expected):
|
|
137
|
+
return False
|
|
138
|
+
current = time.time() if now is None else now
|
|
139
|
+
return int(expiry_str) > current
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class LoginRateLimiter:
|
|
143
|
+
"""In-memory per-client-IP limiter for failed logins (single-process apps)."""
|
|
144
|
+
|
|
145
|
+
def __init__(
|
|
146
|
+
self, max_failures: int = LOGIN_MAX_FAILURES, window_seconds: int = LOGIN_WINDOW_SECONDS
|
|
147
|
+
) -> None:
|
|
148
|
+
self.max_failures = max_failures
|
|
149
|
+
self.window_seconds = window_seconds
|
|
150
|
+
self._failures: dict[str, list[float]] = {}
|
|
151
|
+
|
|
152
|
+
def _purge(self, client_ip: str) -> list[float]:
|
|
153
|
+
cutoff = time.time() - self.window_seconds
|
|
154
|
+
recent = [t for t in self._failures.get(client_ip, []) if t > cutoff]
|
|
155
|
+
self._failures[client_ip] = recent
|
|
156
|
+
return recent
|
|
157
|
+
|
|
158
|
+
def is_blocked(self, client_ip: str) -> bool:
|
|
159
|
+
return len(self._purge(client_ip)) >= self.max_failures
|
|
160
|
+
|
|
161
|
+
def record_failure(self, client_ip: str) -> None:
|
|
162
|
+
self._purge(client_ip)
|
|
163
|
+
self._failures[client_ip].append(time.time())
|
|
164
|
+
|
|
165
|
+
def reset(self, client_ip: str) -> None:
|
|
166
|
+
self._failures.pop(client_ip, None)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""FastAPI application factory for garsync."""
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sqlite3
|
|
7
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import parse_qs
|
|
11
|
+
|
|
12
|
+
from fastapi import FastAPI, Request, Response, status
|
|
13
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
14
|
+
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
15
|
+
from fastapi.staticfiles import StaticFiles
|
|
16
|
+
|
|
17
|
+
from garsync.api.auth import (
|
|
18
|
+
SESSION_COOKIE_NAME,
|
|
19
|
+
SESSION_TTL_SECONDS,
|
|
20
|
+
LoginRateLimiter,
|
|
21
|
+
make_session_token,
|
|
22
|
+
render_login_page,
|
|
23
|
+
verify_session_token,
|
|
24
|
+
)
|
|
25
|
+
from garsync.api.routes import activities, biometrics, sleep, stats, sync
|
|
26
|
+
from garsync.db.connection import get_connection
|
|
27
|
+
from garsync.db.schema import init_db
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger("garsync.auth")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@asynccontextmanager
|
|
33
|
+
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
34
|
+
"""Manage database connection lifecycle."""
|
|
35
|
+
db_path = os.environ.get("GARSYNC_DB_PATH", "data/garsync.db")
|
|
36
|
+
conn = get_connection(db_path)
|
|
37
|
+
init_db(conn)
|
|
38
|
+
app.state.db = conn
|
|
39
|
+
yield
|
|
40
|
+
conn.close()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def create_app(conn: sqlite3.Connection | None = None) -> FastAPI:
|
|
44
|
+
"""Create and configure the FastAPI application.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
conn: Optional pre-configured connection (for testing).
|
|
48
|
+
If provided, lifespan is skipped.
|
|
49
|
+
"""
|
|
50
|
+
if conn is not None:
|
|
51
|
+
app = FastAPI(title="GarSync API", version="0.1.0")
|
|
52
|
+
app.state.db = conn
|
|
53
|
+
else:
|
|
54
|
+
app = FastAPI(title="GarSync API", version="0.1.0", lifespan=lifespan)
|
|
55
|
+
|
|
56
|
+
access_password = os.environ.get("GARSYNC_ACCESS_PASSWORD")
|
|
57
|
+
api_key = os.environ.get("GARSYNC_API_KEY")
|
|
58
|
+
auth_active = bool(access_password or api_key)
|
|
59
|
+
insecure_cookies = os.environ.get("GARSYNC_INSECURE_COOKIES") == "1"
|
|
60
|
+
limiter = LoginRateLimiter()
|
|
61
|
+
|
|
62
|
+
if not auth_active:
|
|
63
|
+
logger.warning(
|
|
64
|
+
"Auth is DISABLED: neither GARSYNC_ACCESS_PASSWORD nor GARSYNC_API_KEY "
|
|
65
|
+
"is set. The API and dashboard are UNPROTECTED. This is only acceptable "
|
|
66
|
+
"on a trusted local network."
|
|
67
|
+
)
|
|
68
|
+
elif not access_password:
|
|
69
|
+
logger.warning(
|
|
70
|
+
"Dashboard is UNAUTHENTICATED: GARSYNC_ACCESS_PASSWORD is not set. "
|
|
71
|
+
"Only /api/* requires the API key. Set GARSYNC_ACCESS_PASSWORD before "
|
|
72
|
+
"deploying publicly."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
@app.middleware("http")
|
|
76
|
+
async def auth_gate(
|
|
77
|
+
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
|
78
|
+
) -> Response:
|
|
79
|
+
"""SEC-001 gate: session cookie for pages, session-or-API-key for /api/*.
|
|
80
|
+
|
|
81
|
+
401 = no credentials presented; 403 = credentials presented but invalid.
|
|
82
|
+
"""
|
|
83
|
+
if not auth_active:
|
|
84
|
+
return await call_next(request)
|
|
85
|
+
|
|
86
|
+
path = request.url.path
|
|
87
|
+
if path in ("/login", "/logout"):
|
|
88
|
+
return await call_next(request)
|
|
89
|
+
|
|
90
|
+
if path.startswith("/api/"):
|
|
91
|
+
if api_key:
|
|
92
|
+
provided = request.headers.get("X-API-KEY")
|
|
93
|
+
if provided is not None:
|
|
94
|
+
if hmac.compare_digest(provided, api_key):
|
|
95
|
+
return await call_next(request)
|
|
96
|
+
return JSONResponse(
|
|
97
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
98
|
+
content={"detail": "Invalid API key"},
|
|
99
|
+
)
|
|
100
|
+
if access_password and verify_session_token(
|
|
101
|
+
request.cookies.get(SESSION_COOKIE_NAME), access_password
|
|
102
|
+
):
|
|
103
|
+
return await call_next(request)
|
|
104
|
+
return JSONResponse(
|
|
105
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
106
|
+
content={"detail": "Authentication required"},
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if access_password:
|
|
110
|
+
if verify_session_token(request.cookies.get(SESSION_COOKIE_NAME), access_password):
|
|
111
|
+
return await call_next(request)
|
|
112
|
+
return RedirectResponse("/login", status_code=status.HTTP_302_FOUND)
|
|
113
|
+
|
|
114
|
+
return await call_next(request)
|
|
115
|
+
|
|
116
|
+
@app.get("/login", include_in_schema=False)
|
|
117
|
+
async def login_page(request: Request) -> Response:
|
|
118
|
+
error = "Invalid password. Try again." if "error" in request.query_params else ""
|
|
119
|
+
return HTMLResponse(render_login_page(error))
|
|
120
|
+
|
|
121
|
+
@app.post("/login", include_in_schema=False)
|
|
122
|
+
async def login_submit(request: Request) -> Response:
|
|
123
|
+
client_ip = request.client.host if request.client else "unknown"
|
|
124
|
+
if limiter.is_blocked(client_ip):
|
|
125
|
+
return HTMLResponse(
|
|
126
|
+
render_login_page("Too many failed attempts. Try again later."),
|
|
127
|
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
form = parse_qs((await request.body()).decode())
|
|
131
|
+
supplied = form.get("password", [""])[0]
|
|
132
|
+
|
|
133
|
+
if access_password and hmac.compare_digest(supplied, access_password):
|
|
134
|
+
limiter.reset(client_ip)
|
|
135
|
+
response = RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
|
|
136
|
+
response.set_cookie(
|
|
137
|
+
SESSION_COOKIE_NAME,
|
|
138
|
+
make_session_token(access_password),
|
|
139
|
+
max_age=SESSION_TTL_SECONDS,
|
|
140
|
+
httponly=True,
|
|
141
|
+
samesite="lax",
|
|
142
|
+
secure=not insecure_cookies,
|
|
143
|
+
)
|
|
144
|
+
return response
|
|
145
|
+
|
|
146
|
+
limiter.record_failure(client_ip)
|
|
147
|
+
return HTMLResponse(
|
|
148
|
+
render_login_page("Invalid password. Try again."),
|
|
149
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
@app.post("/logout", include_in_schema=False)
|
|
153
|
+
async def logout() -> Response:
|
|
154
|
+
response = RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER)
|
|
155
|
+
response.delete_cookie(SESSION_COOKIE_NAME)
|
|
156
|
+
return response
|
|
157
|
+
|
|
158
|
+
allowed_origins = [
|
|
159
|
+
origin.strip()
|
|
160
|
+
for origin in os.environ.get("GARSYNC_ALLOWED_ORIGINS", "").split(",")
|
|
161
|
+
if origin.strip()
|
|
162
|
+
]
|
|
163
|
+
if "*" in allowed_origins:
|
|
164
|
+
msg = (
|
|
165
|
+
"GARSYNC_ALLOWED_ORIGINS must list explicit origins; '*' is not allowed "
|
|
166
|
+
"because the API uses credentialed (cookie / X-API-KEY) requests."
|
|
167
|
+
)
|
|
168
|
+
raise ValueError(msg)
|
|
169
|
+
if allowed_origins:
|
|
170
|
+
app.add_middleware(
|
|
171
|
+
CORSMiddleware,
|
|
172
|
+
allow_origins=allowed_origins,
|
|
173
|
+
allow_credentials=True,
|
|
174
|
+
allow_methods=["GET", "POST"],
|
|
175
|
+
allow_headers=["X-API-KEY", "Content-Type"],
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
app.include_router(activities.router)
|
|
179
|
+
app.include_router(biometrics.router)
|
|
180
|
+
app.include_router(sleep.router)
|
|
181
|
+
app.include_router(stats.router)
|
|
182
|
+
app.include_router(sync.router)
|
|
183
|
+
|
|
184
|
+
static_dir = Path(__file__).resolve().parents[3] / "frontend" / "dist"
|
|
185
|
+
if static_dir.is_dir():
|
|
186
|
+
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
|
|
187
|
+
|
|
188
|
+
return app
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
app = create_app()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Activity endpoints."""
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import datetime
|
|
4
4
|
|
|
5
5
|
from fastapi import APIRouter, Depends, Query
|
|
6
6
|
|
|
@@ -15,17 +15,17 @@ router = APIRouter(prefix="/api", tags=["activities"])
|
|
|
15
15
|
def list_activities(
|
|
16
16
|
page: int = Query(default=1, ge=1),
|
|
17
17
|
limit: int = Query(default=20, ge=1, le=100),
|
|
18
|
-
start_date:
|
|
19
|
-
end_date:
|
|
20
|
-
activity_type:
|
|
18
|
+
start_date: datetime.date | None = Query(default=None),
|
|
19
|
+
end_date: datetime.date | None = Query(default=None),
|
|
20
|
+
activity_type: str | None = Query(default=None),
|
|
21
21
|
repo: ActivityRepository = Depends(get_activity_repo),
|
|
22
22
|
) -> PaginatedActivities:
|
|
23
23
|
"""List activities with pagination and optional filters."""
|
|
24
24
|
rows, total = repo.get_paginated(
|
|
25
25
|
page=page,
|
|
26
26
|
limit=limit,
|
|
27
|
-
start_date=start_date,
|
|
28
|
-
end_date=end_date,
|
|
27
|
+
start_date=start_date.isoformat() if start_date else None,
|
|
28
|
+
end_date=end_date.isoformat() if end_date else None,
|
|
29
29
|
activity_type=activity_type,
|
|
30
30
|
)
|
|
31
31
|
items = [ActivityItem(**dict(row)) for row in rows]
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Biometrics endpoints."""
|
|
2
2
|
|
|
3
|
+
import datetime
|
|
4
|
+
|
|
3
5
|
from fastapi import APIRouter, Depends, Query
|
|
4
6
|
|
|
5
7
|
from garsync.api.deps import get_biometrics_repo
|
|
@@ -11,16 +13,16 @@ router = APIRouter(prefix="/api", tags=["biometrics"])
|
|
|
11
13
|
|
|
12
14
|
@router.get("/biometrics", response_model=BiometricsResponse)
|
|
13
15
|
def list_biometrics(
|
|
14
|
-
start_date:
|
|
15
|
-
end_date:
|
|
16
|
+
start_date: datetime.date = Query(),
|
|
17
|
+
end_date: datetime.date = Query(),
|
|
16
18
|
repo: BiometricsRepository = Depends(get_biometrics_repo),
|
|
17
19
|
) -> BiometricsResponse:
|
|
18
20
|
"""List biometrics within a date range."""
|
|
19
|
-
rows = repo.get_by_date_range(start_date, end_date)
|
|
21
|
+
rows = repo.get_by_date_range(start_date.isoformat(), end_date.isoformat())
|
|
20
22
|
metrics = [BiometricItem(**dict(row)) for row in rows]
|
|
21
23
|
return BiometricsResponse(
|
|
22
24
|
metrics=metrics,
|
|
23
|
-
start_date=start_date,
|
|
24
|
-
end_date=end_date,
|
|
25
|
+
start_date=start_date.isoformat(),
|
|
26
|
+
end_date=end_date.isoformat(),
|
|
25
27
|
count=len(metrics),
|
|
26
28
|
)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Sleep endpoints."""
|
|
2
2
|
|
|
3
|
+
import datetime
|
|
4
|
+
|
|
3
5
|
from fastapi import APIRouter, Depends, Query
|
|
4
6
|
|
|
5
7
|
from garsync.api.deps import get_sleep_repo
|
|
@@ -11,16 +13,16 @@ router = APIRouter(prefix="/api", tags=["sleep"])
|
|
|
11
13
|
|
|
12
14
|
@router.get("/sleep", response_model=SleepResponse)
|
|
13
15
|
def list_sleep(
|
|
14
|
-
start_date:
|
|
15
|
-
end_date:
|
|
16
|
+
start_date: datetime.date = Query(),
|
|
17
|
+
end_date: datetime.date = Query(),
|
|
16
18
|
repo: SleepRepository = Depends(get_sleep_repo),
|
|
17
19
|
) -> SleepResponse:
|
|
18
20
|
"""List sleep sessions within a date range."""
|
|
19
|
-
rows = repo.get_by_date_range(start_date, end_date)
|
|
21
|
+
rows = repo.get_by_date_range(start_date.isoformat(), end_date.isoformat())
|
|
20
22
|
sessions = [SleepItem(**dict(row)) for row in rows]
|
|
21
23
|
return SleepResponse(
|
|
22
24
|
sleep_sessions=sessions,
|
|
23
|
-
start_date=start_date,
|
|
24
|
-
end_date=end_date,
|
|
25
|
+
start_date=start_date.isoformat(),
|
|
26
|
+
end_date=end_date.isoformat(),
|
|
25
27
|
count=len(sessions),
|
|
26
28
|
)
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"""Stats endpoints — summary and heatmap."""
|
|
2
2
|
|
|
3
|
-
from datetime import date, timedelta
|
|
4
|
-
from typing import Optional
|
|
3
|
+
from datetime import UTC, date, datetime, timedelta
|
|
5
4
|
|
|
6
5
|
from fastapi import APIRouter, Depends, Query
|
|
7
6
|
|
|
@@ -23,11 +22,11 @@ router = APIRouter(prefix="/api/stats", tags=["stats"])
|
|
|
23
22
|
|
|
24
23
|
def _resolve_dates(
|
|
25
24
|
period: str,
|
|
26
|
-
start_date:
|
|
27
|
-
end_date:
|
|
25
|
+
start_date: str | None,
|
|
26
|
+
end_date: str | None,
|
|
28
27
|
) -> tuple[str, str]:
|
|
29
28
|
"""Resolve start/end dates from period or explicit params."""
|
|
30
|
-
today =
|
|
29
|
+
today = datetime.now(UTC).date()
|
|
31
30
|
if start_date and end_date:
|
|
32
31
|
return start_date, end_date
|
|
33
32
|
if period == "week":
|
|
@@ -42,14 +41,18 @@ def _resolve_dates(
|
|
|
42
41
|
@router.get("/summary", response_model=SummaryStats)
|
|
43
42
|
def summary(
|
|
44
43
|
period: str = Query(default="week"),
|
|
45
|
-
start_date:
|
|
46
|
-
end_date:
|
|
44
|
+
start_date: date | None = Query(default=None),
|
|
45
|
+
end_date: date | None = Query(default=None),
|
|
47
46
|
activity_repo: ActivityRepository = Depends(get_activity_repo),
|
|
48
47
|
biometrics_repo: BiometricsRepository = Depends(get_biometrics_repo),
|
|
49
48
|
sleep_repo: SleepRepository = Depends(get_sleep_repo),
|
|
50
49
|
) -> SummaryStats:
|
|
51
50
|
"""Get aggregated stats for a period."""
|
|
52
|
-
sd, ed = _resolve_dates(
|
|
51
|
+
sd, ed = _resolve_dates(
|
|
52
|
+
period,
|
|
53
|
+
start_date.isoformat() if start_date else None,
|
|
54
|
+
end_date.isoformat() if end_date else None,
|
|
55
|
+
)
|
|
53
56
|
|
|
54
57
|
activity_stats = activity_repo.get_summary_stats(sd, ed)
|
|
55
58
|
bio_stats = biometrics_repo.get_avg_stats(sd, ed)
|
|
@@ -92,12 +95,12 @@ def _compute_intensity(count: int, max_count: int) -> int:
|
|
|
92
95
|
|
|
93
96
|
@router.get("/heatmap", response_model=HeatmapResponse)
|
|
94
97
|
def heatmap(
|
|
95
|
-
year:
|
|
96
|
-
activity_type:
|
|
98
|
+
year: int | None = Query(default=None),
|
|
99
|
+
activity_type: str | None = Query(default=None),
|
|
97
100
|
repo: ActivityRepository = Depends(get_activity_repo),
|
|
98
101
|
) -> HeatmapResponse:
|
|
99
102
|
"""Get activity heatmap data for a year."""
|
|
100
|
-
target_year = year or
|
|
103
|
+
target_year = year or datetime.now(UTC).date().year
|
|
101
104
|
rows = repo.get_heatmap_data(target_year, activity_type)
|
|
102
105
|
|
|
103
106
|
max_count = max((row["activity_count"] for row in rows), default=0)
|