05/24 Fix bugs

This commit is contained in:
2026-05-24 17:27:45 -04:00
parent cd63d5a020
commit 136b0e5635
14 changed files with 158 additions and 68 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(gh pr *)"
]
}
}
+1
View File
@@ -173,4 +173,5 @@ cython_debug/
# PyPI configuration file
.pypirc
.claude/
+11 -4
View File
@@ -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
@@ -102,6 +112,3 @@ def create_app():
if __name__ == "__main__":
application = create_app()
application.run(debug=False, host="0.0.0.0", port=5000)
# Gunicorn entry point
application = create_app()
+40 -1
View File
@@ -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.
+13 -16
View File
@@ -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 = []
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:
where = "WHERE al.action LIKE %s OR u.username LIKE %s OR al.entity LIKE %s OR al.detail LIKE %s"
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(
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,
)
cur.execute(sql, params)
rows = cur.fetchall()
cur.close()
return rows
+1
View File
@@ -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
+10 -3
View File
@@ -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")
+9
View File
@@ -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
+25 -24
View File
@@ -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("/<int:bid_id>/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:
+31 -12
View File
@@ -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();
}
+1 -1
View File
@@ -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 =
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{% block title %}Website Checker{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
+3 -2
View File
@@ -299,6 +299,7 @@ function renderDetail(data) {
? '<button class="btn btn-secondary btn-sm" onclick="openEditModal(' + b.id + ')">✎ Edit</button>'
+ '<form method="post" action="/bids/' + b.id + '/delete" style="display:inline"'
+ ' onsubmit="return confirm(\'Delete \\\'' + esc(b.title) + '\\\'?\')">'
+ '<input type="hidden" name="csrf_token" value="' + getCsrfToken() + '">'
+ '<button class="btn btn-danger btn-sm" type="submit">✕ Delete</button></form>'
: '';
@@ -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; }
+1 -1
View File
@@ -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(); });