From 57baefe1c72454c2c0563cb0f64a80cf9891331f Mon Sep 17 00:00:00 2001 From: Paul Thomas Date: Fri, 29 May 2026 12:00:49 +0100 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=20Finance=20Planne?= =?UTF-8?q?r=20setup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 13 ++ admin.php | 251 ++++++++++++++++++++++++ api.php | 453 ++++++++++++++++++++++++++++++++++++++++++++ auth.php | 113 +++++++++++ donate.php | 95 ++++++++++ git-cron-setup.sh | 48 +++++ git-setup.sh | 98 ++++++++++ git-sync.sh | 50 +++++ gitignore | 20 ++ login.php | 95 ++++++++++ nav.php | 85 +++++++++ register.php | 136 +++++++++++++ settings.php | 155 +++++++++++++++ style_additions.css | 69 +++++++ 14 files changed, 1681 insertions(+) create mode 100644 .gitignore create mode 100644 admin.php create mode 100644 api.php create mode 100644 auth.php create mode 100644 donate.php create mode 100755 git-cron-setup.sh create mode 100755 git-setup.sh create mode 100755 git-sync.sh create mode 100644 gitignore create mode 100644 login.php create mode 100644 nav.php create mode 100644 register.php create mode 100644 settings.php create mode 100644 style_additions.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ce950f --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +db.php +*.log +*.cache +/vendor/ +*.env +.DS_Store +Thumbs.db +desktop.ini +.vscode/ +.idea/ +*.swp +*.swo +*~ diff --git a/admin.php b/admin.php new file mode 100644 index 0000000..d3b5a62 --- /dev/null +++ b/admin.php @@ -0,0 +1,251 @@ + +
+ + +
+
Total Users
+
Admins
+
Suspended
+
Active
+
+ + + +
+

Loading…

+
+ + + +
+
+ + +
+
+ + +
+
+ + + +
+

