Compare commits

...

6 Commits

Author SHA1 Message Date
Jonathan 33656c4512 Merge development: security hardening, login fix, chart improvements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:31:11 +02:00
Jonathan 5cf3726f59 Improve portfolio chart with historical price-based data points
Chart now plots weekly data points from first purchase to today using
candle/history price data, giving an accurate view of portfolio value
over time rather than just at purchase dates.

Backend seeds up to 365 days of daily close prices from CoinGecko as
synthetic OHLC candles, refreshing stale entries older than 31 days.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:30:48 +02:00
Jonathan ce227e47d6 Fix login blocked by registration password validation
Split UserCreate into UserCreate (with min_length constraints) and
UserLogin (no constraints) so existing short passwords can still log in.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 18:49:43 +01:00
Jonathan 11b020907d Revert backend Dockerfile to run as root
Non-root appuser can't write to the host-mounted SQLite data volume —
chown in the image layer doesn't carry over to runtime volume mounts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 18:45:41 +01:00
Jonathan 4da23c9def Revert bcrypt version back to 3.2.2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 18:44:22 +01:00
Jonathan 85455f3271 Security hardening: secrets, validation, Docker, and error handling
- Add root .gitignore to prevent btc_wallet.py (with RPC credentials) from being committed
- Load JWT SECRET_KEY from environment variable instead of hardcoded value
- Restrict CORS to explicit methods/headers instead of wildcards
- Add Pydantic Field validation (gt=0) to purchase amounts and user credentials
- Add logging to all silent exception handlers in btc.py
- Run backend and frontend Docker containers as non-root appuser
- Add .dockerignore for both backend and frontend
- Pass SECRET_KEY env var through docker-compose; add healthchecks to both services
- Update bcrypt from pinned 3.2.2 to >=4.0.0
- Capture error objects in frontend catch blocks; check admin delete response

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 18:40:41 +01:00
17 changed files with 180 additions and 41 deletions
+2
View File
@@ -0,0 +1,2 @@
# Local wallet scripts with credentials — never commit
btc_wallet.py
+7
View File
@@ -0,0 +1,7 @@
.git
__pycache__
*.pyc
*.pyo
.env
*.egg-info
.pytest_cache
+2 -1
View File
@@ -1,8 +1,9 @@
import os
from datetime import datetime, timedelta
from jose import JWTError, jwt
from passlib.context import CryptContext
SECRET_KEY = "change-me-in-production-use-a-long-random-string"
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-insecure-key-change-me")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 1 day
+2 -2
View File
@@ -17,8 +17,8 @@ app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization"],
)
app.include_router(users.router)
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import List
from datetime import datetime
@@ -12,13 +12,13 @@ router = APIRouter()
class PurchaseCreate(BaseModel):
amount_eur: float
price_eur: float
amount_eur: float = Field(gt=0, le=10_000_000)
price_eur: float = Field(gt=0, le=10_000_000)
class PurchaseUpdate(BaseModel):
amount_eur: float
price_eur: float
amount_eur: float = Field(gt=0, le=10_000_000)
price_eur: float = Field(gt=0, le=10_000_000)
created_at: datetime
+7 -2
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel
from pydantic import BaseModel, Field
from ..database import get_db
from .. import models
@@ -10,6 +10,11 @@ router = APIRouter()
class UserCreate(BaseModel):
username: str = Field(min_length=3, max_length=50)
password: str = Field(min_length=8)
class UserLogin(BaseModel):
username: str
password: str
@@ -37,7 +42,7 @@ def register(user_in: UserCreate, db: Session = Depends(get_db)):
@router.post("/login", response_model=Token)
def login(user_in: UserCreate, db: Session = Depends(get_db)):
def login(user_in: UserLogin, db: Session = Depends(get_db)):
user = db.query(models.User).filter(models.User.username == user_in.username).first()
if not user or not verify_password(user_in.password, user.password):
raise HTTPException(status_code=401, detail="Invalid credentials")
+9 -3
View File
@@ -1,6 +1,9 @@
import logging
import requests
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
def get_btc_history_eur() -> list:
try:
@@ -11,7 +14,8 @@ def get_btc_history_eur() -> list:
)
resp.raise_for_status()
return resp.json().get("prices", []) # [[timestamp_ms, price], ...]
except Exception:
except Exception as e:
logger.error(f"Failed to fetch BTC history: {e}")
return []
@@ -25,7 +29,8 @@ def get_btc_ohlc_eur(days: int) -> list:
)
resp.raise_for_status()
return resp.json() # [[timestamp_ms, open, high, low, close], ...]
except Exception:
except Exception as e:
logger.error(f"Failed to fetch BTC OHLC: {e}")
return []
@@ -58,5 +63,6 @@ def get_btc_price_eur() -> float:
)
resp.raise_for_status()
return float(resp.json()["bitcoin"]["eur"])
except Exception:
except Exception as e:
logger.error(f"Failed to fetch BTC price: {e}")
return 0.0
+46 -2
View File
@@ -1,10 +1,10 @@
import logging
from datetime import datetime, timezone, date as dt_date
from datetime import datetime, timezone, timedelta, date as dt_date
from sqlalchemy.orm import Session
from ..models import OHLCCandle
from .btc import get_btc_ohlc_eur, aggregate_to_daily
from .btc import get_btc_ohlc_eur, aggregate_to_daily, get_btc_history_eur
logger = logging.getLogger(__name__)
@@ -30,10 +30,54 @@ def seed_candles(db: Session) -> None:
logger.info("Candle seed: stored %d daily candles (%s%s).", len(rows), min(daily.keys()), max(daily.keys()))
def seed_historical_prices(db: Session) -> None:
"""Backfill up to 365 days of daily close prices from CoinGecko market_chart.
Uses previous day's close as each day's open to produce red/green candles.
Clears entries older than 31 days on each run so the data stays fresh.
Real OHLC entries (last 30 days) are never touched.
"""
raw = get_btc_history_eur()
if not raw:
logger.warning("Historical price seed: CoinGecko returned no data.")
return
prices = {}
for ts_ms, price in raw:
date = datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).strftime("%Y-%m-%d")
prices[date] = price
# Remove stale historical entries (older than 31 days) so they get re-seeded with current data
cutoff = (datetime.now(tz=timezone.utc) - timedelta(days=31)).strftime("%Y-%m-%d")
db.query(OHLCCandle).filter(OHLCCandle.date < cutoff).delete()
db.commit()
existing = {c.date for c in db.query(OHLCCandle).all()}
new_rows = []
prev_close = None
for date, close in sorted(prices.items()):
if date in existing:
prev_close = close
continue
open_ = prev_close if prev_close is not None else close
high = max(open_, close)
low = min(open_, close)
new_rows.append(OHLCCandle(date=date, open=open_, high=high, low=low, close=close))
prev_close = close
if new_rows:
db.add_all(new_rows)
db.commit()
logger.info("Historical price seed: stored %d daily entries (%s%s).", len(new_rows), new_rows[0].date, new_rows[-1].date)
def refresh_latest_candles(db: Session) -> None:
"""Add any missing candles up to today. Seeds the DB if empty.
Also detects and replaces coarse (>2-day gap) legacy data from a previous days=365 seed.
"""
# Always backfill historical prices for dates not yet in DB (no-op once populated)
seed_historical_prices(db)
# Sparse-data detection: if existing candles have >2-day gaps, wipe and re-seed
first_two = db.query(OHLCCandle).order_by(OHLCCandle.date.asc()).limit(2).all()
if len(first_two) == 2:
+13
View File
@@ -7,7 +7,14 @@ services:
- ./data:/app/data
environment:
- DATABASE_URL=sqlite:////app/data/btc_portfolio.db
- SECRET_KEY=${SECRET_KEY:-dev-insecure-key-change-me}
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
frontend:
build:
@@ -19,3 +26,9 @@ services:
depends_on:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3001/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
+6
View File
@@ -0,0 +1,6 @@
.git
node_modules
build
.env
.env.local
npm-debug.log
+3
View File
@@ -10,7 +10,10 @@ RUN npm run build
FROM node:18-alpine
RUN npm install -g serve
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=build /app/build ./build
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 3001
CMD ["serve", "-s", "build", "-l", "3001"]
@@ -39,7 +39,8 @@ export default function AddPurchase({ onAdded }) {
setAmountEur('');
setPriceEur('');
onAdded();
} catch {
} catch (err) {
console.error('AddPurchase network error:', err);
setError('Network error');
}
};
@@ -19,40 +19,84 @@ const styles = {
saveBtn: { marginTop: '0.75rem', background: 'none', border: '1px solid #555', color: '#aaa', borderRadius: '6px', padding: '0.4rem 1rem', cursor: 'pointer', fontSize: '0.85rem' },
};
export default function PortfolioChart({ purchases, stats }) {
function toDateKey(date) {
return date.toISOString().split('T')[0];
}
function priceOn(date, priceMap, currentPrice, isToday, sortedPurchases) {
if (isToday) return currentPrice || 0;
// Try candle history (walk back up to 7 days)
for (let i = 0; i <= 7; i++) {
const d = new Date(date);
d.setDate(d.getDate() - i);
const p = priceMap[toDateKey(d)];
if (p) return p;
}
// Fall back to most recent purchase price up to this date
let fallback = null;
for (const p of sortedPurchases) {
const pd = new Date(p.created_at);
pd.setHours(0, 0, 0, 0);
if (pd <= date) fallback = p.price_eur;
}
return fallback;
}
export default function PortfolioChart({ purchases, stats, btcHistory }) {
const chartRef = useRef(null);
if (!purchases || purchases.length === 0) return null;
// Build cumulative data points sorted by date
const sorted = [...purchases].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
const today = new Date();
today.setHours(0, 0, 0, 0);
// Build price lookup from candle history
const priceMap = {};
(btcHistory || []).forEach(({ date, close }) => { priceMap[date] = close; });
const firstDate = new Date(sorted[0].created_at);
firstDate.setHours(0, 0, 0, 0);
// Generate biweekly dates from first purchase to today
const dates = [];
const cursor = new Date(firstDate);
while (cursor <= today) {
dates.push(new Date(cursor));
cursor.setDate(cursor.getDate() + 7);
}
if (toDateKey(dates[dates.length - 1]) !== toDateKey(today)) {
dates.push(new Date(today));
}
let cumInvested = 0;
let cumBtc = 0;
const labels = [];
const portfolioValues = [];
const investedValues = [];
sorted.forEach((p, i) => {
cumInvested += p.amount_eur;
cumBtc += p.amount_eur / p.price_eur;
const currentVal = cumBtc * (stats?.current_price || p.price_eur);
labels.push(new Date(p.created_at).toLocaleDateString());
portfolioValues.push(parseFloat(currentVal.toFixed(2)));
dates.forEach(date => {
const isToday = toDateKey(date) === toDateKey(today);
const price = priceOn(date, priceMap, stats?.current_price, isToday, sorted);
if (price === null) return; // no price data available, skip
// Cumulative BTC and invested up to this date
let cumBtc = 0;
let cumInvested = 0;
sorted.forEach(p => {
const pDate = new Date(p.created_at);
pDate.setHours(0, 0, 0, 0);
if (pDate <= date) {
cumBtc += p.amount_eur / p.price_eur;
cumInvested += p.amount_eur;
}
});
if (cumBtc === 0) return; // no purchases yet at this date
labels.push(date.toLocaleDateString('en-GB'));
portfolioValues.push(parseFloat((cumBtc * price).toFixed(2)));
investedValues.push(parseFloat(cumInvested.toFixed(2)));
});
const todayLabel = new Date().toLocaleDateString();
if (labels.length === 0 || labels[labels.length - 1] !== todayLabel) {
const currentVal = cumBtc * (stats?.current_price || 0);
labels.push(todayLabel);
portfolioValues.push(parseFloat(currentVal.toFixed(2)));
investedValues.push(parseFloat(cumInvested.toFixed(2)));
}
const currentPrice = stats?.current_price || 0;
const breakEvenLine = labels.map(() => stats?.average_price || 0);
const data = {
labels,
datasets: [
@@ -66,7 +66,12 @@ export default function AdminPage() {
const handleDelete = async (id, name) => {
if (!window.confirm(`Delete user "${name}"? This also deletes all their purchases.`)) return;
await fetch(`${API}/admin/users/${id}`, { method: 'DELETE', headers: authHeaders() });
const res = await fetch(`${API}/admin/users/${id}`, { method: 'DELETE', headers: authHeaders() });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data.detail || 'Failed to delete user');
return;
}
fetchUsers();
};
@@ -135,7 +135,7 @@ export default function Dashboard() {
>{label}</button>
))}
</div>
{(chartView === 'both' || chartView === 'portfolio') && <PortfolioChart purchases={purchases} stats={stats} />}
{(chartView === 'both' || chartView === 'portfolio') && <PortfolioChart purchases={purchases} stats={stats} btcHistory={candles?.candles ?? []} />}
{(chartView === 'both' || chartView === 'history') && (
<BTCCandlestickChart
candles={activeCandles?.candles ?? null}
+2 -1
View File
@@ -37,7 +37,8 @@ export default function Login() {
localStorage.setItem('token', data.access_token);
localStorage.setItem('is_admin', data.is_admin ? 'true' : 'false');
navigate('/');
} catch {
} catch (err) {
console.error('Login network error:', err);
setError('Network error');
}
};
+2 -1
View File
@@ -38,7 +38,8 @@ export default function Register() {
}
setSuccess('Account created! Redirecting...');
setTimeout(() => navigate('/login'), 1500);
} catch {
} catch (err) {
console.error('Register network error:', err);
setError('Network error');
}
};