Compare commits

..

10 Commits

Author SHA1 Message Date
48c67e69ad Update default categories 2026-01-01 03:40:58 +01:00
a8714ebf7e Remove the CORS middleware
The app and static files are served from a single domain, we don't use
cross domain requests at all so removing it locks it down to the most
restrictive state, which seems reasonable. It was initially added from
an example without true understanding of the need.
2026-01-01 02:47:24 +01:00
be1544d5d5 Add basic expense list rendering to frontend 2026-01-01 02:23:50 +01:00
17a1c29d76 Configure setuptools for non-editable install 2026-01-01 00:18:51 +01:00
61a13d65a4 Add expense listing API endpoint 2025-12-31 23:18:46 +01:00
13dcad6c0f Add expense creation to frontend 2025-12-27 13:36:18 +01:00
6bf56d7537 Add expense creation API endpoint 2025-12-27 13:32:16 +01:00
7525d2b2ed Ensure foreign key constraints are always enforced 2025-12-27 00:21:22 +01:00
4becbcdea3 Update auth validation code to hash incoming tokens 2025-12-26 23:12:06 +01:00
4f6e5cd33a Add cli commands for token management 2025-12-26 22:40:38 +01:00
17 changed files with 556 additions and 49 deletions

5
.gitignore vendored
View File