+ Configure donation links shown to all users on the Donate page and Settings. Leave blank to hide. +

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+ + + + + + + + + + + diff --git a/api.php b/api.php new file mode 100644 index 0000000..f9dd557 --- /dev/null +++ b/api.php @@ -0,0 +1,453 @@ + true, 'data' => $data] + : ['ok' => false, 'error' => $error] + ); + exit; +} + +// All API calls require login +if (!currentUserId()) respond(false, null, 'Not authenticated'); + +$action = $_GET['action'] ?? $_POST['action'] ?? ''; +$uid = currentUserId(); + +try { + $db = getDB(); + + // ── ACCOUNTS ───────────────────────────────────────────────────────────── + if ($action === 'list_accounts') { + $st = $db->prepare("SELECT * FROM accounts WHERE user_id=? ORDER BY name"); + $st->execute([$uid]); + respond(true, $st->fetchAll()); + } + + if ($action === 'add_account') { + $name = trim($_POST['name'] ?? ''); + $currency = strtoupper(trim($_POST['currency'] ?? 'GBP')); + if (!$name) respond(false, null, 'Name required'); + $st = $db->prepare("INSERT INTO accounts (user_id, name, currency) VALUES (?,?,?)"); + $st->execute([$uid, $name, $currency]); + respond(true, ['id' => $db->lastInsertId()]); + } + + if ($action === 'delete_account') { + $id = (int)($_POST['id'] ?? 0); + $db->prepare("DELETE FROM accounts WHERE id=? AND user_id=?")->execute([$id, $uid]); + respond(true); + } + + // ── RECURRING TRANSACTIONS ──────────────────────────────────────────────── + if ($action === 'list_transactions') { + $aid = (int)($_GET['account_id'] ?? 0); + if ($aid) { + $st = $db->prepare("SELECT t.*, a.currency FROM transactions t + JOIN accounts a ON a.id=t.account_id + WHERE t.account_id=? AND a.user_id=? ORDER BY t.name"); + $st->execute([$aid, $uid]); + } else { + $st = $db->prepare("SELECT t.*, a.currency FROM transactions t + JOIN accounts a ON a.id=t.account_id WHERE a.user_id=? ORDER BY t.name"); + $st->execute([$uid]); + } + respond(true, $st->fetchAll()); + } + + if ($action === 'add_transaction') { + $st = $db->prepare("INSERT INTO transactions + (account_id,name,type,amount,start_date,frequency,interval_value,ends_on,notes,tags) + VALUES (?,?,?,?,?,?,?,?,?,?)"); + $st->execute([ + (int)$_POST['account_id'], + trim($_POST['name']), + $_POST['type'], + (float)$_POST['amount'], + $_POST['start_date'], + $_POST['frequency'], + (int)($_POST['interval_value'] ?? 1), + $_POST['ends_on'] ?: null, + trim($_POST['notes'] ?? '') ?: null, + trim($_POST['tags'] ?? '') ?: null, + ]); + respond(true, ['id' => $db->lastInsertId()]); + } + + if ($action === 'get_transaction') { + $st = $db->prepare("SELECT t.* FROM transactions t + JOIN accounts a ON a.id=t.account_id WHERE t.id=? AND a.user_id=?"); + $st->execute([(int)($_GET['id']??0), $uid]); + respond(true, $st->fetch()); + } + + if ($action === 'update_transaction') { + $st = $db->prepare("UPDATE transactions SET + name=?,type=?,amount=?,start_date=?,frequency=?,interval_value=?,ends_on=?,notes=?,tags=? + WHERE id=?"); + $st->execute([ + trim($_POST['name']), + $_POST['type'], + (float)$_POST['amount'], + $_POST['start_date'], + $_POST['frequency'], + (int)($_POST['interval_value'] ?? 1), + $_POST['ends_on'] ?: null, + trim($_POST['notes'] ?? '') ?: null, + trim($_POST['tags'] ?? '') ?: null, + (int)$_POST['id'], + ]); + respond(true); + } + + if ($action === 'delete_transaction') { + $db->prepare("DELETE FROM transactions WHERE id=?")->execute([(int)($_POST['id']??0)]); + respond(true); + } + + // ── BALANCES ───────────────────────────────────────────────────────────── + if ($action === 'list_balances') { + $aid = (int)($_GET['account_id'] ?? 0); + if ($aid) { + $st = $db->prepare("SELECT b.*,a.name AS account_name,a.currency FROM balances b + JOIN accounts a ON a.id=b.account_id WHERE b.account_id=? AND a.user_id=? + ORDER BY b.recorded_on DESC"); + $st->execute([$aid, $uid]); + } else { + $st = $db->prepare("SELECT b.*,a.name AS account_name,a.currency FROM balances b + JOIN accounts a ON a.id=b.account_id WHERE a.user_id=? + ORDER BY b.recorded_on DESC"); + $st->execute([$uid]); + } + respond(true, $st->fetchAll()); + } + + if ($action === 'add_balance') { + $st = $db->prepare("INSERT INTO balances (account_id,amount,type,recorded_on,notes) VALUES (?,?,?,?,?)"); + $st->execute([ + (int)$_POST['account_id'], + (float)$_POST['amount'], + $_POST['type'], + $_POST['recorded_on'], + trim($_POST['notes'] ?? '') ?: null, + ]); + respond(true, ['id' => $db->lastInsertId()]); + } + + if ($action === 'delete_balance') { + $db->prepare("DELETE FROM balances WHERE id=?")->execute([(int)($_POST['id']??0)]); + respond(true); + } + + // ── ONE-OFF TRANSACTIONS ────────────────────────────────────────────────── + if ($action === 'list_oneoff') { + $aid = (int)($_GET['account_id'] ?? 0); + $limit = (int)($_GET['limit'] ?? 100); + if ($aid) { + $st = $db->prepare("SELECT o.*,a.currency FROM oneoff_transactions o + JOIN accounts a ON a.id=o.account_id + WHERE o.account_id=? AND a.user_id=? ORDER BY o.date DESC LIMIT $limit"); + $st->execute([$aid, $uid]); + } else { + $st = $db->prepare("SELECT o.*,a.name AS account_name,a.currency FROM oneoff_transactions o + JOIN accounts a ON a.id=o.account_id WHERE a.user_id=? ORDER BY o.date DESC LIMIT $limit"); + $st->execute([$uid]); + } + respond(true, $st->fetchAll()); + } + + if ($action === 'add_oneoff') { + $st = $db->prepare("INSERT INTO oneoff_transactions + (account_id,name,type,amount,date,notes,tags) VALUES (?,?,?,?,?,?,?)"); + $st->execute([ + (int)$_POST['account_id'], + trim($_POST['name']), + $_POST['type'], + (float)$_POST['amount'], + $_POST['date'], + trim($_POST['notes'] ?? '') ?: null, + trim($_POST['tags'] ?? '') ?: null, + ]); + respond(true, ['id' => $db->lastInsertId()]); + } + + if ($action === 'get_oneoff') { + $st = $db->prepare("SELECT o.* FROM oneoff_transactions o + JOIN accounts a ON a.id=o.account_id WHERE o.id=? AND a.user_id=?"); + $st->execute([(int)($_GET['id']??0), $uid]); + respond(true, $st->fetch()); + } + + if ($action === 'update_oneoff') { + $st = $db->prepare("UPDATE oneoff_transactions SET + name=?,type=?,amount=?,date=?,notes=?,tags=? WHERE id=?"); + $st->execute([ + trim($_POST['name']), + $_POST['type'], + (float)$_POST['amount'], + $_POST['date'], + trim($_POST['notes'] ?? '') ?: null, + trim($_POST['tags'] ?? '') ?: null, + (int)$_POST['id'], + ]); + respond(true); + } + + if ($action === 'delete_oneoff') { + $db->prepare("DELETE FROM oneoff_transactions WHERE id=?")->execute([(int)($_POST['id']??0)]); + respond(true); + } + + // ── DEBTS ───────────────────────────────────────────────────────────────── + if ($action === 'list_debts') { + $st = $db->prepare("SELECT * FROM debts WHERE user_id=? ORDER BY balance DESC"); + $st->execute([$uid]); + respond(true, $st->fetchAll()); + } + + if ($action === 'add_debt') { + $st = $db->prepare("INSERT INTO debts + (user_id,name,type,balance,interest_rate,min_payment,due_day,credit_limit,notes,tags,opened_on,currency) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)"); + $st->execute([ + $uid, + trim($_POST['name']), + $_POST['dtype'], + (float)$_POST['balance'], + (float)($_POST['interest_rate'] ?? 0), + (float)($_POST['min_payment'] ?? 0), + $_POST['due_day'] ? (int)$_POST['due_day'] : null, + $_POST['credit_limit'] ? (float)$_POST['credit_limit'] : null, + trim($_POST['notes'] ?? '') ?: null, + trim($_POST['tags'] ?? '') ?: null, + $_POST['opened_on'] ?: null, + strtoupper(trim($_POST['currency'] ?? 'GBP')), + ]); + respond(true, ['id' => $db->lastInsertId()]); + } + + if ($action === 'get_debt') { + $st = $db->prepare("SELECT * FROM debts WHERE id=? AND user_id=?"); + $st->execute([(int)($_GET['id']??0), $uid]); + respond(true, $st->fetch()); + } + + if ($action === 'update_debt') { + $st = $db->prepare("UPDATE debts SET + name=?,type=?,balance=?,interest_rate=?,min_payment=?,due_day=?, + credit_limit=?,notes=?,tags=?,opened_on=?,currency=? + WHERE id=? AND user_id=?"); + $st->execute([ + trim($_POST['name']), + $_POST['dtype'], + (float)$_POST['balance'], + (float)($_POST['interest_rate'] ?? 0), + (float)($_POST['min_payment'] ?? 0), + $_POST['due_day'] ? (int)$_POST['due_day'] : null, + $_POST['credit_limit'] ? (float)$_POST['credit_limit'] : null, + trim($_POST['notes'] ?? '') ?: null, + trim($_POST['tags'] ?? '') ?: null, + $_POST['opened_on'] ?: null, + strtoupper(trim($_POST['currency'] ?? 'GBP')), + (int)$_POST['id'], + $uid, + ]); + respond(true); + } + + if ($action === 'delete_debt') { + $db->prepare("DELETE FROM debts WHERE id=? AND user_id=?")->execute([(int)($_POST['id']??0), $uid]); + respond(true); + } + + // ── TRENDS ──────────────────────────────────────────────────────────────── + if ($action === 'trends') { + $aid = (int)($_GET['account_id'] ?? 0); + $start = new DateTime('first day of this month'); + $end = (clone $start)->modify('+12 months'); + + if ($aid) { + $balSt = $db->prepare("SELECT amount, type FROM balances WHERE account_id=? ORDER BY recorded_on DESC LIMIT 1"); + $balSt->execute([$aid]); + } else { + $balSt = $db->prepare("SELECT b.amount,b.type FROM balances b + JOIN accounts a ON a.id=b.account_id WHERE a.user_id=? ORDER BY b.recorded_on DESC LIMIT 1"); + $balSt->execute([$uid]); + } + $balRow = $balSt->fetch(); + $runningBalance = $balRow + ? ($balRow['type'] === 'negative' ? -(float)$balRow['amount'] : (float)$balRow['amount']) + : 0.0; + + if ($aid) { + $txSt = $db->prepare("SELECT t.* FROM transactions t + JOIN accounts a ON a.id=t.account_id WHERE t.account_id=? AND a.user_id=?"); + $txSt->execute([$aid, $uid]); + } else { + $txSt = $db->prepare("SELECT t.* FROM transactions t + JOIN accounts a ON a.id=t.account_id WHERE a.user_id=?"); + $txSt->execute([$uid]); + } + $txs = $txSt->fetchAll(); + + $points = []; + $cursor = clone $start; + while ($cursor <= $end) { + foreach ($txs as $tx) { + if (occursOn($tx, $cursor)) { + $signed = $tx['type'] === 'income' ? (float)$tx['amount'] : -(float)$tx['amount']; + $runningBalance += $signed; + } + } + $points[] = ['date' => $cursor->format('M Y'), 'balance' => round($runningBalance, 2)]; + $cursor->modify('+1 month'); + } + respond(true, $points); + } + + // ── SUMMARY ─────────────────────────────────────────────────────────────── + if ($action === 'summary') { + $aid = (int)($_GET['account_id'] ?? 0); + if ($aid) { + $iSt = $db->prepare("SELECT COALESCE(SUM(amount),0) FROM transactions WHERE account_id=? AND type='income'"); + $iSt->execute([$aid]); + $eSt = $db->prepare("SELECT COALESCE(SUM(amount),0) FROM transactions WHERE account_id=? AND type='expense'"); + $eSt->execute([$aid]); + } else { + $iSt = $db->prepare("SELECT COALESCE(SUM(t.amount),0) FROM transactions t + JOIN accounts a ON a.id=t.account_id WHERE a.user_id=? AND t.type='income'"); + $iSt->execute([$uid]); + $eSt = $db->prepare("SELECT COALESCE(SUM(t.amount),0) FROM transactions t + JOIN accounts a ON a.id=t.account_id WHERE a.user_id=? AND t.type='expense'"); + $eSt->execute([$uid]); + } + $dSt = $db->prepare("SELECT COALESCE(SUM(balance),0) FROM debts WHERE user_id=?"); + $dSt->execute([$uid]); + + respond(true, [ + 'monthly_income' => (float)$iSt->fetchColumn(), + 'monthly_expense' => (float)$eSt->fetchColumn(), + 'total_debt' => (float)$dSt->fetchColumn(), + ]); + } + + // ── USER SETTINGS ───────────────────────────────────────────────────────── + if ($action === 'change_password') { + $ok = changePassword($uid, $_POST['current'] ?? '', $_POST['new'] ?? ''); + $ok ? respond(true) : respond(false, null, 'Current password is incorrect.'); + } + + if ($action === 'update_profile') { + $dn = trim($_POST['display_name'] ?? ''); + $db->prepare("UPDATE users SET display_name=? WHERE id=?")->execute([$dn, $uid]); + $_SESSION['user']['display_name'] = $dn; + respond(true); + } + + // ── ADMIN ───────────────────────────────────────────────────────────────── + // All admin actions re-verify admin status from DB for security + + if ($action === 'admin_list_users') { + if (!isAdmin()) respond(false, null, 'Forbidden'); + $st = $db->query(" + SELECT u.id, u.username, u.display_name, u.is_admin, u.is_suspended, u.created_at, + (SELECT COUNT(*) FROM accounts WHERE user_id = u.id) AS account_count + FROM users u + ORDER BY u.created_at ASC + "); + respond(true, $st->fetchAll()); + } + + if ($action === 'admin_toggle_admin') { + if (!isAdmin()) respond(false, null, 'Forbidden'); + $id = (int)($_POST['id'] ?? 0); + if ($id === $uid) respond(false, null, 'You cannot change your own admin status.'); + $db->prepare("UPDATE users SET is_admin = 1 - is_admin WHERE id = ?")->execute([$id]); + respond(true); + } + + if ($action === 'admin_toggle_suspend') { + if (!isAdmin()) respond(false, null, 'Forbidden'); + $id = (int)($_POST['id'] ?? 0); + if ($id === $uid) respond(false, null, 'You cannot suspend yourself.'); + $db->prepare("UPDATE users SET is_suspended = 1 - is_suspended WHERE id = ?")->execute([$id]); + respond(true); + } + + if ($action === 'admin_delete_user') { + if (!isAdmin()) respond(false, null, 'Forbidden'); + $id = (int)($_POST['id'] ?? 0); + if ($id === $uid) respond(false, null, 'You cannot delete your own account.'); + // Safety: never delete another admin + $st = $db->prepare("SELECT is_admin FROM users WHERE id = ?"); + $st->execute([$id]); + $target = $st->fetch(); + if (!$target) respond(false, null, 'User not found.'); + if ($target['is_admin']) respond(false, null, 'Cannot delete another admin. Revoke their admin status first.'); + $db->prepare("DELETE FROM users WHERE id = ?")->execute([$id]); + respond(true); + } + + if ($action === 'admin_reset_password') { + if (!isAdmin()) respond(false, null, 'Forbidden'); + $id = (int)($_POST['id'] ?? 0); + $pw = $_POST['password'] ?? ''; + if (strlen($pw) < 6) respond(false, null, 'Password must be at least 6 characters.'); + $db->prepare("UPDATE users SET password_hash = ? WHERE id = ?") + ->execute([password_hash($pw, PASSWORD_DEFAULT), $id]); + respond(true); + } + + if ($action === 'admin_get_settings') { + if (!isAdmin()) respond(false, null, 'Forbidden'); + $st = $db->query("SELECT setting_key, setting_value FROM settings"); + $out = []; + foreach ($st->fetchAll() as $row) { + $out[$row['setting_key']] = $row['setting_value']; + } + respond(true, $out); + } + + if ($action === 'admin_save_settings') { + if (!isAdmin()) respond(false, null, 'Forbidden'); + $allowed = ['site_name','registration_enabled','donation_message','paypal_link','kofi_link','buymeacoffee_link']; + $st = $db->prepare("INSERT INTO settings (setting_key, setting_value) VALUES (?,?) + ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)"); + foreach ($allowed as $k) { + $st->execute([$k, trim($_POST[$k] ?? '')]); + } + respond(true); + } + + respond(false, null, "Unknown action: $action"); + +} catch (Exception $e) { + respond(false, null, $e->getMessage()); +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── +function occursOn(array $tx, DateTime $date): bool { + $start = new DateTime($tx['start_date']); + if ($date < $start) return false; + if ($tx['ends_on'] && $date > new DateTime($tx['ends_on'])) return false; + $freq = $tx['frequency']; + $iv = (int)$tx['interval_value']; + if ($freq === 'monthly') { + return $date->format('d') === $start->format('d') + && (($date->format('Ym') - $start->format('Ym')) % $iv === 0); + } + if ($freq === 'weekly') { + return ((int)$start->diff($date)->days % (7 * $iv)) === 0; + } + if ($freq === 'yearly') { + return $date->format('m-d') === $start->format('m-d'); + } + return false; +} diff --git a/auth.php b/auth.php new file mode 100644 index 0000000..9fd1d04 --- /dev/null +++ b/auth.php @@ -0,0 +1,113 @@ +' + . 'Access Denied · Finance Planner' + . '' + . '' + . '
' + . '
🚫
' + . '

