05/24 Fix bugs
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(gh pr *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -173,4 +173,5 @@ cython_debug/
|
|||||||
|
|
||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,13 @@ def create_app():
|
|||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
# ── Secret key for session management ─────────────────────────────────────
|
# ── 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) ──────────────────────────────────────────
|
# ── Session timeout (30 minutes) ──────────────────────────────────────────
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
@@ -60,6 +66,10 @@ def create_app():
|
|||||||
app.register_blueprint(ai_summary_bp)
|
app.register_blueprint(ai_summary_bp)
|
||||||
app.register_blueprint(bid_tracker_bp)
|
app.register_blueprint(bid_tracker_bp)
|
||||||
|
|
||||||
|
# ── CSRF protection (Flask-WTF) ────────────────────────────────────────────
|
||||||
|
from flask_wtf.csrf import CSRFProtect
|
||||||
|
CSRFProtect(app)
|
||||||
|
|
||||||
# ── Template context processors ────────────────────────────────────────────
|
# ── Template context processors ────────────────────────────────────────────
|
||||||
from flask import session, redirect, url_for, g
|
from flask import session, redirect, url_for, g
|
||||||
import functools
|
import functools
|
||||||
@@ -102,6 +112,3 @@ def create_app():
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
application = create_app()
|
application = create_app()
|
||||||
application.run(debug=False, host="0.0.0.0", port=5000)
|
application.run(debug=False, host="0.0.0.0", port=5000)
|
||||||
|
|
||||||
# Gunicorn entry point
|
|
||||||
application = create_app()
|
|
||||||
@@ -189,7 +189,8 @@ def initialize_database():
|
|||||||
entity VARCHAR(100),
|
entity VARCHAR(100),
|
||||||
entity_id INT,
|
entity_id INT,
|
||||||
detail TEXT,
|
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
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
""",
|
""",
|
||||||
@@ -347,6 +348,44 @@ def initialize_database():
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
logger.info("Migration: added ip_address column to login_attempts table.")
|
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) ─
|
# ── Seed app_settings from environment variables (first-run bootstrap) ─
|
||||||
# Uses INSERT IGNORE so values already saved via the Admin UI are never
|
# Uses INSERT IGNORE so values already saved via the Admin UI are never
|
||||||
# overwritten — .env only fills in keys that are completely absent.
|
# overwritten — .env only fills in keys that are completely absent.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ models.py — Data-access layer for all entities.
|
|||||||
Ported 1-for-1 from the desktop version; all function signatures preserved.
|
Ported 1-for-1 from the desktop version; all function signatures preserved.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import bcrypt
|
import bcrypt
|
||||||
@@ -99,7 +100,6 @@ def check_login_allowed(username: str) -> tuple:
|
|||||||
return True, 0
|
return True, 0
|
||||||
locked_until = row.get("locked_until")
|
locked_until = row.get("locked_until")
|
||||||
if locked_until:
|
if locked_until:
|
||||||
import datetime
|
|
||||||
now = datetime.datetime.now()
|
now = datetime.datetime.now()
|
||||||
if now < locked_until:
|
if now < locked_until:
|
||||||
remaining = int((locked_until - now).total_seconds())
|
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):
|
def record_failed_attempt(username: str, ip_address: str = None):
|
||||||
conn = None
|
conn = None
|
||||||
try:
|
try:
|
||||||
import datetime
|
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
cur = conn.cursor(dictionary=True)
|
cur = conn.cursor(dictionary=True)
|
||||||
cur.execute("SELECT id, failed_attempts FROM users WHERE username=%s", (username,))
|
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:
|
try:
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
cur = conn.cursor(dictionary=True)
|
cur = conn.cursor(dictionary=True)
|
||||||
where = ""
|
|
||||||
params = []
|
params = []
|
||||||
if search:
|
sql = (
|
||||||
where = "WHERE al.action LIKE %s OR u.username LIKE %s OR al.entity LIKE %s OR al.detail LIKE %s"
|
"SELECT al.id, al.user_id, al.action, al.entity, al.entity_id,"
|
||||||
like = f"%{search}%"
|
" al.detail, al.logged_at AS created_at, u.username"
|
||||||
params.extend([like, like, like, like])
|
" FROM activity_log al"
|
||||||
params.append(limit)
|
" LEFT JOIN users u ON u.id = al.user_id"
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
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()
|
rows = cur.fetchall()
|
||||||
cur.close()
|
cur.close()
|
||||||
return rows
|
return rows
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
Flask==3.0.3
|
Flask==3.0.3
|
||||||
|
Flask-WTF==1.2.1
|
||||||
Flask-Session==0.8.0
|
Flask-Session==0.8.0
|
||||||
mysql-connector-python==8.4.0
|
mysql-connector-python==8.4.0
|
||||||
cryptography==42.0.8
|
cryptography==42.0.8
|
||||||
|
|||||||
+10
-3
@@ -229,6 +229,7 @@ def _call_groq(api_key: str, model: str, text: str, criteria: list) -> dict:
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
n = text.count("=== ") or 1 # count file separators for the prompt header
|
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
|
# Build the two-stage prompt matching the desktop app exactly
|
||||||
prompt = _EXTRACTION_PROMPT.format(
|
prompt = _EXTRACTION_PROMPT.format(
|
||||||
@@ -272,7 +273,7 @@ def _call_groq(api_key: str, model: str, text: str, criteria: list) -> dict:
|
|||||||
if match:
|
if match:
|
||||||
verdict = match.group(1).upper()
|
verdict = match.group(1).upper()
|
||||||
|
|
||||||
return {"verdict": verdict, "summary": content}
|
return {"verdict": verdict, "summary": content, "truncated": truncated}
|
||||||
|
|
||||||
|
|
||||||
# ─── Criteria Management (admin only) ─────────────────────────────────────────
|
# ─── Criteria Management (admin only) ─────────────────────────────────────────
|
||||||
@@ -284,7 +285,10 @@ def create_criterion_view():
|
|||||||
title = request.form.get("title", "").strip()
|
title = request.form.get("title", "").strip()
|
||||||
desc = request.form.get("description", "").strip()
|
desc = request.form.get("description", "").strip()
|
||||||
is_active = request.form.get("is_active", "1") == "1"
|
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:
|
try:
|
||||||
create_criterion(admin["id"], title, desc, is_active, sort_order)
|
create_criterion(admin["id"], title, desc, is_active, sort_order)
|
||||||
flash(f"Criterion '{title}' created.", "success")
|
flash(f"Criterion '{title}' created.", "success")
|
||||||
@@ -300,7 +304,10 @@ def edit_criterion_view(criterion_id):
|
|||||||
title = request.form.get("title", "").strip()
|
title = request.form.get("title", "").strip()
|
||||||
desc = request.form.get("description", "").strip()
|
desc = request.form.get("description", "").strip()
|
||||||
is_active = request.form.get("is_active", "1") == "1"
|
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:
|
try:
|
||||||
update_criterion(admin["id"], criterion_id, title, desc, is_active, sort_order)
|
update_criterion(admin["id"], criterion_id, title, desc, is_active, sort_order)
|
||||||
flash(f"Criterion '{title}' updated.", "success")
|
flash(f"Criterion '{title}' updated.", "success")
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ def login():
|
|||||||
else:
|
else:
|
||||||
user = authenticate(username, password)
|
user = authenticate(username, password)
|
||||||
if user:
|
if user:
|
||||||
|
session.clear() # prevent session fixation
|
||||||
session.permanent = True
|
session.permanent = True
|
||||||
# Store a safe subset — never store the password hash in session
|
# Store a safe subset — never store the password hash in session
|
||||||
session["user"] = {
|
session["user"] = {
|
||||||
@@ -86,3 +87,11 @@ def change_password_view():
|
|||||||
error = msg
|
error = msg
|
||||||
|
|
||||||
return render_template("change_password.html", success=success, error=error)
|
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
@@ -8,6 +8,7 @@ from flask import (Blueprint, render_template, request, redirect, url_for,
|
|||||||
from models import (
|
from models import (
|
||||||
get_all_bids, get_bid, create_bid, update_bid, delete_bid,
|
get_all_bids, get_bid, create_bid, update_bid, delete_bid,
|
||||||
get_bid_updates, add_bid_update, delete_bid_update, BID_STATUSES,
|
get_bid_updates, add_bid_update, delete_bid_update, BID_STATUSES,
|
||||||
|
log_action,
|
||||||
)
|
)
|
||||||
from utils.decorators import login_required
|
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("/")
|
@bid_tracker_bp.route("/")
|
||||||
@login_required
|
@login_required
|
||||||
def bids_list():
|
def bids_list():
|
||||||
@@ -66,7 +72,9 @@ def create():
|
|||||||
return redirect(url_for("bid_tracker.bids_list"))
|
return redirect(url_for("bid_tracker.bids_list"))
|
||||||
|
|
||||||
try:
|
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")
|
flash(f"Bid '{title}' added.", "success")
|
||||||
logger.info(f"Bid '{title}' created by user_id={user['id']}.")
|
logger.info(f"Bid '{title}' created by user_id={user['id']}.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -90,6 +98,8 @@ def edit(bid_id):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
update_bid(user["id"], bid_id, title, url, source, sol_no, status, due_date, notes)
|
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")
|
flash(f"Bid '{title}' updated.", "success")
|
||||||
logger.info(f"Bid id={bid_id} updated by user_id={user['id']}.")
|
logger.info(f"Bid id={bid_id} updated by user_id={user['id']}.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -105,6 +115,8 @@ def delete(bid_id):
|
|||||||
user = session["user"]
|
user = session["user"]
|
||||||
try:
|
try:
|
||||||
delete_bid(user["id"], bid_id)
|
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")
|
flash("Bid deleted.", "success")
|
||||||
logger.info(f"Bid id={bid_id} deleted by user_id={user['id']}.")
|
logger.info(f"Bid id={bid_id} deleted by user_id={user['id']}.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -122,7 +134,9 @@ def add_update(bid_id):
|
|||||||
flash("Update content cannot be empty.", "warning")
|
flash("Update content cannot be empty.", "warning")
|
||||||
else:
|
else:
|
||||||
try:
|
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")
|
flash("Update posted.", "success")
|
||||||
logger.info(f"Bid update posted on bid_id={bid_id} by user_id={user['id']}.")
|
logger.info(f"Bid update posted on bid_id={bid_id} by user_id={user['id']}.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -137,6 +151,8 @@ def delete_update(update_id):
|
|||||||
user = session["user"]
|
user = session["user"]
|
||||||
try:
|
try:
|
||||||
delete_bid_update(user["id"], update_id)
|
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")
|
flash("Update deleted.", "success")
|
||||||
logger.info(f"Bid update id={update_id} deleted by user_id={user['id']}.")
|
logger.info(f"Bid update id={update_id} deleted by user_id={user['id']}.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -162,19 +178,9 @@ def bid_json(bid_id):
|
|||||||
is_owner = bid.get("added_by") == user["id"]
|
is_owner = bid.get("added_by") == user["id"]
|
||||||
can_edit = user["role"] == "admin" or is_owner
|
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({
|
return jsonify({
|
||||||
"bid": ser(bid),
|
"bid": _ser(bid),
|
||||||
"updates": [ser(u) for u in updates],
|
"updates": [_ser(u) for u in updates],
|
||||||
"can_edit": can_edit,
|
"can_edit": can_edit,
|
||||||
"user_id": user["id"],
|
"user_id": user["id"],
|
||||||
"is_admin": user["role"] == "admin",
|
"is_admin": user["role"] == "admin",
|
||||||
@@ -191,16 +197,7 @@ def list_json():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
def ser(row):
|
return jsonify([_ser(b) for b in bids])
|
||||||
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])
|
|
||||||
|
|
||||||
|
|
||||||
@bid_tracker_bp.route("/<int:bid_id>/updates/json", methods=["POST"])
|
@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
|
return jsonify({"error": "Update content cannot be empty."}), 400
|
||||||
try:
|
try:
|
||||||
update_id = add_bid_update(user["id"], bid_id, content)
|
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']}.")
|
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})
|
return jsonify({"ok": True, "update_id": update_id})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -227,6 +226,8 @@ def delete_update_json(update_id):
|
|||||||
user = session["user"]
|
user = session["user"]
|
||||||
try:
|
try:
|
||||||
delete_bid_update(user["id"], update_id)
|
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']}.")
|
logger.info(f"Bid update id={update_id} deleted by user_id={user['id']}.")
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+31
-12
@@ -173,7 +173,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
clearTimeout(expireTimer);
|
clearTimeout(expireTimer);
|
||||||
warningTimer = setTimeout(() => {
|
warningTimer = setTimeout(() => {
|
||||||
if (confirm('Your session will expire in 5 minutes. Click OK to stay logged in.')) {
|
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();
|
resetTimers();
|
||||||
}
|
}
|
||||||
}, SESSION_MS - WARN_BEFORE_MS);
|
}, SESSION_MS - WARN_BEFORE_MS);
|
||||||
@@ -191,19 +191,38 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
resetTimers();
|
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) ─────────────── */
|
/* ── CSRF helper (reads meta tag set by Flask) ─────────────── */
|
||||||
function getCsrfToken() {
|
function getCsrfToken() {
|
||||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||||
return meta ? meta.content : '';
|
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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -357,7 +357,7 @@ async function runAnalysis() {
|
|||||||
if (model) fd.append('model', model);
|
if (model) fd.append('model', model);
|
||||||
|
|
||||||
try {
|
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();
|
var data = await resp.json();
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
document.getElementById('ai-output').innerHTML =
|
document.getElementById('ai-output').innerHTML =
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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>
|
<title>{% block title %}Website Checker{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -299,6 +299,7 @@ function renderDetail(data) {
|
|||||||
? '<button class="btn btn-secondary btn-sm" onclick="openEditModal(' + b.id + ')">✎ Edit</button>'
|
? '<button class="btn btn-secondary btn-sm" onclick="openEditModal(' + b.id + ')">✎ Edit</button>'
|
||||||
+ '<form method="post" action="/bids/' + b.id + '/delete" style="display:inline"'
|
+ '<form method="post" action="/bids/' + b.id + '/delete" style="display:inline"'
|
||||||
+ ' onsubmit="return confirm(\'Delete \\\'' + esc(b.title) + '\\\'?\')">'
|
+ ' 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>'
|
+ '<button class="btn btn-danger btn-sm" type="submit">✕ Delete</button></form>'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
@@ -352,7 +353,7 @@ function postUpdate(bidId) {
|
|||||||
var fd = new FormData();
|
var fd = new FormData();
|
||||||
fd.append('content', content);
|
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(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
if (d.error) { alert(d.error); return; }
|
if (d.error) { alert(d.error); return; }
|
||||||
@@ -371,7 +372,7 @@ function postUpdate(bidId) {
|
|||||||
function deleteUpdate(updateId, bidId) {
|
function deleteUpdate(updateId, bidId) {
|
||||||
if (!confirm('Delete this update?')) return;
|
if (!confirm('Delete this update?')) return;
|
||||||
var fd = new FormData();
|
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(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
if (d.error) { alert(d.error); return; }
|
if (d.error) { alert(d.error); return; }
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ document.getElementById('btn-bulk-check').addEventListener('click', function() {
|
|||||||
Promise.all(selected.map(function(id) {
|
Promise.all(selected.map(function(id) {
|
||||||
return fetch('/dashboard/check/' + id, {
|
return fetch('/dashboard/check/' + id, {
|
||||||
method: 'POST', credentials: 'same-origin',
|
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='
|
body: 'user_note='
|
||||||
});
|
});
|
||||||
})).then(function() { location.reload(); });
|
})).then(function() { location.reload(); });
|
||||||
|
|||||||
Reference in New Issue
Block a user