garsync 0.2.2__tar.gz → 0.2.3__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.2.3/LICENSE +21 -0
- {garsync-0.2.2 → garsync-0.2.3}/PKG-INFO +6 -7
- {garsync-0.2.2 → garsync-0.2.3}/README.md +1 -1
- {garsync-0.2.2 → garsync-0.2.3}/pyproject.toml +13 -7
- {garsync-0.2.2 → garsync-0.2.3}/src/garsync/__init__.py +1 -1
- {garsync-0.2.2 → garsync-0.2.3}/src/garsync/api/auth.py +21 -1
- {garsync-0.2.2 → garsync-0.2.3}/src/garsync/api/main.py +35 -4
- {garsync-0.2.2 → garsync-0.2.3}/src/garsync/__main__.py +0 -0
- {garsync-0.2.2 → garsync-0.2.3}/src/garsync/api/__init__.py +0 -0
- {garsync-0.2.2 → garsync-0.2.3}/src/garsync/api/deps.py +0 -0
- {garsync-0.2.2 → garsync-0.2.3}/src/garsync/api/routes/__init__.py +0 -0
- {garsync-0.2.2 → garsync-0.2.3}/src/garsync/api/routes/activities.py +0 -0
- {garsync-0.2.2 → garsync-0.2.3}/src/garsync/api/routes/biometrics.py +0 -0
- {garsync-0.2.2 → garsync-0.2.3}/src/garsync/api/routes/sleep.py +0 -0
- {garsync-0.2.2 → garsync-0.2.3}/src/garsync/api/routes/stats.py +0 -0
- {garsync-0.2.2 → garsync-0.2.3}/src/garsync/api/routes/sync.py +0 -0
- {garsync-0.2.2 → garsync-0.2.3}/src/garsync/api/schemas.py +0 -0
- {garsync-0.2.2 → garsync-0.2.3}/src/garsync/cli.py +0 -0
- {garsync-0.2.2 → garsync-0.2.3}/src/garsync/client.py +0 -0
- {garsync-0.2.2 → garsync-0.2.3}/src/garsync/db/__init__.py +0 -0
- {garsync-0.2.2 → garsync-0.2.3}/src/garsync/db/connection.py +0 -0
- {garsync-0.2.2 → garsync-0.2.3}/src/garsync/db/repository.py +0 -0
- {garsync-0.2.2 → garsync-0.2.3}/src/garsync/db/schema.py +0 -0
- {garsync-0.2.2 → garsync-0.2.3}/src/garsync/exporter.py +0 -0
- {garsync-0.2.2 → garsync-0.2.3}/src/garsync/models.py +0 -0
- {garsync-0.2.2 → garsync-0.2.3}/src/garsync/pipeline.py +0 -0
garsync-0.2.3/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Manu Lorente
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: garsync
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary:
|
|
5
|
+
License-File: LICENSE
|
|
5
6
|
Author: Manu
|
|
6
7
|
Author-email: mlorentedev@gmail.com
|
|
7
8
|
Requires-Python: >=3.12
|
|
@@ -9,16 +10,14 @@ Classifier: Programming Language :: Python :: 3
|
|
|
9
10
|
Classifier: Programming Language :: Python :: 3.12
|
|
10
11
|
Classifier: Programming Language :: Python :: 3.13
|
|
11
12
|
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
12
14
|
Requires-Dist: fastapi (>=0.141.1,<1.0.0)
|
|
13
|
-
Requires-Dist: garminconnect (>=0.3.
|
|
14
|
-
Requires-Dist: pandas (<4)
|
|
15
|
-
Requires-Dist: plotly (>=7.0.0,<8.0.0)
|
|
15
|
+
Requires-Dist: garminconnect (>=0.3.16,<0.4.0)
|
|
16
16
|
Requires-Dist: pydantic (>=2.13.5,<3.0.0)
|
|
17
17
|
Requires-Dist: rich (>=15.0.0,<16.0.0)
|
|
18
|
-
Requires-Dist: streamlit (>=1.63.0,<2.0.0)
|
|
19
18
|
Requires-Dist: tenacity (>=9.1.4,<10.0.0)
|
|
20
19
|
Requires-Dist: typer (>=0.27.2,<0.28.0)
|
|
21
|
-
Requires-Dist: uvicorn[standard] (>=0.
|
|
20
|
+
Requires-Dist: uvicorn[standard] (>=0.53.0,<1.0.0)
|
|
22
21
|
Description-Content-Type: text/markdown
|
|
23
22
|
|
|
24
23
|
# GarSync
|
|
@@ -46,7 +45,7 @@ Garmin Connect provides great data, but it's locked in a proprietary cloud.
|
|
|
46
45
|
| **Data Ownership** | Proprietary Cloud | Local SQLite (Full Control) |
|
|
47
46
|
| **Customization** | Fixed Dashboards | Extensible Astro + Chart.js |
|
|
48
47
|
| **Access** | Web/Mobile Only | REST API + SQL + CLI |
|
|
49
|
-
| **Automation** | Manual Export |
|
|
48
|
+
| **Automation** | Manual Export | CLI sync over any date window on demand (in-process scheduler: [ADR-008](docs/adr/adr-008-ingestion-ledger-and-adapters.md)) |
|
|
50
49
|
|
|
51
50
|
## Quick Start
|
|
52
51
|
|
|
@@ -23,7 +23,7 @@ Garmin Connect provides great data, but it's locked in a proprietary cloud.
|
|
|
23
23
|
| **Data Ownership** | Proprietary Cloud | Local SQLite (Full Control) |
|
|
24
24
|
| **Customization** | Fixed Dashboards | Extensible Astro + Chart.js |
|
|
25
25
|
| **Access** | Web/Mobile Only | REST API + SQL + CLI |
|
|
26
|
-
| **Automation** | Manual Export |
|
|
26
|
+
| **Automation** | Manual Export | CLI sync over any date window on demand (in-process scheduler: [ADR-008](docs/adr/adr-008-ingestion-ledger-and-adapters.md)) |
|
|
27
27
|
|
|
28
28
|
## Quick Start
|
|
29
29
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "garsync"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.3"
|
|
4
4
|
description = ""
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "Manu",email = "mlorentedev@gmail.com"}
|
|
@@ -8,16 +8,13 @@ authors = [
|
|
|
8
8
|
readme = "README.md"
|
|
9
9
|
requires-python = ">=3.12"
|
|
10
10
|
dependencies = [
|
|
11
|
-
"garminconnect (>=0.3.
|
|
11
|
+
"garminconnect (>=0.3.16,<0.4.0)",
|
|
12
12
|
"pydantic (>=2.13.5,<3.0.0)",
|
|
13
13
|
"tenacity (>=9.1.4,<10.0.0)",
|
|
14
14
|
"typer (>=0.27.2,<0.28.0)",
|
|
15
15
|
"rich (>=15.0.0,<16.0.0)",
|
|
16
|
-
"pandas (<4)",
|
|
17
|
-
"streamlit (>=1.63.0,<2.0.0)",
|
|
18
|
-
"plotly (>=7.0.0,<8.0.0)",
|
|
19
16
|
"fastapi (>=0.141.1,<1.0.0)",
|
|
20
|
-
"uvicorn[standard] (>=0.
|
|
17
|
+
"uvicorn[standard] (>=0.53.0,<1.0.0)",
|
|
21
18
|
]
|
|
22
19
|
|
|
23
20
|
|
|
@@ -36,7 +33,7 @@ dev = [
|
|
|
36
33
|
"pytest (>=9.1.1,<10.0.0)",
|
|
37
34
|
"pytest-cov (>=7.1.0,<8.0.0)",
|
|
38
35
|
"mypy (>=2.3.1,<3.0.0)",
|
|
39
|
-
"ruff (>=0.16.
|
|
36
|
+
"ruff (>=0.16.8,<0.17.0)",
|
|
40
37
|
"pytest-mock (>=3.15.1,<4.0.0)",
|
|
41
38
|
"httpx (>=0.28.1,<1.0.0)"
|
|
42
39
|
]
|
|
@@ -56,6 +53,15 @@ disallow_untyped_defs = false
|
|
|
56
53
|
[tool.pytest.ini_options]
|
|
57
54
|
testpaths = ["tests"]
|
|
58
55
|
addopts = "-q"
|
|
56
|
+
# Markers exist so a slower, end-to-end layer can be excluded from the default
|
|
57
|
+
# run (`-m "not e2e"`) instead of being made faster by deleting it. `make smoke`
|
|
58
|
+
# is today's e2e path and lives outside pytest; the marker is declared here so
|
|
59
|
+
# a future pytest-driven e2e test has somewhere to land.
|
|
60
|
+
markers = [
|
|
61
|
+
"unit: one layer — repository, schema, parsing, pure helpers",
|
|
62
|
+
"integration: more than one layer — CLI + database, or API + database over HTTP",
|
|
63
|
+
"e2e: the deployed shape end to end; excluded from the default run",
|
|
64
|
+
]
|
|
59
65
|
|
|
60
66
|
[tool.ruff]
|
|
61
67
|
target-version = "py312"
|
|
@@ -120,6 +120,26 @@ def make_session_token(access_password: str, now: float | None = None) -> str:
|
|
|
120
120
|
return f"{expiry}.{sig}"
|
|
121
121
|
|
|
122
122
|
|
|
123
|
+
def constant_time_equals(candidate: str, expected: str) -> bool:
|
|
124
|
+
"""Constant-time string comparison that survives any input a client can send.
|
|
125
|
+
|
|
126
|
+
`hmac.compare_digest` refuses `str` arguments that contain non-ASCII characters and
|
|
127
|
+
raises `TypeError: comparing strings with non-ASCII characters is not supported`.
|
|
128
|
+
On an auth check that turns attacker-controlled input into an unhandled 500: sending
|
|
129
|
+
`password=ñ` to `POST /login` (percent-encoded UTF-8, which is what any browser
|
|
130
|
+
sends), a non-ASCII `X-API-KEY` header (latin-1 on the wire) or a non-ASCII session
|
|
131
|
+
cookie crashed the request **before** the failed attempt was recorded, so it was an
|
|
132
|
+
unauthenticated denial of service that also bypassed the login rate limiter.
|
|
133
|
+
Independent adversarial review of specs/SEC-001, 2026-09-25 (`agy/gemini-3.1-pro-high`),
|
|
134
|
+
filed it as a Blocker and the spec was refused until it was fixed.
|
|
135
|
+
|
|
136
|
+
Encoding both sides to UTF-8 keeps the constant-time property (the comparison is over
|
|
137
|
+
the encoded bytes) and removes the crash. Length is still observable, as it is for
|
|
138
|
+
`compare_digest` on equal-length inputs; that is not the property this protects.
|
|
139
|
+
"""
|
|
140
|
+
return hmac.compare_digest(candidate.encode("utf-8"), expected.encode("utf-8"))
|
|
141
|
+
|
|
142
|
+
|
|
123
143
|
def verify_session_token(token: str | None, access_password: str, now: float | None = None) -> bool:
|
|
124
144
|
"""Constant-time token verification; False on tamper, bad format or expiry."""
|
|
125
145
|
if not token or not access_password:
|
|
@@ -133,7 +153,7 @@ def verify_session_token(token: str | None, access_password: str, now: float | N
|
|
|
133
153
|
f"garsync-session|{expiry_str}".encode(),
|
|
134
154
|
hashlib.sha256,
|
|
135
155
|
).hexdigest()
|
|
136
|
-
if not
|
|
156
|
+
if not constant_time_equals(provided_sig, expected):
|
|
137
157
|
return False
|
|
138
158
|
current = time.time() if now is None else now
|
|
139
159
|
return int(expiry_str) > current
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"""FastAPI application factory for garsync."""
|
|
2
2
|
|
|
3
|
-
import hmac
|
|
4
3
|
import logging
|
|
5
4
|
import os
|
|
6
5
|
import sqlite3
|
|
@@ -18,6 +17,7 @@ from garsync.api.auth import (
|
|
|
18
17
|
SESSION_COOKIE_NAME,
|
|
19
18
|
SESSION_TTL_SECONDS,
|
|
20
19
|
LoginRateLimiter,
|
|
20
|
+
constant_time_equals,
|
|
21
21
|
make_session_token,
|
|
22
22
|
render_login_page,
|
|
23
23
|
verify_session_token,
|
|
@@ -28,6 +28,11 @@ from garsync.db.schema import init_db
|
|
|
28
28
|
|
|
29
29
|
logger = logging.getLogger("garsync.auth")
|
|
30
30
|
|
|
31
|
+
# Largest login body the app will buffer before treating the attempt as failed. The
|
|
32
|
+
# URL-encoded form is a handful of bytes; anything past this is a client trying to spend
|
|
33
|
+
# the server's memory, and the endpoint needs no credentials to reach it.
|
|
34
|
+
MAX_LOGIN_BODY_BYTES = 4096
|
|
35
|
+
|
|
31
36
|
|
|
32
37
|
@asynccontextmanager
|
|
33
38
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
@@ -91,7 +96,7 @@ def create_app(conn: sqlite3.Connection | None = None) -> FastAPI:
|
|
|
91
96
|
if api_key:
|
|
92
97
|
provided = request.headers.get("X-API-KEY")
|
|
93
98
|
if provided is not None:
|
|
94
|
-
if
|
|
99
|
+
if constant_time_equals(provided, api_key):
|
|
95
100
|
return await call_next(request)
|
|
96
101
|
return JSONResponse(
|
|
97
102
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
@@ -127,10 +132,36 @@ def create_app(conn: sqlite3.Connection | None = None) -> FastAPI:
|
|
|
127
132
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
128
133
|
)
|
|
129
134
|
|
|
130
|
-
|
|
135
|
+
# Cap what an unauthenticated endpoint will buffer: `await request.body()` read
|
|
136
|
+
# whatever the client sent, so `POST /login` was a free memory lever for anyone
|
|
137
|
+
# who could reach it. The proxy's body limit is a second line of defence, not the
|
|
138
|
+
# first one on the app's only unauthenticated write path. Independent review of
|
|
139
|
+
# specs/SEC-001 (2026-09-25) raised it as speculative; it is cheap to close, and an
|
|
140
|
+
# oversized body counts as a failed attempt rather than a distinct status, so the
|
|
141
|
+
# response is not an oracle for how the body was rejected.
|
|
142
|
+
#
|
|
143
|
+
# This cap holds only while nothing upstream has consumed the body: FastAPI does not
|
|
144
|
+
# parse one for a handler that declares no body parameter, and the auth middleware
|
|
145
|
+
# never reads it, so the request arrives unconsumed (verified: `_stream_consumed` is
|
|
146
|
+
# False at entry and the loop receives chunks). If a future middleware starts reading
|
|
147
|
+
# the body, the proxy's size limit is the remaining backstop.
|
|
148
|
+
body = b""
|
|
149
|
+
async for chunk in request.stream():
|
|
150
|
+
body += chunk
|
|
151
|
+
if len(body) > MAX_LOGIN_BODY_BYTES:
|
|
152
|
+
limiter.record_failure(client_ip)
|
|
153
|
+
return HTMLResponse(
|
|
154
|
+
render_login_page("Invalid password. Try again."),
|
|
155
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# errors="replace": a body that is not valid UTF-8 is a wrong password, not a
|
|
159
|
+
# 500. A bare `.decode()` raised UnicodeDecodeError on `password=\xff` —
|
|
160
|
+
# reachable unauthenticated, and the same crash class the compare fix addresses.
|
|
161
|
+
form = parse_qs(body.decode("utf-8", errors="replace"))
|
|
131
162
|
supplied = form.get("password", [""])[0]
|
|
132
163
|
|
|
133
|
-
if access_password and
|
|
164
|
+
if access_password and constant_time_equals(supplied, access_password):
|
|
134
165
|
limiter.reset(client_ip)
|
|
135
166
|
response = RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
|
|
136
167
|
response.set_cookie(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|