From 85455f32717a5341460f6200a06f834d8f1fb42f Mon Sep 17 00:00:00 2001 From: Jonathan Date: Thu, 26 Mar 2026 18:40:41 +0100 Subject: [PATCH 1/5] 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 --- .gitignore | 2 ++ btc-portfolio/backend/.dockerignore | 7 +++++++ btc-portfolio/backend/Dockerfile | 6 +++++- btc-portfolio/backend/app/auth.py | 3 ++- btc-portfolio/backend/app/main.py | 4 ++-- btc-portfolio/backend/app/routes/purchases.py | 10 +++++----- btc-portfolio/backend/app/routes/users.py | 6 +++--- btc-portfolio/backend/app/services/btc.py | 12 +++++++++--- btc-portfolio/backend/requirements.txt | 2 +- btc-portfolio/docker-compose.yml | 13 +++++++++++++ btc-portfolio/frontend/.dockerignore | 6 ++++++ btc-portfolio/frontend/Dockerfile | 3 +++ .../frontend/src/components/AddPurchase.js | 3 ++- btc-portfolio/frontend/src/pages/AdminPage.js | 7 ++++++- btc-portfolio/frontend/src/pages/Login.js | 3 ++- btc-portfolio/frontend/src/pages/Register.js | 3 ++- 16 files changed, 70 insertions(+), 20 deletions(-) create mode 100644 .gitignore create mode 100644 btc-portfolio/backend/.dockerignore create mode 100644 btc-portfolio/frontend/.dockerignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..82ff38b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Local wallet scripts with credentials — never commit +btc_wallet.py diff --git a/btc-portfolio/backend/.dockerignore b/btc-portfolio/backend/.dockerignore new file mode 100644 index 0000000..9b5dfdb --- /dev/null +++ b/btc-portfolio/backend/.dockerignore @@ -0,0 +1,7 @@ +.git +__pycache__ +*.pyc +*.pyo +.env +*.egg-info +.pytest_cache diff --git a/btc-portfolio/backend/Dockerfile b/btc-portfolio/backend/Dockerfile index 7f2d0b9..7691b6d 100644 --- a/btc-portfolio/backend/Dockerfile +++ b/btc-portfolio/backend/Dockerfile @@ -5,8 +5,12 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt +RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser + COPY . . -RUN mkdir -p /app/data +RUN mkdir -p /app/data && chown -R appuser:appgroup /app/data + +USER appuser CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/btc-portfolio/backend/app/auth.py b/btc-portfolio/backend/app/auth.py index 7d1993e..9e3dc53 100644 --- a/btc-portfolio/backend/app/auth.py +++ b/btc-portfolio/backend/app/auth.py @@ -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 diff --git a/btc-portfolio/backend/app/main.py b/btc-portfolio/backend/app/main.py index 0c2d4f0..b458409 100644 --- a/btc-portfolio/backend/app/main.py +++ b/btc-portfolio/backend/app/main.py @@ -13,8 +13,8 @@ app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:3000", "http://localhost:3001"], allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["Content-Type", "Authorization"], ) app.include_router(users.router) diff --git a/btc-portfolio/backend/app/routes/purchases.py b/btc-portfolio/backend/app/routes/purchases.py index bad5c7a..f8c3c65 100644 --- a/btc-portfolio/backend/app/routes/purchases.py +++ b/btc-portfolio/backend/app/routes/purchases.py @@ -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 diff --git a/btc-portfolio/backend/app/routes/users.py b/btc-portfolio/backend/app/routes/users.py index d78d118..eb2a640 100644 --- a/btc-portfolio/backend/app/routes/users.py +++ b/btc-portfolio/backend/app/routes/users.py @@ -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,8 +10,8 @@ router = APIRouter() class UserCreate(BaseModel): - username: str - password: str + username: str = Field(min_length=3, max_length=50) + password: str = Field(min_length=8) class Token(BaseModel): diff --git a/btc-portfolio/backend/app/services/btc.py b/btc-portfolio/backend/app/services/btc.py index 20d50f0..02f471c 100644 --- a/btc-portfolio/backend/app/services/btc.py +++ b/btc-portfolio/backend/app/services/btc.py @@ -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 diff --git a/btc-portfolio/backend/requirements.txt b/btc-portfolio/backend/requirements.txt index 9c39138..c2f949b 100644 --- a/btc-portfolio/backend/requirements.txt +++ b/btc-portfolio/backend/requirements.txt @@ -2,7 +2,7 @@ fastapi uvicorn[standard] sqlalchemy passlib[bcrypt] -bcrypt==3.2.2 +bcrypt>=4.0.0 python-jose[cryptography] requests python-multipart diff --git a/btc-portfolio/docker-compose.yml b/btc-portfolio/docker-compose.yml index d126cfc..1308903 100644 --- a/btc-portfolio/docker-compose.yml +++ b/btc-portfolio/docker-compose.yml @@ -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 diff --git a/btc-portfolio/frontend/.dockerignore b/btc-portfolio/frontend/.dockerignore new file mode 100644 index 0000000..ae12c86 --- /dev/null +++ b/btc-portfolio/frontend/.dockerignore @@ -0,0 +1,6 @@ +.git +node_modules +build +.env +.env.local +npm-debug.log diff --git a/btc-portfolio/frontend/Dockerfile b/btc-portfolio/frontend/Dockerfile index 42fb6de..7f73f59 100644 --- a/btc-portfolio/frontend/Dockerfile +++ b/btc-portfolio/frontend/Dockerfile @@ -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"] diff --git a/btc-portfolio/frontend/src/components/AddPurchase.js b/btc-portfolio/frontend/src/components/AddPurchase.js index 6b1e04b..a4c1add 100644 --- a/btc-portfolio/frontend/src/components/AddPurchase.js +++ b/btc-portfolio/frontend/src/components/AddPurchase.js @@ -39,7 +39,8 @@ export default function AddPurchase({ onAdded }) { setAmountEur(''); setPriceEur(''); onAdded(); - } catch { + } catch (err) { + console.error('AddPurchase network error:', err); setError('Network error'); } }; diff --git a/btc-portfolio/frontend/src/pages/AdminPage.js b/btc-portfolio/frontend/src/pages/AdminPage.js index 810dab0..0152842 100644 --- a/btc-portfolio/frontend/src/pages/AdminPage.js +++ b/btc-portfolio/frontend/src/pages/AdminPage.js @@ -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(); }; diff --git a/btc-portfolio/frontend/src/pages/Login.js b/btc-portfolio/frontend/src/pages/Login.js index 984e60f..872e030 100644 --- a/btc-portfolio/frontend/src/pages/Login.js +++ b/btc-portfolio/frontend/src/pages/Login.js @@ -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'); } }; diff --git a/btc-portfolio/frontend/src/pages/Register.js b/btc-portfolio/frontend/src/pages/Register.js index 803ff07..2fbccde 100644 --- a/btc-portfolio/frontend/src/pages/Register.js +++ b/btc-portfolio/frontend/src/pages/Register.js @@ -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'); } }; From 4da23c9defcbad02f38aab92354399afbcfe16d9 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Thu, 26 Mar 2026 18:44:22 +0100 Subject: [PATCH 2/5] Revert bcrypt version back to 3.2.2 Co-Authored-By: Claude Sonnet 4.6 --- btc-portfolio/backend/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/btc-portfolio/backend/requirements.txt b/btc-portfolio/backend/requirements.txt index c2f949b..9c39138 100644 --- a/btc-portfolio/backend/requirements.txt +++ b/btc-portfolio/backend/requirements.txt @@ -2,7 +2,7 @@ fastapi uvicorn[standard] sqlalchemy passlib[bcrypt] -bcrypt>=4.0.0 +bcrypt==3.2.2 python-jose[cryptography] requests python-multipart From 11b020907d9ba3d2fcc21d6dd362f8d651ef5e8d Mon Sep 17 00:00:00 2001 From: Jonathan Date: Thu, 26 Mar 2026 18:45:41 +0100 Subject: [PATCH 3/5] Revert backend Dockerfile to run as root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- btc-portfolio/backend/Dockerfile | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/btc-portfolio/backend/Dockerfile b/btc-portfolio/backend/Dockerfile index 7691b6d..7f2d0b9 100644 --- a/btc-portfolio/backend/Dockerfile +++ b/btc-portfolio/backend/Dockerfile @@ -5,12 +5,8 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser - COPY . . -RUN mkdir -p /app/data && chown -R appuser:appgroup /app/data - -USER appuser +RUN mkdir -p /app/data CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] From ce227e47d689d14ad6cdf976c0e066b37884f3a3 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Thu, 26 Mar 2026 18:49:43 +0100 Subject: [PATCH 4/5] 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 --- btc-portfolio/backend/app/routes/users.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/btc-portfolio/backend/app/routes/users.py b/btc-portfolio/backend/app/routes/users.py index eb2a640..4395342 100644 --- a/btc-portfolio/backend/app/routes/users.py +++ b/btc-portfolio/backend/app/routes/users.py @@ -14,6 +14,11 @@ class UserCreate(BaseModel): password: str = Field(min_length=8) +class UserLogin(BaseModel): + username: str + password: str + + class Token(BaseModel): access_token: str token_type: 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") From 5cf3726f598f97057526ed85050e6ce98cf98d4f Mon Sep 17 00:00:00 2001 From: Jonathan Date: Mon, 6 Apr 2026 19:30:48 +0200 Subject: [PATCH 5/5] 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 --- btc-portfolio/backend/app/services/candles.py | 48 ++++++++++- .../frontend/src/components/PortfolioChart.js | 86 ++++++++++++++----- btc-portfolio/frontend/src/pages/Dashboard.js | 2 +- 3 files changed, 112 insertions(+), 24 deletions(-) diff --git a/btc-portfolio/backend/app/services/candles.py b/btc-portfolio/backend/app/services/candles.py index 434a569..31016ad 100644 --- a/btc-portfolio/backend/app/services/candles.py +++ b/btc-portfolio/backend/app/services/candles.py @@ -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: diff --git a/btc-portfolio/frontend/src/components/PortfolioChart.js b/btc-portfolio/frontend/src/components/PortfolioChart.js index f8a0ffd..628e70c 100644 --- a/btc-portfolio/frontend/src/components/PortfolioChart.js +++ b/btc-portfolio/frontend/src/components/PortfolioChart.js @@ -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: [ diff --git a/btc-portfolio/frontend/src/pages/Dashboard.js b/btc-portfolio/frontend/src/pages/Dashboard.js index dd5baff..4b3762c 100644 --- a/btc-portfolio/frontend/src/pages/Dashboard.js +++ b/btc-portfolio/frontend/src/pages/Dashboard.js @@ -135,7 +135,7 @@ export default function Dashboard() { >{label} ))} - {(chartView === 'both' || chartView === 'portfolio') && } + {(chartView === 'both' || chartView === 'portfolio') && } {(chartView === 'both' || chartView === 'history') && (