@@ -1,5 +1,6 @@
/venv
/venv/
/build/
/dev/
*.pyc
__pycache__
*.egg-info/
/dev/*

View File

@@ -1,7 +1,6 @@
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from mft.settings import settings
@@ -14,15 +13,6 @@ def create_app() -> FastAPI:
description="A simple expense tracking application",
)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Register routes
from mft.routes import api_router

View File

@@ -2,6 +2,7 @@ from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from mft.database import get_db
import hashlib
security = HTTPBearer()
@@ -23,6 +24,7 @@ def verify_token(
HTTPException: If token is invalid or disabled
"""
token = credentials.credentials
token_hash = hashlib.sha256(token.encode()).hexdigest()
with get_db() as conn:
cursor = conn.cursor()
@@ -32,7 +34,7 @@ def verify_token(
FROM auth
WHERE token = ?
""",
(token,),
(token_hash,),
).fetchone()
if result is None or not result["enabled"]:

View File

@@ -4,7 +4,9 @@ import argparse
import sqlite3
import uvicorn
import mft.config
from datetime import datetime
import secrets
import hashlib
from datetime import datetime, timezone
from pathlib import Path
@@ -49,6 +51,15 @@ def parse_args() -> argparse.Namespace:
user_add_parser = user_subparsers.add_parser("add", help="Add a new user")
user_add_parser.add_argument("name", type=str, help="User name")
# token (list, add, disable) subcommands
token_parser = subparsers.add_parser("token", help="Token management")
token_subparsers = token_parser.add_subparsers(dest="token_command", required=True)
token_list_parser = token_subparsers.add_parser("list", help="List all tokens")
token_add_parser = token_subparsers.add_parser("add", help="Add a new token")
token_add_parser.add_argument("username", type=str, help="User name for the token")
token_disable_parser = token_subparsers.add_parser("disable", help="Disable a token")
token_disable_parser.add_argument("token_id", type=int, help="Token ID to disable")
return transform_args(parser.parse_args())
@@ -78,6 +89,8 @@ def main():
db_command(args, settings)
elif args.command == "user":
user_command(args, settings)
elif args.command == "token":
token_command(args, settings)
def run_command(args, settings):
@@ -207,5 +220,130 @@ def user_add_command(args, settings):
sys.exit(1)
def token_command(args, settings):
if args.token_command == "list":
token_list_command(args, settings)
elif args.token_command == "add":
token_add_command(args, settings)
elif args.token_command == "disable":
token_disable_command(args, settings)
def token_list_command(args, settings):
from mft.database import get_db
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT
a.id,
u.name,
a.created,
a.enabled,
SUBSTR(a.token, 1, 8) as token_prefix
FROM auth a
JOIN user u ON a.uid = u.id
ORDER BY a.id
"""
)
tokens = cursor.fetchall()
if not tokens:
print("No tokens found.")
return
print(f"{'ID':<5} {'User':<20} {'Created':<30} {'Enabled':<10} {'Token Prefix':<15}")
print("-" * 90)
for token in tokens:
token_id = token[0]
username = token[1]
created = token[2]
enabled = "Yes" if token[3] else "No"
token_prefix = token[4]
print(
f"{token_id:<5} {username:<20} {created:<30} {enabled:<10} {token_prefix}..."
)
print(f"\nTotal tokens: {len(tokens)}")
def token_add_command(args, settings):
from mft.database import get_db
# Generate the token
token = secrets.token_urlsafe(32)
# Hash the token for storage
token_hash = hashlib.sha256(token.encode()).hexdigest()
# Get current timestamp
created = datetime.now(timezone.utc).isoformat()
with get_db() as conn:
cursor = conn.cursor()
# Check if user exists
cursor.execute("SELECT id FROM user WHERE name = ?", (args.username,))
user = cursor.fetchone()
if not user:
print(
f"Error: User '{args.username}' does not exist.",
file=sys.stderr,
)
sys.exit(1)
user_id = user[0]
try:
cursor.execute(
"INSERT INTO auth (uid, created, token) VALUES (?, ?, ?)",
(user_id, created, token_hash),
)
conn.commit()
token_id = cursor.lastrowid
print(f"Token created successfully for user '{args.username}'")
print(f"Token ID: {token_id}")
print(f"\n{'='*60}")
print(f"Token (save this, it won't be shown again):")
print(f"{token}")
print(f"{'='*60}\n")
except sqlite3.IntegrityError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def token_disable_command(args, settings):
from mft.database import get_db
with get_db() as conn:
cursor = conn.cursor()
# Check if token exists
cursor.execute("SELECT enabled FROM auth WHERE id = ?", (args.token_id,))
token = cursor.fetchone()
if not token:
print(
f"Error: Token with ID {args.token_id} does not exist.",
file=sys.stderr,
)
sys.exit(1)
if not token[0]:
print(f"Token {args.token_id} is already disabled.")
return
# Disable the token
cursor.execute("UPDATE auth SET enabled = 0 WHERE id = ?", (args.token_id,))
conn.commit()
print(f"Token {args.token_id} has been disabled.")
if __name__ == "__main__":
main()

View File

@@ -10,7 +10,6 @@ port = 8080
[mft]
database = "~/.local/var/db/mft.db"
cors_origins = ["http://127.0.0.1:8080"]
"""
@@ -30,7 +29,6 @@ class MftConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
database: str = "~/.local/var/db/mft.db"
cors_origins: list[str] = Field(default_factory=lambda: ["http://127.0.0.1:8080"])
class AppConfig(BaseModel):

View File

@@ -20,6 +20,7 @@ def get_db() -> Generator[sqlite3.Connection, None, None]:
"""Get a database connection context manager."""
conn = sqlite3.connect(settings.database_path)
conn.row_factory = sqlite3.Row # Enable dict-like access to rows
conn.execute("PRAGMA foreign_keys = ON")
try:
yield conn
finally:

View File

@@ -1,13 +1,9 @@
"""API routes aggregation."""
from fastapi import APIRouter
from mft.routes import auth, categories, health
from mft.routes import auth, categories, expense, health
api_router = APIRouter()
# Include all route modules
api_router.include_router(health.router, tags=["health"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(categories.router, tags=["categories"])
api_router.include_router(expense.router, tags=["expenses"])

View File

@@ -1,10 +1,6 @@
"""Authentication routes."""
from fastapi import APIRouter, Depends
from mft.auth import verify_token
router = APIRouter()
@@ -19,8 +15,4 @@ async def validate(user_id: int = Depends(verify_token)):
Returns:
A simple status response with the authenticated user ID
"""
return {
"status": "ok",
"user_id": user_id,
"message": "Successfully authenticated"
}
return {"status": "ok", "user_id": user_id, "message": "Successfully authenticated"}

View File

@@ -1,12 +1,8 @@
"""Category endpoints."""
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from mft.auth import verify_token
from mft.database import get_db
router = APIRouter()

192
mft/routes/expense.py Normal file
View File

@@ -0,0 +1,192 @@
import re
import sqlite3
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, status, Depends, Query
from pydantic import BaseModel, Field
from mft.auth import verify_token
from mft.database import get_db
from dateutil.relativedelta import relativedelta
router = APIRouter()
class ExpenseCreate(BaseModel):
cid: int = Field(..., description="Category ID")
value: int = Field(..., gt=0, description="Amount in cents")
note: str | None = Field(None, description="Optional note")
class Expense(BaseModel):
id: int
uid: int
cid: int
ts: str
value: int
note: str | None
def parse_month(month_str: str) -> datetime:
"""
Parse YYYY-MM format string to datetime at start of month.
Args:
month_str: String in YYYY-MM format
Returns:
datetime object at start of month (00:00:00 UTC)
Raises:
ValueError: If format is invalid
"""
if not re.match(r"^\d{4}-\d{2}$", month_str):
raise ValueError(f"Invalid month format: {month_str}")
year, month = map(int, month_str.split("-"))
if month < 1 or month > 12:
raise ValueError(f"Invalid month value: {month}")
if year < 1 or year > 5000:
raise ValueError(f"Invalid year value: {year}")
return datetime(year, month, 1, tzinfo=timezone.utc)
def get_default_range() -> tuple[datetime, datetime]:
"""
Get default time range: current month + previous month.
Returns:
Tuple of (from_date, to_date) where range is [from, to)
"""
now = datetime.now(timezone.utc)
this_month = datetime(now.year, now.month, 1, tzinfo=timezone.utc)
to_date = this_month + relativedelta(months=1)
from_date = this_month - relativedelta(months=1)
return from_date, to_date
@router.get("/expenses", response_model=list[Expense])
def list_expenses(
from_month: str | None = Query(
None, description="Start month (YYYY-MM, inclusive)"
),
to_month: str | None = Query(None, description="End month (YYYY-MM, exclusive)"),
uid: int = Depends(verify_token),
):
"""
List expenses for a time range.
Query parameters must be both provided or both omitted.
If omitted, defaults to current month + previous month.
Args:
from_month: Start of range in YYYY-MM format (inclusive)
to_month: End of range in YYYY-MM format (exclusive)
Returns:
List of expenses ordered by timestamp descending (newest first)
Raises:
400: Invalid parameters (format, missing one param, from > to)
"""
# FIXME: Timezones are more or less ignored unless you happen to live in
# UTC. Possible fixes are adding a TZ parameter to the api endpoint or
# storing the TZ preference in the user account table.
# Validate both-or-neither
if (from_month is None) != (to_month is None):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Both 'from' and 'to' parameters must be provided, or both omitted",
)
try:
if from_month is None:
from_date, to_date = get_default_range()
else:
from_date = parse_month(from_month)
to_date = parse_month(to_month)
if from_date >= to_date:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="'from' must be before 'to'",
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
# Convert to ISO format strings for SQLite comparison
from_ts = from_date.isoformat()
to_ts = to_date.isoformat()
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT id, uid, cid, ts, value, note
FROM expense
WHERE ts >= ? AND ts < ?
ORDER BY ts DESC
""",
(from_ts, to_ts),
)
rows = cursor.fetchall()
return [
Expense(
id=row[0],
uid=row[1],
cid=row[2],
ts=row[3],
value=row[4],
note=row[5],
)
for row in rows
]
@router.post("/expenses", response_model=Expense, status_code=status.HTTP_201_CREATED)
def create_expense(expense: ExpenseCreate, uid: int = Depends(verify_token)):
"""
Create a new expense entry.
Args:
expense: Expense data (category ID, value in cents, optional note)
uid: User ID from authentication token
Returns:
The created expense with generated ID and timestamp
Raises:
400: Invalid category ID or constraint violation
"""
timestamp = datetime.now(timezone.utc).isoformat()
with get_db() as conn:
cursor = conn.cursor()
try:
cursor.execute(
"""
INSERT INTO expense (uid, cid, ts, value, note)
VALUES (?, ?, ?, ?, ?)
""",
(uid, expense.cid, timestamp, expense.value, expense.note),
)
conn.commit()
expense_id = cursor.lastrowid
return Expense(
id=expense_id,
uid=uid,
cid=expense.cid,
ts=timestamp,
value=expense.value,
note=expense.note,
)
except sqlite3.IntegrityError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid request: constraint violation",
) from e

View File

@@ -1,12 +1,8 @@
"""Health check and status endpoints."""
from fastapi import APIRouter
router = APIRouter()
@router.get("/status")
async def status():
"""Minimal status check to verify the API is up."""
return {"status": "ok"}

View File

@@ -27,11 +27,18 @@ CREATE TABLE category (
);
INSERT INTO category (name) VALUES
('Food/Drink'),
('Living'),
('Groceries'),
('Take-out'),
('Household consumables'),
('Household Other'),
('Hobby'),
('Utilities');
('Household goods'),
('Clothing'),
('Entertainment'),
('Utilities'),
('Subscriptions'),
('Transportation'),
('Health'),
('Other');
CREATE TABLE expense (
id INTEGER PRIMARY KEY,

View File

@@ -22,7 +22,6 @@ class Settings:
def set_config_file_values(self, config):
self.database_path: Path = Path(config.mft.database).expanduser().absolute()
self.cors_origins = config.mft.cors_origins
self.host = config.server.host
self.port = config.server.port

View File

@@ -1,5 +1,8 @@
const TOKEN_KEY = 'mft_token';
// Store categories for mapping cid to name
let categories = [];
// Validate token with the API
async function validateToken(token) {
try {
@@ -58,11 +61,49 @@ document.getElementById('logout-btn').addEventListener('click', () => {
document.getElementById('token-form').reset();
});
// Handle expense form submission (no-op for now)
document.getElementById('expense-form').addEventListener('submit', (e) => {
// Handle expense form submission
document.getElementById('expense-form').addEventListener('submit', async (e) => {
e.preventDefault();
// TODO: Implement expense submission
console.log('Expense form submitted (no-op)');
const token = localStorage.getItem(TOKEN_KEY);
const categoryId = parseInt(document.getElementById('category').value);
const valueInDollars = parseFloat(document.getElementById('value').value);
const note = document.getElementById('note').value.trim() || null;
// Convert dollars to cents
const valueInCents = Math.round(valueInDollars * 100);
try {
const response = await fetch('/api/expenses', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
cid: categoryId,
value: valueInCents,
note: note
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to add expense');
}
const expense = await response.json();
console.log('Expense created:', expense);
// Reset form
document.getElementById('expense-form').reset();
// Add to expense list
addExpenseToList(expense);
} catch (error) {
console.error('Error adding expense:', error);
alert(`Failed to add expense: ${error.message}`);
}
});
function showLogin() {
@@ -74,6 +115,7 @@ async function showApp() {
document.getElementById('login-section').style.display = 'none';
document.getElementById('app-section').style.display = 'block';
await loadCategories();
await loadExpenses();
}
async function loadCategories() {
@@ -92,7 +134,7 @@ async function loadCategories() {
throw new Error('Failed to load categories');
}
const categories = await response.json();
categories = await response.json();
// Clear loading option
categorySelect.innerHTML = '';
@@ -115,3 +157,89 @@ async function loadCategories() {
categorySelect.innerHTML = '<option value="">Error loading categories</option>';
}
}
async function loadExpenses() {
const token = localStorage.getItem(TOKEN_KEY);
const listContainer = document.getElementById('expense-list');
try {
const response = await fetch('/api/expenses', {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error('Failed to load expenses');
}
const expenses = await response.json();
renderExpenses(expenses);
} catch (error) {
console.error('Error loading expenses:', error);
listContainer.innerHTML = '<p>Error loading expenses</p>';
}
}
function createExpenseCard(expense) {
const category = categories.find(c => c.id === expense.cid);
const categoryName = category ? category.name : 'Unknown';
const amount = (expense.value / 100).toFixed(2);
const timestamp = new Date(expense.ts).toLocaleString();
// Create card structure
const item = document.createElement('div');
item.className = 'expense-item';
item.innerHTML = `
<div class="expense-header">
<span class="expense-category"></span>
<span class="expense-amount"></span>
</div>
<div class="expense-details">
<span class="expense-time"></span>
<span class="expense-note"></span>
</div>
`;
// Populate with textContent
item.querySelector('.expense-category').textContent = categoryName;
item.querySelector('.expense-amount').textContent = `${amount}`;
item.querySelector('.expense-time').textContent = timestamp;
if (expense.note) {
item.querySelector('.expense-note').textContent = expense.note;
} else {
item.querySelector('.expense-note').remove();
}
return item;
}
function renderExpenses(expenses) {
const listContainer = document.getElementById('expense-list');
if (expenses.length === 0) {
listContainer.innerHTML = '<p class="empty-state">No expenses recorded</p>';
return;
}
listContainer.innerHTML = '';
expenses.forEach(expense => {
const card = createExpenseCard(expense);
listContainer.appendChild(card);
});
}
function addExpenseToList(expense) {
const listContainer = document.getElementById('expense-list');
// Remove empty state if it exists
const emptyState = listContainer.querySelector('.empty-state');
if (emptyState) {
listContainer.innerHTML = '';
}
const card = createExpenseCard(expense);
listContainer.insertAdjacentElement('afterbegin', card);
}

View File

@@ -44,6 +44,11 @@
</div>
<button type="submit">Add Expense</button>
</form>
<div id="expense-list-section">
<h2>Recent Expenses</h2>
<div id="expense-list"></div>
</div>
</div>
</div>

View File

@@ -87,3 +87,62 @@ select:focus {
outline: none;
border-color: #3498db;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
#expense-list-section {
margin-top: 40px;
}
.expense-item {
padding: 15px;
margin-bottom: 10px;
border: 1px solid #e0e0e0;
border-radius: 4px;
background: #fafafa;
}
.expense-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.expense-category {
font-weight: 600;
color: #2c3e50;
}
.expense-amount {
font-size: 1.1em;
font-weight: 600;
color: #e74c3c;
}
.expense-details {
display: flex;
gap: 15px;
font-size: 0.9em;
color: #666;
}
.expense-time {
font-style: italic;
}
.expense-note {
color: #555;
}
.empty-state {
text-align: center;
color: #999;
font-style: italic;
padding: 20px;
}

View File

@@ -6,6 +6,7 @@ requires-python = ">=3.12"
dependencies = [
"fastapi",
"uvicorn[standard]",
"python-dateutil",
]
[project.optional-dependencies]
@@ -19,3 +20,9 @@ build-backend = "setuptools.build_meta"
[project.scripts]
mft = "mft.cli:main"
[tool.setuptools]
packages = ["mft"]
[tool.setuptools.package-data]
mft = ["static/*", "schema/*"]