Access Denied

' + . '

Admin privileges required.

' + . '← Back to Dashboard' + . '
'; + exit; + } +} + +/** + * Returns true on success, 'suspended' if account suspended, false on bad credentials. + */ +function attemptLogin(string $username, string $password): bool|string { + require_once __DIR__ . '/db.php'; + $db = getDB(); + $st = $db->prepare("SELECT * FROM users WHERE username = ? LIMIT 1"); + $st->execute([trim($username)]); + $user = $st->fetch(); + + if (!$user) return false; + if ((int)($user['is_suspended'] ?? 0) === 1) return 'suspended'; + if (!password_verify($password, $user['password_hash'])) return false; + + $_SESSION['user'] = [ + 'id' => (int)$user['id'], + 'username' => $user['username'], + 'display_name' => $user['display_name'] ?? $user['username'], + 'is_admin' => (int)($user['is_admin'] ?? 0), + ]; + return true; +} + +function logout(): void { + $_SESSION = []; + if (ini_get('session.use_cookies')) { + $p = session_get_cookie_params(); + setcookie(session_name(), '', time() - 42000, + $p['path'], $p['domain'], $p['secure'], $p['httponly']); + } + session_destroy(); +} + +function changePassword(int $userId, string $currentPassword, string $newPassword): bool { + require_once __DIR__ . '/db.php'; + $db = getDB(); + $st = $db->prepare("SELECT password_hash FROM users WHERE id = ?"); + $st->execute([$userId]); + $row = $st->fetch(); + if (!$row || !password_verify($currentPassword, $row['password_hash'])) return false; + $db->prepare("UPDATE users SET password_hash = ? WHERE id = ?") + ->execute([password_hash($newPassword, PASSWORD_DEFAULT), $userId]); + return true; +} + +// ── App settings (stored in `settings` table) ───────────────────────────── + +function getSetting(string $key, string $default = ''): string { + try { + require_once __DIR__ . '/db.php'; + $db = getDB(); + $st = $db->prepare("SELECT setting_value FROM settings WHERE setting_key = ?"); + $st->execute([$key]); + $row = $st->fetch(); + return $row ? $row['setting_value'] : $default; + } catch (Exception $e) { + return $default; + } +} + +function setSetting(string $key, string $value): void { + require_once __DIR__ . '/db.php'; + $db = getDB(); + $db->prepare("INSERT INTO settings (setting_key, setting_value) VALUES (?,?) + ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)") + ->execute([$key, $value]); +} diff --git a/donate.php b/donate.php new file mode 100644 index 0000000..089b551 --- /dev/null +++ b/donate.php @@ -0,0 +1,95 @@ + +
+ +
+
❤️
+

