diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..b5aada3 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(gh pr *)" + ] + } +} diff --git a/.gitignore b/.gitignore index 36b13f1..d70cf7f 100644 --- a/.gitignore +++ b/.gitignore @@ -173,4 +173,5 @@ cython_debug/ # PyPI configuration file .pypirc +.claude/ diff --git a/app.py b/app.py index 5fa2bd0..1701a25 100644 --- a/app.py +++ b/app.py @@ -21,7 +21,13 @@ def create_app(): app = Flask(__name__) # ── Secret key for session management ───────────────────────────────────── - app.secret_key = os.environ.get("SECRET_KEY", os.urandom(32)) + secret_key = os.environ.get("SECRET_KEY") + if not secret_key: + logger.warning( + "SECRET_KEY not set in environment. Sessions will break across " + "Gunicorn workers. Set SECRET_KEY in .env before deploying." + ) + app.secret_key = secret_key or os.urandom(32) # ── Session timeout (30 minutes) ────────────────────────────────────────── from datetime import timedelta @@ -60,6 +66,10 @@ def create_app(): app.register_blueprint(ai_summary_bp) app.register_blueprint(bid_tracker_bp) + # ── CSRF protection (Flask-WTF) ──────────────────────────────────────────── + from flask_wtf.csrf import CSRFProtect + CSRFProtect(app) + # ── Template context processors ──────────────────────────────────────────── from flask import session, redirect, url_for, g import functools @@ -101,7 +111,4 @@ def create_app(): # ─── Dev entry point ────────────────────────────────────────────────────────── if __name__ == "__main__": application = create_app() - application.run(debug=False, host="0.0.0.0", port=5000) - -# Gunicorn entry point -application = create_app() \ No newline at end of file + application.run(debug=False, host="0.0.0.0", port=5000) \ No newline at end of file diff --git a/config.py b/config.py index 2a60ebe..1d174c3 100644 --- a/config.py +++ b/config.py @@ -189,7 +189,8 @@ def initialize_database(): entity VARCHAR(100), entity_id INT, detail TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + logged_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_activity_log_time (logged_at), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, @@ -347,6 +348,44 @@ def initialize_database(): conn.commit() logger.info("Migration: added ip_address column to login_attempts table.") + # ── activity_log: rename created_at → logged_at ──────────────── + # The desktop app uses logged_at; earlier web-only installs may have + # created the table with created_at. Rename it if that's the case. + cursor.execute( + """ + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'activity_log' + AND COLUMN_NAME = 'created_at' + """ + ) + (has_created_at,) = cursor.fetchone() + if has_created_at: + cursor.execute( + "ALTER TABLE activity_log " + "CHANGE COLUMN created_at logged_at DATETIME DEFAULT CURRENT_TIMESTAMP" + ) + conn.commit() + logger.info("Migration: renamed activity_log.created_at to logged_at.") + + # ── activity_log: add index on logged_at if missing ──────────── + cursor.execute( + """ + SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'activity_log' + AND INDEX_NAME = 'idx_activity_log_time' + """ + ) + (has_idx,) = cursor.fetchone() + if not has_idx: + cursor.execute( + "ALTER TABLE activity_log " + "ADD INDEX idx_activity_log_time (logged_at)" + ) + conn.commit() + logger.info("Migration: added idx_activity_log_time index to activity_log.") + # ── Seed app_settings from environment variables (first-run bootstrap) ─ # Uses INSERT IGNORE so values already saved via the Admin UI are never # overwritten — .env only fills in keys that are completely absent. diff --git a/models.py b/models.py index 1663c6c..a923780 100644 --- a/models.py +++ b/models.py @@ -3,6 +3,7 @@ models.py — Data-access layer for all entities. Ported 1-for-1 from the desktop version; all function signatures preserved. """ +import datetime import hashlib import logging import bcrypt @@ -99,7 +100,6 @@ def check_login_allowed(username: str) -> tuple: return True, 0 locked_until = row.get("locked_until") if locked_until: - import datetime now = datetime.datetime.now() if now < locked_until: remaining = int((locked_until - now).total_seconds()) @@ -113,7 +113,6 @@ def check_login_allowed(username: str) -> tuple: def record_failed_attempt(username: str, ip_address: str = None): conn = None try: - import datetime conn = get_connection() cur = conn.cursor(dictionary=True) cur.execute("SELECT id, failed_attempts FROM users WHERE username=%s", (username,)) @@ -678,25 +677,23 @@ def get_activity_log(limit=200, search: str = ""): try: conn = get_connection() cur = conn.cursor(dictionary=True) - where = "" params = [] - if search: - where = "WHERE al.action LIKE %s OR u.username LIKE %s OR al.entity LIKE %s OR al.detail LIKE %s" - like = f"%{search}%" - params.extend([like, like, like, like]) - params.append(limit) - cur.execute( - f""" - SELECT al.id, al.user_id, al.action, al.entity, al.entity_id, - al.detail, al.logged_at AS created_at, u.username - FROM activity_log al - LEFT JOIN users u ON u.id = al.user_id - {where} - ORDER BY al.logged_at DESC - LIMIT %s - """, - params, + sql = ( + "SELECT al.id, al.user_id, al.action, al.entity, al.entity_id," + " al.detail, al.logged_at AS created_at, u.username" + " FROM activity_log al" + " LEFT JOIN users u ON u.id = al.user_id" ) + if search: + sql += ( + " WHERE al.action LIKE %s OR u.username LIKE %s" + " OR al.entity LIKE %s OR al.detail LIKE %s" + ) + like = f"%{search}%" + params.extend([like, like, like, like]) + sql += " ORDER BY al.logged_at DESC LIMIT %s" + params.append(limit) + cur.execute(sql, params) rows = cur.fetchall() cur.close() return rows diff --git a/requirements.txt b/requirements.txt index 945dcc2..5d5655e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ Flask==3.0.3 +Flask-WTF==1.2.1 Flask-Session==0.8.0 mysql-connector-python==8.4.0 cryptography==42.0.8 diff --git a/routes/ai_summary.py b/routes/ai_summary.py index 018cddd..20f3814 100644 --- a/routes/ai_summary.py +++ b/routes/ai_summary.py @@ -229,6 +229,7 @@ def _call_groq(api_key: str, model: str, text: str, criteria: list) -> dict: import re n = text.count("=== ") or 1 # count file separators for the prompt header + truncated = len(text) > 14000 # Build the two-stage prompt matching the desktop app exactly prompt = _EXTRACTION_PROMPT.format( @@ -272,7 +273,7 @@ def _call_groq(api_key: str, model: str, text: str, criteria: list) -> dict: if match: verdict = match.group(1).upper() - return {"verdict": verdict, "summary": content} + return {"verdict": verdict, "summary": content, "truncated": truncated} # ─── Criteria Management (admin only) ───────────────────────────────────────── @@ -284,7 +285,10 @@ def create_criterion_view(): title = request.form.get("title", "").strip() desc = request.form.get("description", "").strip() is_active = request.form.get("is_active", "1") == "1" - sort_order = int(request.form.get("sort_order", 0)) + try: + sort_order = int(request.form.get("sort_order", 0) or 0) + except (ValueError, TypeError): + sort_order = 0 try: create_criterion(admin["id"], title, desc, is_active, sort_order) flash(f"Criterion '{title}' created.", "success") @@ -300,7 +304,10 @@ def edit_criterion_view(criterion_id): title = request.form.get("title", "").strip() desc = request.form.get("description", "").strip() is_active = request.form.get("is_active", "1") == "1" - sort_order = int(request.form.get("sort_order", 0)) + try: + sort_order = int(request.form.get("sort_order", 0) or 0) + except (ValueError, TypeError): + sort_order = 0 try: update_criterion(admin["id"], criterion_id, title, desc, is_active, sort_order) flash(f"Criterion '{title}' updated.", "success") diff --git a/routes/auth.py b/routes/auth.py index 6114041..ff9ece7 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -31,6 +31,7 @@ def login(): else: user = authenticate(username, password) if user: + session.clear() # prevent session fixation session.permanent = True # Store a safe subset — never store the password hash in session session["user"] = { @@ -86,3 +87,11 @@ def change_password_view(): error = msg return render_template("change_password.html", success=success, error=error) + + +@auth_bp.route("/ping") +@login_required +def ping(): + """Keep-alive endpoint for the session timeout warning in app.js.""" + session.modified = True + return "", 204 diff --git a/routes/bid_tracker.py b/routes/bid_tracker.py index ce4c416..f58dc89 100644 --- a/routes/bid_tracker.py +++ b/routes/bid_tracker.py @@ -8,6 +8,7 @@ from flask import (Blueprint, render_template, request, redirect, url_for, from models import ( get_all_bids, get_bid, create_bid, update_bid, delete_bid, get_bid_updates, add_bid_update, delete_bid_update, BID_STATUSES, + log_action, ) from utils.decorators import login_required @@ -23,6 +24,11 @@ STATUS_LABELS = { } +def _ser(row): + """Make a DB dict JSON-serialisable (dates/times → ISO strings).""" + return {k: v.isoformat() if hasattr(v, "isoformat") else v for k, v in row.items()} + + @bid_tracker_bp.route("/") @login_required def bids_list(): @@ -66,7 +72,9 @@ def create(): return redirect(url_for("bid_tracker.bids_list")) try: - create_bid(user["id"], title, url, source, sol_no, status, due_date, notes) + bid_id = create_bid(user["id"], title, url, source, sol_no, status, due_date, notes) + log_action(user["id"], "CREATE_BID", "bid_tracker", bid_id, + f"Created bid '{title}' status='{status}'.") flash(f"Bid '{title}' added.", "success") logger.info(f"Bid '{title}' created by user_id={user['id']}.") except Exception as e: @@ -90,6 +98,8 @@ def edit(bid_id): try: update_bid(user["id"], bid_id, title, url, source, sol_no, status, due_date, notes) + log_action(user["id"], "UPDATE_BID", "bid_tracker", bid_id, + f"Updated bid id={bid_id} status='{status}'.") flash(f"Bid '{title}' updated.", "success") logger.info(f"Bid id={bid_id} updated by user_id={user['id']}.") except Exception as e: @@ -105,6 +115,8 @@ def delete(bid_id): user = session["user"] try: delete_bid(user["id"], bid_id) + log_action(user["id"], "DELETE_BID", "bid_tracker", bid_id, + f"Deleted bid id={bid_id}.") flash("Bid deleted.", "success") logger.info(f"Bid id={bid_id} deleted by user_id={user['id']}.") except Exception as e: @@ -122,7 +134,9 @@ def add_update(bid_id): flash("Update content cannot be empty.", "warning") else: try: - add_bid_update(user["id"], bid_id, content) + upd_id = add_bid_update(user["id"], bid_id, content) + log_action(user["id"], "ADD_BID_UPDATE", "bid_updates", upd_id, + f"Posted update on bid_id={bid_id}.") flash("Update posted.", "success") logger.info(f"Bid update posted on bid_id={bid_id} by user_id={user['id']}.") except Exception as e: @@ -137,6 +151,8 @@ def delete_update(update_id): user = session["user"] try: delete_bid_update(user["id"], update_id) + log_action(user["id"], "DELETE_BID_UPDATE", "bid_updates", update_id, + f"Deleted bid update id={update_id}.") flash("Update deleted.", "success") logger.info(f"Bid update id={update_id} deleted by user_id={user['id']}.") except Exception as e: @@ -162,19 +178,9 @@ def bid_json(bid_id): is_owner = bid.get("added_by") == user["id"] can_edit = user["role"] == "admin" or is_owner - def ser(row): - """Make a dict JSON-serialisable (dates → str).""" - out = {} - for k, v in row.items(): - if hasattr(v, "isoformat"): - out[k] = v.isoformat() - else: - out[k] = v - return out - return jsonify({ - "bid": ser(bid), - "updates": [ser(u) for u in updates], + "bid": _ser(bid), + "updates": [_ser(u) for u in updates], "can_edit": can_edit, "user_id": user["id"], "is_admin": user["role"] == "admin", @@ -191,16 +197,7 @@ def list_json(): except Exception as e: return jsonify({"error": str(e)}), 500 - def ser(row): - out = {} - for k, v in row.items(): - if hasattr(v, "isoformat"): - out[k] = v.isoformat() - else: - out[k] = v - return out - - return jsonify([ser(b) for b in bids]) + return jsonify([_ser(b) for b in bids]) @bid_tracker_bp.route("//updates/json", methods=["POST"]) @@ -213,6 +210,8 @@ def add_update_json(bid_id): return jsonify({"error": "Update content cannot be empty."}), 400 try: update_id = add_bid_update(user["id"], bid_id, content) + log_action(user["id"], "ADD_BID_UPDATE", "bid_updates", update_id, + f"Posted update on bid_id={bid_id}.") logger.info(f"Bid update id={update_id} posted on bid_id={bid_id} by user_id={user['id']}.") return jsonify({"ok": True, "update_id": update_id}) except Exception as e: @@ -227,6 +226,8 @@ def delete_update_json(update_id): user = session["user"] try: delete_bid_update(user["id"], update_id) + log_action(user["id"], "DELETE_BID_UPDATE", "bid_updates", update_id, + f"Deleted bid update id={update_id}.") logger.info(f"Bid update id={update_id} deleted by user_id={user['id']}.") return jsonify({"ok": True}) except Exception as e: diff --git a/static/js/app.js b/static/js/app.js index 5c3138a..ffff318 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -173,7 +173,7 @@ document.addEventListener('DOMContentLoaded', () => { clearTimeout(expireTimer); warningTimer = setTimeout(() => { if (confirm('Your session will expire in 5 minutes. Click OK to stay logged in.')) { - fetch('/auth/ping', { credentials: 'same-origin' }).catch(() => {}); + fetch('/ping', { credentials: 'same-origin' }).catch(() => {}); resetTimers(); } }, SESSION_MS - WARN_BEFORE_MS); @@ -191,19 +191,38 @@ document.addEventListener('DOMContentLoaded', () => { resetTimers(); })(); -/* ── Generic fetch-based form submit (JSON response) ───────── */ -async function submitJson(url, data, method = 'POST') { - const res = await fetch(url, { - method, - headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, - body: JSON.stringify(data), - credentials: 'same-origin', - }); - return res.json(); -} - /* ── CSRF helper (reads meta tag set by Flask) ─────────────── */ function getCsrfToken() { const meta = document.querySelector('meta[name="csrf-token"]'); return meta ? meta.content : ''; } + +/* ── Auto-inject CSRF token into every static POST form ─────── */ +document.addEventListener('DOMContentLoaded', () => { + const token = getCsrfToken(); + if (!token) return; + document.querySelectorAll('form').forEach(form => { + if ((form.getAttribute('method') || '').toLowerCase() !== 'post') return; + if (form.querySelector('input[name="csrf_token"]')) return; + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'csrf_token'; + input.value = token; + form.appendChild(input); + }); +}); + +/* ── Generic fetch-based form submit (JSON response) ───────── */ +async function submitJson(url, data, method = 'POST') { + const res = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRFToken': getCsrfToken(), + }, + body: JSON.stringify(data), + credentials: 'same-origin', + }); + return res.json(); +} diff --git a/templates/ai_summary.html b/templates/ai_summary.html index 985e501..4e617a2 100644 --- a/templates/ai_summary.html +++ b/templates/ai_summary.html @@ -357,7 +357,7 @@ async function runAnalysis() { if (model) fd.append('model', model); try { - var resp = await fetch('/ai-summary/analyze', { method: 'POST', body: fd }); + var resp = await fetch('/ai-summary/analyze', { method: 'POST', body: fd, headers: {'X-CSRFToken': getCsrfToken()} }); var data = await resp.json(); if (data.error) { document.getElementById('ai-output').innerHTML = diff --git a/templates/base.html b/templates/base.html index 9ce21b5..46225f8 100644 --- a/templates/base.html +++ b/templates/base.html @@ -3,6 +3,7 @@ + {% block title %}Website Checker{% endblock %} diff --git a/templates/bid_tracker.html b/templates/bid_tracker.html index dc3aef7..9f9a702 100644 --- a/templates/bid_tracker.html +++ b/templates/bid_tracker.html @@ -299,6 +299,7 @@ function renderDetail(data) { ? '' + '
' + + '' + '
' : ''; @@ -352,7 +353,7 @@ function postUpdate(bidId) { var fd = new FormData(); fd.append('content', content); - fetch('/bids/' + bidId + '/updates/json', { method: 'POST', body: fd, credentials: 'same-origin' }) + fetch('/bids/' + bidId + '/updates/json', { method: 'POST', body: fd, credentials: 'same-origin', headers: {'X-CSRFToken': getCsrfToken()} }) .then(function(r) { return r.json(); }) .then(function(d) { if (d.error) { alert(d.error); return; } @@ -371,7 +372,7 @@ function postUpdate(bidId) { function deleteUpdate(updateId, bidId) { if (!confirm('Delete this update?')) return; var fd = new FormData(); - fetch('/bids/updates/' + updateId + '/delete/json', { method: 'POST', body: fd, credentials: 'same-origin' }) + fetch('/bids/updates/' + updateId + '/delete/json', { method: 'POST', body: fd, credentials: 'same-origin', headers: {'X-CSRFToken': getCsrfToken()} }) .then(function(r) { return r.json(); }) .then(function(d) { if (d.error) { alert(d.error); return; } diff --git a/templates/user/dashboard.html b/templates/user/dashboard.html index 8e0a768..dcf2e03 100644 --- a/templates/user/dashboard.html +++ b/templates/user/dashboard.html @@ -292,7 +292,7 @@ document.getElementById('btn-bulk-check').addEventListener('click', function() { Promise.all(selected.map(function(id) { return fetch('/dashboard/check/' + id, { method: 'POST', credentials: 'same-origin', - headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + headers: {'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': getCsrfToken()}, body: 'user_note=' }); })).then(function() { location.reload(); });