Initial commit: MyMidas personal finance tracker

Full-stack self-hosted finance app with FastAPI backend and React frontend.

Features:
- Accounts, transactions, budgets, investments with GBP base currency
- CSV import with auto-detection for 10 UK bank formats
- ML predictions: spending forecast, net worth projection, Monte Carlo
- 7 selectable themes (Obsidian, Arctic, Midnight, Vault, Terminal, Synthwave, Ledger)
- Receipt/document attachments on transactions (JPEG, PNG, WebP, PDF)
- AES-256-GCM field encryption, RS256 JWT, TOTP 2FA, RLS, audit log
- Encrypted nightly backups + key rotation script
- Mobile-responsive layout with bottom nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-04-21 11:56:10 +00:00
commit 61a7884ee5
127 changed files with 13323 additions and 0 deletions

45
scripts/backup.sh Executable file
View file

@ -0,0 +1,45 @@
#!/bin/bash
# backup.sh — pg_dump | gzip | gpg encrypt → /backups/
# Run inside the backend container:
# docker compose exec backend bash /app/scripts/backup.sh
# Or from host:
# docker compose exec -e BACKUP_PASSPHRASE="$BACKUP_PASSPHRASE" backend bash scripts/backup.sh
set -euo pipefail
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="${BACKUP_DIR:-/app/backups}"
BACKUP_FILE="${BACKUP_DIR}/${TIMESTAMP}.sql.gz.gpg"
RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-30}"
# Require passphrase
if [ -z "${BACKUP_PASSPHRASE:-}" ]; then
echo "[backup] ERROR: BACKUP_PASSPHRASE is not set" >&2
exit 1
fi
mkdir -p "${BACKUP_DIR}"
echo "[backup] Starting at ${TIMESTAMP}"
# pg_dump using the DATABASE_URL but swap asyncpg driver for psycopg2-compatible URL
PG_URL="${DATABASE_URL/postgresql+asyncpg/postgresql}"
pg_dump "${PG_URL}" \
| gzip \
| gpg --batch --yes --symmetric --cipher-algo AES256 \
--passphrase "${BACKUP_PASSPHRASE}" \
--output "${BACKUP_FILE}"
SIZE=$(du -sh "${BACKUP_FILE}" | cut -f1)
echo "[backup] Written ${SIZE}${BACKUP_FILE}"
# List current backups
COUNT=$(find "${BACKUP_DIR}" -name "*.sql.gz.gpg" | wc -l)
echo "[backup] ${COUNT} backup(s) on disk"
# Prune old backups
PRUNED=$(find "${BACKUP_DIR}" -name "*.sql.gz.gpg" -mtime "+${RETENTION_DAYS}" -print -delete | wc -l)
if [ "${PRUNED}" -gt 0 ]; then
echo "[backup] Pruned ${PRUNED} backup(s) older than ${RETENTION_DAYS} days"
fi
echo "[backup] Done"

56
scripts/restore.sh Executable file
View file

@ -0,0 +1,56 @@
#!/bin/bash
# restore.sh — decrypt and restore a backup
# Usage:
# docker compose exec backend bash scripts/restore.sh <backup_file>
# docker compose exec backend bash scripts/restore.sh --list
set -euo pipefail
BACKUP_DIR="${BACKUP_DIR:-/app/backups}"
if [ "${1:-}" = "--list" ]; then
echo "[restore] Available backups in ${BACKUP_DIR}:"
find "${BACKUP_DIR}" -name "*.sql.gz.gpg" -printf " %f (%s bytes)\n" | sort -r
exit 0
fi
if [ -z "${1:-}" ]; then
echo "Usage: restore.sh <backup_file.sql.gz.gpg>"
echo " restore.sh --list"
exit 1
fi
BACKUP_FILE="$1"
# Accept bare filename or full path
if [ ! -f "${BACKUP_FILE}" ] && [ -f "${BACKUP_DIR}/${BACKUP_FILE}" ]; then
BACKUP_FILE="${BACKUP_DIR}/${BACKUP_FILE}"
fi
if [ ! -f "${BACKUP_FILE}" ]; then
echo "[restore] ERROR: File not found: ${BACKUP_FILE}" >&2
exit 1
fi
if [ -z "${BACKUP_PASSPHRASE:-}" ]; then
echo "[restore] ERROR: BACKUP_PASSPHRASE is not set" >&2
exit 1
fi
PG_URL="${DATABASE_URL/postgresql+asyncpg/postgresql}"
echo "[restore] WARNING: This will overwrite the current database."
echo "[restore] File: ${BACKUP_FILE}"
read -r -p "[restore] Type 'yes' to continue: " CONFIRM
if [ "${CONFIRM}" != "yes" ]; then
echo "[restore] Aborted"
exit 1
fi
echo "[restore] Decrypting and restoring…"
gpg --batch --yes --decrypt \
--passphrase "${BACKUP_PASSPHRASE}" \
"${BACKUP_FILE}" \
| gunzip \
| psql "${PG_URL}"
echo "[restore] Done"

54
scripts/rotate_keys.sh Executable file
View file

@ -0,0 +1,54 @@
#!/bin/bash
# rotate_keys.sh — re-encrypt all AES-256-GCM fields with a new key.
# The application must be STOPPED before running.
#
# Usage:
# NEW_ENCRYPTION_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
# docker compose stop backend
# NEW_ENCRYPTION_KEY="$NEW_ENCRYPTION_KEY" ./scripts/rotate_keys.sh
# # On success, update ENCRYPTION_KEY in .env, then:
# docker compose up -d backend
set -euo pipefail
if [ -z "${NEW_ENCRYPTION_KEY:-}" ]; then
echo "ERROR: NEW_ENCRYPTION_KEY is not set"
echo "Generate with: python3 -c \"import secrets; print(secrets.token_hex(32))\""
exit 1
fi
if [ -z "${ENCRYPTION_KEY:-}" ]; then
# Try to load from .env in the project root
ENV_FILE="$(dirname "$0")/../.env"
if [ -f "${ENV_FILE}" ]; then
ENCRYPTION_KEY=$(grep -E '^ENCRYPTION_KEY=' "${ENV_FILE}" | cut -d= -f2- | tr -d '"' | tr -d "'")
fi
fi
if [ -z "${ENCRYPTION_KEY:-}" ]; then
echo "ERROR: ENCRYPTION_KEY (current key) could not be found in environment or .env"
exit 1
fi
if [ "${ENCRYPTION_KEY}" = "${NEW_ENCRYPTION_KEY}" ]; then
echo "ERROR: NEW_ENCRYPTION_KEY is the same as the current key — nothing to do"
exit 1
fi
echo "[rotate] This will re-encrypt ALL sensitive fields in the database."
echo "[rotate] Ensure the application containers are stopped before continuing."
read -r -p "[rotate] Type 'yes' to continue: " CONFIRM
if [ "${CONFIRM}" != "yes" ]; then
echo "[rotate] Aborted"
exit 1
fi
echo "[rotate] Running key rotation inside the backend container…"
docker compose exec \
-e ENCRYPTION_KEY="${ENCRYPTION_KEY}" \
-e NEW_ENCRYPTION_KEY="${NEW_ENCRYPTION_KEY}" \
backend python -m app.core.key_rotation
echo ""
echo "[rotate] SUCCESS. Next steps:"
echo " 1. Update ENCRYPTION_KEY in .env to: ${NEW_ENCRYPTION_KEY}"
echo " 2. Restart the backend: docker compose up -d backend"