Support

+

+ +

+
+ + +
+
🔧
+

+ No donation options have been set up yet.
+ + Configure them in the Admin Panel → + + Check back soon! + +

+
+ + + +
+ + +
+
💳
+
+
PayPal
+
Fast, secure, widely accepted
+
+ Donate +
+ + + +
+
+
+
Ko-fi
+
Buy me a coffee, no account needed
+
+ Donate +
+ + + +
+
+
+
Buy Me a Coffee
+
One-time or monthly support
+
+ Donate +
+ + +
+ +

+ Every donation, however small, keeps this project going. Thank you! ❤️ +

+ + + +
+ + + + diff --git a/git-cron-setup.sh b/git-cron-setup.sh new file mode 100755 index 0000000..91ca727 --- /dev/null +++ b/git-cron-setup.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# ───────────────────────────────────────────── +# git-cron-setup.sh · Optional: auto-sync +# on a schedule using cron. +# +# Adds a cron job that runs git-sync.sh +# every night at 2am. +# ───────────────────────────────────────────── + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SYNC_SCRIPT="$SCRIPT_DIR/git-sync.sh" +LOG_FILE="$SCRIPT_DIR/git-sync.log" +CRON_SCHEDULE="0 2 * * *" # 2:00am daily — change if you like + +echo "" +echo "╔══════════════════════════════════════════╗" +echo "║ Auto-Sync Cron Setup ║" +echo "╚══════════════════════════════════════════╝" +echo "" + +# Check the sync script exists +if [ ! -f "$SYNC_SCRIPT" ]; then + echo "❌ $SYNC_SCRIPT not found. Please upload git-sync.sh first." + exit 1 +fi + +chmod +x "$SYNC_SCRIPT" + +CRON_LINE="$CRON_SCHEDULE $SYNC_SCRIPT >> $LOG_FILE 2>&1" + +# Don't add duplicate +if crontab -l 2>/dev/null | grep -qF "$SYNC_SCRIPT"; then + echo "ℹ️ Cron job already exists — no changes made." +else + (crontab -l 2>/dev/null; echo "$CRON_LINE") | crontab - + echo "✅ Cron job added:" + echo " $CRON_LINE" +fi + +echo "" +echo "Current cron jobs:" +crontab -l +echo "" +echo "Logs will be written to: $LOG_FILE" +echo "" +echo "To remove the cron job: crontab -e (then delete the line)" +echo "To run a sync right now: $SYNC_SCRIPT" +echo "" diff --git a/git-setup.sh b/git-setup.sh new file mode 100755 index 0000000..2f04fb4 --- /dev/null +++ b/git-setup.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# ───────────────────────────────────────────── +# git-setup.sh · Run ONCE to initialise the +# local repo and connect it to Gitea. +# ───────────────────────────────────────────── + +set -e # stop on any error + +REMOTE_URL="https://git.cdb-online.co.uk/Computers_Dont_Byte/FinancePlanner" +BRANCH="main" + +echo "" +echo "╔══════════════════════════════════════════╗" +echo "║ Finance Planner — Git Setup ║" +echo "╚══════════════════════════════════════════╝" +echo "" + +# ── Move to the directory this script lives in +cd "$(dirname "$0")" +echo "📁 Working directory: $(pwd)" +echo "" + +# ── Ask for identity (used in commit messages) +read -p "Your name (for git commits): " GIT_NAME +read -p "Your email (for git commits): " GIT_EMAIL +echo "" + +# ── Initialise git if not already done +if [ ! -d ".git" ]; then + git init + git checkout -b "$BRANCH" 2>/dev/null || git branch -m "$BRANCH" + echo "✅ Git repository initialised." +else + echo "ℹ️ Git already initialised — skipping." +fi + +# ── Set local identity +git config user.name "$GIT_NAME" +git config user.email "$GIT_EMAIL" + +# ── Set remote +if git remote get-url origin &>/dev/null; then + git remote set-url origin "$REMOTE_URL" + echo "✅ Remote 'origin' updated." +else + git remote add origin "$REMOTE_URL" + echo "✅ Remote 'origin' added." +fi + +echo "" +echo "Remote → $REMOTE_URL" +echo "" + +# ── Copy in the .gitignore if not present +if [ ! -f ".gitignore" ]; then + cat > .gitignore << 'GITIGNORE' +db.php +*.log +*.cache +/vendor/ +*.env +.DS_Store +Thumbs.db +desktop.ini +.vscode/ +.idea/ +*.swp +*.swo +*~ +GITIGNORE + echo "✅ .gitignore created (db.php is excluded to protect credentials)." +fi + +# ── Initial commit +git add -A +git status --short +echo "" +git commit -m "Initial commit — Finance Planner setup" || echo "ℹ️ Nothing new to commit." + +echo "" +echo "╔══════════════════════════════════════════╗" +echo "║ Setup complete! ║" +echo "╚══════════════════════════════════════════╝" +echo "" +echo "Next steps:" +echo "" +echo " Option A — HTTPS (username + password / token):" +echo " git push -u origin $BRANCH" +echo " (You will be prompted for your Gitea credentials)" +echo "" +echo " Option B — SSH (no password prompts):" +echo " ssh-keygen -t ed25519 -C \"$GIT_EMAIL\"" +echo " cat ~/.ssh/id_ed25519.pub # paste this into Gitea → Settings → SSH Keys" +echo " git remote set-url origin git@git.cdb-online.co.uk:Computers_Dont_Byte/FinancePlanner.git" +echo " git push -u origin $BRANCH" +echo "" +echo " Then run ./git-sync.sh any time you want to push changes." +echo "" diff --git a/git-sync.sh b/git-sync.sh new file mode 100755 index 0000000..b15f5cb --- /dev/null +++ b/git-sync.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# ───────────────────────────────────────────── +# git-sync.sh · Commit & push all changes +# to Gitea. Run this whenever you update files. +# +# Usage: +# ./git-sync.sh (auto message) +# ./git-sync.sh "My message" (custom message) +# ───────────────────────────────────────────── + +set -e + +BRANCH="main" + +cd "$(dirname "$0")" + +# ── Check we're in a git repo +if [ ! -d ".git" ]; then + echo "❌ No git repo found. Run ./git-setup.sh first." + exit 1 +fi + +# ── Check there's anything to sync +if git diff --quiet && git diff --cached --quiet && [ -z "$(git status --porcelain)" ]; then + echo "✅ Nothing to sync — everything is already up to date." + exit 0 +fi + +# ── Commit message +if [ -n "$1" ]; then + MSG="$1" +else + MSG="Sync: $(date '+%Y-%m-%d %H:%M:%S')" +fi + +echo "" +echo "📦 Files changed:" +git status --short +echo "" + +git add -A +git commit -m "$MSG" + +echo "" +echo "⬆️ Pushing to origin/$BRANCH …" +git push origin "$BRANCH" + +echo "" +echo "✅ Synced successfully at $(date '+%H:%M:%S')" +echo "" diff --git a/gitignore b/gitignore new file mode 100644 index 0000000..b3cb6e2 --- /dev/null +++ b/gitignore @@ -0,0 +1,20 @@ +# ── Sensitive config ────────────────────────── +db.php + +# ── PHP / server ────────────────────────────── +*.log +*.cache +/vendor/ +*.env + +# ── OS junk ─────────────────────────────────── +.DS_Store +Thumbs.db +desktop.ini + +# ── Editor files ────────────────────────────── +.vscode/ +.idea/ +*.swp +*.swo +*~ diff --git a/login.php b/login.php new file mode 100644 index 0000000..409c422 --- /dev/null +++ b/login.php @@ -0,0 +1,95 @@ + + + + + + +Login · <?= htmlspecialchars($siteName) ?> + + + + +
+
+ +

