Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,11 @@ jobs:
- name: Install dependencies (runtime + dev)
run: uv sync --frozen --group dev

- name: Run tests
run: uv run pytest
- name: Lint (ruff)
run: uv run ruff check app tests

- name: Type-check (mypy)
run: uv run mypy

- name: Run tests (with coverage)
run: uv run pytest --cov --cov-report=term-missing
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,7 @@ subscriptions.db
subscriptions.db.bak.*
subscriptions.db-shm
subscriptions.db-wal

# Coverage artifacts
.coverage
htmlcov/
26 changes: 20 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,12 @@ uv run python main.py # http://localhost:5001 (live reload)

First visit walks you through creating the initial **super-admin** account.

Run the tests:
Lint, type-check, and test (the same checks CI runs):

```bash
uv run pytest
uv run ruff check app tests # lint + import sorting
uv run mypy # type-check the logic/data layer
uv run pytest --cov # tests with coverage
```

---
Expand All @@ -60,6 +62,7 @@ All configuration is via environment variables:
| `SUBTRACKER_SECRET` | **Prod** | random per-process | Session-signing key. **Set a stable value in production** — without it, logins do not survive a restart and a warning is logged. Generate with `openssl rand -hex 32`. |
| `SUBTRACKER_DB` | no | `./subscriptions.db` | SQLite file path. Point at a persistent volume in a container (e.g. `/data/subscriptions.db`). |
| `SUBTRACKER_PORT` | no | `5001` | Listen port. |
| `SUBTRACKER_SECURE_COOKIES` | no | `0` | Set to `1` when serving over HTTPS: marks the session cookie `Secure` and sets `SameSite=strict`. |

The database runs in WAL mode and is created automatically on first boot.

Expand Down Expand Up @@ -141,12 +144,19 @@ test suite). Pushing to `master` never touches the stable image.
- Sessions are signed cookies keyed by `SUBTRACKER_SECRET`.
- Authorization is enforced per-route via the RBAC layer (`app/rbac.py`,
`app/authz.py`); roles compose by union across a global and a per-team axis.
- **CSRF protection** (`app/csrf.py`): a per-session token is required on every
state-changing request. It rides in a `<meta>` tag; client JS attaches it to
POST forms (hidden field) and HTMX requests (`X-CSRFToken` header), and a
Beforeware rejects any unsafe request whose token doesn't match the session.
- **Login/setup rate-limiting** (`app/ratelimit.py`): 10 attempts per 5 minutes
per client throttles online password guessing.
- CSV export quotes leading formula characters (`=`, `+`, `-`, `@`) to neutralise
spreadsheet formula injection, and imports are size- and row-capped.

**Recommended hardening before exposing to untrusted networks** (not yet
implemented): CSRF tokens on state-changing forms, login rate-limiting, and
serving behind HTTPS with `Secure` session cookies.
**Before exposing to untrusted networks**, serve behind HTTPS and set
`SUBTRACKER_SECURE_COOKIES=1` — this marks the session cookie `Secure` (sent only
over HTTPS) and tightens `SameSite` to `strict`. The in-memory rate limiter is
per-process; a multi-worker deployment would want a shared store.

---

Expand All @@ -158,14 +168,18 @@ app/
config.py env-driven configuration
session.py auth gate (Beforeware) + request context
rbac.py / authz.py role catalog, permission resolution, per-route guard
permissions.py permission name catalog (the Perm constants)
csrf.py per-session CSRF token: guard, <meta>, client JS
ratelimit.py in-memory auth rate limiter
auth.py bcrypt hashing + authentication
cost_utils.py cadence math, prorated/period-aware cost, next payments
timeutil.py central date/time provider
styles.py shadcn token CSS + Tailwind utility-class constants
db/ schema (idempotent + migration), data access per entity
routes/ one APIRouter per feature area
components/ FastHTML view helpers (shadcn-styled) + charts
tests/ pytest suite (cost math, RBAC, auth, migration, HTTP smoke)
tests/ pytest suite (cost math, RBAC + HTTP enforcement, auth, CSRF,
rate-limit, lifecycle, import/export, migration, HTTP smoke)
Dockerfile production image (used by compose + the GHCR image build)
docker-compose.yml standalone deployment
repository.yaml Home Assistant add-on repository manifest
Expand Down
2 changes: 1 addition & 1 deletion app/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

from fasthtml.common import serve

from app.main import app # noqa: F401 (build the app)
from app.config import PORT
from app.main import app # noqa: F401 (build the app)

if __name__ == "__main__":
serve(appname="app.main", app="app", port=PORT)
3 changes: 2 additions & 1 deletion app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ def create_user(username: str, password: str, global_role: str = "user") -> int:
"global_role": global_role,
"created_at": timeutil.now_iso(),
})
return result.last_pk
assert result.last_pk is not None
return int(result.last_pk)


def authenticate(username: str, password: str):
Expand Down
33 changes: 25 additions & 8 deletions app/components/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,40 @@
app.components — stable import surface for all shared UI primitives.
"""

from app.components.charts import bar_chart, hbar_breakdown, line_chart
from app.components.fmt import (
MONTH_LABELS, fmt_eur, category_label, truncate, json_pretty,
)
from app.components.widgets import (
alert, badge, status_badge, action_btn, action_menu, pagination_bar,
dropdown_menu, menu_item_cls, select_menu,
MONTH_LABELS,
category_label,
fmt_eur,
json_pretty,
truncate,
)
from app.components.forms import subscription_form
from app.components.layout import (
nav_bar, page_title, section_card, collapsible_card, team_switcher, forbidden_page,
collapsible_card,
forbidden_page,
nav_bar,
page_title,
section_card,
team_switcher,
)
from app.components.widgets import (
action_btn,
action_menu,
alert,
badge,
dropdown_menu,
menu_item_cls,
pagination_bar,
select_menu,
status_badge,
)
from app.components.charts import bar_chart, line_chart, hbar_breakdown
from app.components.forms import subscription_form

__all__ = [
"MONTH_LABELS", "fmt_eur", "category_label", "truncate", "json_pretty",
"alert", "badge", "status_badge", "action_btn", "action_menu", "pagination_bar",
"dropdown_menu", "menu_item_cls", "select_menu",
"nav_bar", "page_title", "section_card", "collapsible_card",
"team_switcher", "forbidden_page",
"bar_chart", "line_chart", "hbar_breakdown", "subscription_form",
]
2 changes: 1 addition & 1 deletion app/components/charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"""

from fasthtml.common import *
from fasthtml.svg import Svg, Rect, Line, Text, Polyline, Polygon, Circle
from fasthtml.svg import Circle, Line, Polygon, Polyline, Rect, Svg, Text

# Shared SVG viewBox geometry for line_chart / bar_chart. The charts scale to their
# container via `w-full` + viewBox; these are the internal coordinate units only.
Expand Down
4 changes: 2 additions & 2 deletions app/components/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@

from app import timeutil
from app.components.widgets import select_menu
from app.cost_utils import FREQUENCIES, BASE_UNITS, frequency_label
from app.styles import INPUT, TEXTAREA, LABEL, FIELD, btn
from app.cost_utils import BASE_UNITS, FREQUENCIES, frequency_label
from app.styles import FIELD, INPUT, LABEL, TEXTAREA, btn


def _field(label, control):
Expand Down
40 changes: 25 additions & 15 deletions app/components/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,15 @@
from fasthtml.common import *

from app.components.widgets import alert, dropdown_menu, menu_item_cls
from app.permissions import Perm
from app.styles import (
NAV, NAV_LINK, NAV_LINK_ACTIVE, SECTION, PAGE_HEADER, btn, badge_cls,
NAV,
NAV_LINK,
NAV_LINK_ACTIVE,
PAGE_HEADER,
SECTION,
badge_cls,
btn,
)


Expand Down Expand Up @@ -72,12 +79,12 @@ def theme_toggle() -> Button:
def _nav_items(ctx) -> list:
"""[(label, href, key)] for the sections the current role may reach."""
items = [
("Dashboard", "/dashboard", "dashboard", ctx.can("subscriptions.view")),
("Manage", "/manage", "manage", ctx.can("subscriptions.view")),
("Audit Log", "/audit", "audit", ctx.can("audit.view")),
("Teams", "/teams", "teams", ctx.can("teams.manage")),
("Deleted", "/admin/deleted", "deleted", ctx.can("records.view_deleted")),
("Users", "/users", "users", ctx.can("users.view")),
("Dashboard", "/dashboard", "dashboard", ctx.can(Perm.SUB_VIEW)),
("Manage", "/manage", "manage", ctx.can(Perm.SUB_VIEW)),
("Audit Log", "/audit", "audit", ctx.can(Perm.AUDIT_VIEW)),
("Teams", "/teams", "teams", ctx.can(Perm.TEAMS_MANAGE)),
("Deleted", "/admin/deleted", "deleted", ctx.can(Perm.RECORDS_VIEW_DELETED)),
("Users", "/users", "users", ctx.can(Perm.USERS_VIEW)),
]
return [(lbl, href, key) for lbl, href, key, show in items if show]

Expand Down Expand Up @@ -132,14 +139,17 @@ def nav_bar(ctx, active: str = "") -> Nav:
cls="hidden md:flex items-center gap-3",
)

return Nav(
A("💳 SubTracker", href="/dashboard", cls="font-bold text-base mr-1"),
desktop_links,
Div(cls="flex-1"),
desktop_right,
Div(theme_toggle(), _mobile_menu(ctx, active, role_label),
cls="flex md:hidden items-center gap-2"),
cls=NAV,
return (
Meta(name="csrf-token", content=ctx.csrf_token),
Nav(
A("💳 SubTracker", href="/dashboard", cls="font-bold text-base mr-1"),
desktop_links,
Div(cls="flex-1"),
desktop_right,
Div(theme_toggle(), _mobile_menu(ctx, active, role_label),
cls="flex md:hidden items-center gap-2"),
cls=NAV,
),
)


Expand Down
2 changes: 1 addition & 1 deletion app/components/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from fasthtml.common import *

from app.styles import btn, badge_cls, ALERT
from app.styles import ALERT, badge_cls, btn

# shadcn DropdownMenu content + item recipes. Show/hide is the native <details>
# behaviour; the open animation + chevron rotation live in GLOBALS (app/styles.py),
Expand Down
16 changes: 16 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,19 @@
"environment for production.", file=sys.stderr)