+

Sign in to your account

+ + +
+ + +
+
+ + +
+
+ + +
+ +
+ + + + +

Default: admin / admin

+
+
+ + diff --git a/nav.php b/nav.php new file mode 100644 index 0000000..98817a5 --- /dev/null +++ b/nav.php @@ -0,0 +1,85 @@ + + + + + + {$title} · {$site} + + + + HTML; +} + +function renderTopBar(string $title, bool $addBtn = false, string $addId = ''): void { + $user = currentUser(); + $name = htmlspecialchars($user['display_name'] ?? ''); + $btn = $addBtn ? "" : ''; + $initial = $name ? strtoupper(substr(strip_tags($name), 0, 1)) : ''; + $avatar = $initial + ? "{$initial}" + : ''; + echo << + {$avatar} +

{$title}

+ {$btn} + + HTML; +} + +function renderNav(string $active): void { + $pages = [ + 'trends' => ['index.php', 'Trends', trendIcon()], + 'transactions' => ['transactions.php', 'Recurring', txIcon()], + 'oneoff' => ['oneoff.php', 'One-off', oneoffIcon()], + 'debts' => ['debts.php', 'Debts', debtIcon()], + 'balances' => ['balances.php', 'Balances', balIcon()], + 'accounts' => ['accounts.php', 'Accounts', acctIcon()], + 'settings' => ['settings.php', 'Settings', settingsIcon()], + ]; + if (isAdmin()) { + $pages['admin'] = ['admin.php', 'Admin', adminIcon()]; + } + + echo ''; +} + +// ── Icons ───────────────────────────────────── +function trendIcon(): string { + return ''; +} +function txIcon(): string { + return ''; +} +function balIcon(): string { + return ''; +} +function acctIcon(): string { + return ''; +} +function debtIcon(): string { + return ''; +} +function oneoffIcon(): string { + return ''; +} +function settingsIcon(): string { + return ''; +} +function adminIcon(): string { + return ''; +} diff --git a/register.php b/register.php new file mode 100644 index 0000000..db99885 --- /dev/null +++ b/register.php @@ -0,0 +1,136 @@ +prepare("SELECT id FROM users WHERE username = ?"); + $st->execute([$username]); + if ($st->fetch()) { + $error = 'That username is already taken. Please choose another.'; + } else { + $dn = $displayName ?: $username; + $hash = password_hash($password, PASSWORD_DEFAULT); + $db->prepare("INSERT INTO users (username, password_hash, display_name) VALUES (?,?,?)") + ->execute([$username, $hash, $dn]); + $newId = (int)$db->lastInsertId(); + // Give them a default account to get started + $db->prepare("INSERT INTO accounts (user_id, name, currency) VALUES (?,?,?)") + ->execute([$newId, 'Main Account', 'GBP']); + // Auto-login + $_SESSION['user'] = [ + 'id' => $newId, + 'username' => $username, + 'display_name' => $dn, + 'is_admin' => 0, + ]; + header('Location: index.php'); + exit; + } + } catch (Exception $e) { + $error = 'Registration failed. Please try again.'; + } + } + } +} +?> + + + + + +Register · <?= htmlspecialchars($siteName) ?> + + + + +
+
+ +

+

Create your free account

+ + +
+ Registration is currently disabled.
+ Please contact the site administrator. +
+ + + + +
+ + +
+
+ + +
3–30 characters · letters, numbers, underscores
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ + + +
+
+ + diff --git a/settings.php b/settings.php new file mode 100644 index 0000000..a7d41cc --- /dev/null +++ b/settings.php @@ -0,0 +1,155 @@ + +
+ + + +
+
+
+ +
+
+
+
@
+ + Admin + +
+
+
+ + +
+ + +
+ + + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + + + +
+ +

+ +

+ + ❤️ Donate +
+ + + + + + + + + + +
+
+
+
Sign Out
+
Logged in as @
+
+ Logout +
+
+ +
+ + + + + + diff --git a/style_additions.css b/style_additions.css new file mode 100644 index 0000000..d62f398 --- /dev/null +++ b/style_additions.css @@ -0,0 +1,69 @@ +/* ───────────────────────────────────────────── + style_additions.css + APPEND these rules to the bottom of style.css +───────────────────────────────────────────── */ + +/* ── Topbar avatar (initials circle) ───────── */ +.topbar-avatar { + position: absolute; left: 14px; + width: 32px; height: 32px; + border-radius: 50%; + background: rgba(255,255,255,.22); + color: #fff; + font-size: .8rem; font-weight: 700; + display: flex; align-items: center; justify-content: center; + text-decoration: none; + transition: background .15s; + user-select: none; +} +.topbar-avatar:hover { background: rgba(255,255,255,.35); } + +/* ── User / admin badges ───────────────────── */ +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 20px; + font-size: .65rem; font-weight: 700; + vertical-align: middle; + margin-left: 4px; +} +.badge-admin { + background: rgba(58,180,242,.18); + color: var(--accent); + border: 1px solid rgba(58,180,242,.3); +} +.badge-suspended { + background: rgba(248,113,113,.18); + color: var(--red); + border: 1px solid rgba(248,113,113,.3); +} + +/* ── Admin user action buttons ─────────────── */ +.user-actions { display: flex; gap: 6px; flex-wrap: wrap; } +.user-actions .btn { padding: 5px 10px; font-size: .75rem; } + +/* ── Checkbox style in settings/admin ──────── */ +input[type="checkbox"] { + width: 18px; height: 18px; + accent-color: var(--accent); + cursor: pointer; +} + +/* ── Auth pages (login / register) ─────────── */ +.auth-wrap { + display: flex; align-items: center; justify-content: center; + min-height: 100vh; padding: 24px; +} +.auth-card { + background: var(--surface); border: 1px solid var(--border); + border-radius: var(--radius); padding: 36px 30px; + max-width: 380px; width: 100%; +} +.auth-logo { font-size: 2.5rem; text-align: center; margin-bottom: 10px; } +.auth-card h1 { font-size: 1.2rem; text-align: center; margin-bottom: 6px; } +.auth-sub { color: var(--text-dim); font-size: .8rem; text-align: center; margin-bottom: 24px; } +.auth-footer { color: var(--text-muted); font-size: .8rem; text-align: center; margin-top: 16px; } +.auth-footer a { color: var(--accent); text-decoration: none; } +.auth-hint { color: var(--text-muted); font-size: .75rem; text-align: center; margin-top: 10px; } +.auth-hint code { background: var(--surface2); padding: 1px 5px; border-radius: 4px; color: var(--accent); } +.field-hint { font-size: .7rem; color: var(--text-muted); margin-top: 3px; }