PORT = int(os.environ.get("SUBTRACKER_PORT", "5001"))


def _flag(name: str, default: bool = False) -> bool:
"""Read a boolean environment flag (1/true/yes/on)."""
val = os.environ.get(name)
if val is None:
return default
return val.strip().lower() in ("1", "true", "yes", "on")


# Cookie hardening for production behind HTTPS. When SUBTRACKER_SECURE_COOKIES is
# enabled the session cookie is marked Secure (browsers only send it over HTTPS)
# and SameSite is tightened to 'strict'. Left off by default so plain-HTTP local
# dev still works; turn it on for any internet-facing deployment.
SECURE_COOKIES = _flag("SUBTRACKER_SECURE_COOKIES", False)
SESSION_SAMESITE = "strict" if SECURE_COOKIES else "lax"
17 changes: 8 additions & 9 deletions app/cost_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
import calendar
from datetime import date, timedelta


# ── Frequency model ──────────────────────────────────────────────────────────

FREQUENCIES = ["daily", "weekly", "monthly", "quarterly", "yearly", "custom"]
Expand All @@ -42,7 +41,7 @@
_UNIT_NOUN = {"daily": "day", "weekly": "week", "monthly": "month", "yearly": "year"}


def resolve(frequency: str, interval: int = 1, base_unit: str = None) -> tuple:
def resolve(frequency: str, interval: int = 1, base_unit: str | None = None) -> tuple:
"""Collapse (frequency, interval, base_unit) to an (effective_unit, n) pair."""
if frequency == "custom":
unit = base_unit if base_unit in BASE_UNITS else "monthly"
Expand All @@ -52,7 +51,7 @@ def resolve(frequency: str, interval: int = 1, base_unit: str = None) -> tuple:
return frequency, 1


def normalise_cadence(frequency: str, interval=1, base_unit: str = None) -> tuple:
def normalise_cadence(frequency: str, interval=1, base_unit: str | None = None) -> tuple:
"""Clean a submitted/imported (frequency, interval, base_unit) triple for storage.

A named preset always means "every 1 unit" with no base_unit. "custom" keeps a
Expand All @@ -74,19 +73,19 @@ def normalise_cadence(frequency: str, interval=1, base_unit: str = None) -> tupl
# ── Cost normalisation ───────────────────────────────────────────────────────

def get_annual_cost(amount: float, frequency: str, interval: int = 1,
base_unit: str = None) -> float:
base_unit: str | None = None) -> float:
unit, n = resolve(frequency, interval, base_unit)
days_between = DAYS_PER_UNIT[unit] * n
return round(amount * (365.25 / days_between), 2)


def get_period_cost(amount: float, frequency: str, interval: int,
base_unit: str, period: str) -> float:
base_unit: str | None, period: str) -> float:
return round(get_annual_cost(amount, frequency, interval, base_unit)
/ PERIOD_DIVISORS[period], 2)


def frequency_label(frequency: str, interval: int = 1, base_unit: str = None) -> str:
def frequency_label(frequency: str, interval: int = 1, base_unit: str | None = None) -> str:
named = {"daily": "Daily", "weekly": "Weekly", "monthly": "Monthly",
"quarterly": "Quarterly", "yearly": "Yearly"}
if frequency != "custom":
Expand Down Expand Up @@ -120,7 +119,7 @@ def _advance(d: date, unit: str, n: int) -> date:


def next_payment_date(start_date_str: str, frequency: str, interval: int,
base_unit: str = None, reference: date = None) -> date:
base_unit: str | None = None, reference: date | None = None) -> date:
"""Return the next payment date >= reference (defaults to today)."""
ref = reference or date.today()
unit, n = resolve(frequency, interval, base_unit)
Expand All @@ -133,13 +132,13 @@ def next_payment_date(start_date_str: str, frequency: str, interval: int,


def upcoming_payments_for_periods(sub: dict, periods: list, count: int = 6,
reference: date = None) -> list:
reference: date | None = None) -> list:
"""
Return up to `count` upcoming [{date, amount}] payments at/after `reference`,
walking each period's cadence (anchored at its start) and clamped to its end.
"""
ref = reference or date.today()
out = []
out: list = []
freq, interval, base_unit = sub["frequency"], sub.get("interval") or 1, sub.get("base_unit")
for p in sorted(periods, key=lambda x: x["start_date"]):
pe = date.fromisoformat(p["end_date"]) if p.get("end_date") else None
Expand Down
Loading
Loading