changes
This commit is contained in:
parent
9713ad5a55
commit
6584c2c8c5
@ -1 +1 @@
|
|||||||
7550
|
3856
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
from flask import Blueprint, request, jsonify
|
# dashboard.py
|
||||||
from admin_login import jwt_required # import decorator
|
import base64
|
||||||
from admin_login import get_db # reuse DB helper from admin_signin
|
|
||||||
import re
|
import re
|
||||||
|
from functools import wraps
|
||||||
|
from flask import Blueprint, request, jsonify, current_app
|
||||||
|
from werkzeug.security import check_password_hash
|
||||||
|
from admin_login import get_db # <-- corrected import (was admin_login)
|
||||||
|
|
||||||
dashboard_bp = Blueprint("dashboard", __name__)
|
dashboard_bp = Blueprint("dashboard", __name__)
|
||||||
|
|
||||||
@ -13,7 +16,6 @@ def validate_product_name(name):
|
|||||||
return False, "product_name is required"
|
return False, "product_name is required"
|
||||||
if len(name) > 255:
|
if len(name) > 255:
|
||||||
return False, "product_name too long"
|
return False, "product_name too long"
|
||||||
# allow letters, numbers, space, dash and apostrophe
|
|
||||||
if not re.match(r"^[A-Za-z0-9\s\-']+$", name):
|
if not re.match(r"^[A-Za-z0-9\s\-']+$", name):
|
||||||
return False, "product_name contains invalid characters"
|
return False, "product_name contains invalid characters"
|
||||||
return True, None
|
return True, None
|
||||||
@ -45,20 +47,78 @@ def validate_order_id(order_id):
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return False, "order_id must be a valid integer"
|
return False, "order_id must be a valid integer"
|
||||||
|
|
||||||
def validate_status(status):
|
# -------------------------
|
||||||
valid_statuses = ['pending', 'processing', 'completed', 'cancelled']
|
# Basic auth helper & decorator
|
||||||
if status and status not in valid_statuses:
|
# -------------------------
|
||||||
return False, f"status must be one of: {', '.join(valid_statuses)}"
|
def _auth_from_basic_header(auth_header):
|
||||||
return True, None
|
if not auth_header:
|
||||||
|
return None, None
|
||||||
|
parts = auth_header.split(None, 1)
|
||||||
|
if len(parts) != 2:
|
||||||
|
return None, None
|
||||||
|
scheme, creds = parts
|
||||||
|
if scheme.lower() != "basic":
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(creds).decode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
return None, None
|
||||||
|
if ":" not in decoded:
|
||||||
|
return None, None
|
||||||
|
username, password = decoded.split(":", 1)
|
||||||
|
return username, password
|
||||||
|
|
||||||
|
def basic_auth_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
auth_header = request.headers.get("Authorization", "")
|
||||||
|
username, password = _auth_from_basic_header(auth_header)
|
||||||
|
|
||||||
|
# fallback: JSON body credentials
|
||||||
|
if not username:
|
||||||
|
try:
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
username = (body.get("username") or "").strip()
|
||||||
|
password = (body.get("password") or "")
|
||||||
|
except Exception:
|
||||||
|
username = None
|
||||||
|
password = None
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
return jsonify({"error": "authentication required (Basic auth header or JSON username/password)"}), 401
|
||||||
|
|
||||||
|
cnx = None
|
||||||
|
cur = None
|
||||||
|
try:
|
||||||
|
cnx = get_db()
|
||||||
|
cur = cnx.cursor(dictionary=True)
|
||||||
|
cur.execute("SELECT id, username, password FROM admins_cred WHERE username = %s", (username,))
|
||||||
|
admin = cur.fetchone()
|
||||||
|
if not admin or not check_password_hash(admin["password"], password):
|
||||||
|
return jsonify({"error": "invalid credentials"}), 401
|
||||||
|
|
||||||
|
request.admin_id = int(admin["id"])
|
||||||
|
request.admin_username = admin["username"]
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
except Exception:
|
||||||
|
current_app.logger.exception("auth verify error")
|
||||||
|
return jsonify({"error": "internal server error"}), 500
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if cur: cur.close()
|
||||||
|
except: pass
|
||||||
|
try:
|
||||||
|
if cnx: cnx.close()
|
||||||
|
except: pass
|
||||||
|
return wrapper
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# Create order item
|
# Create order item
|
||||||
# -------------------------
|
# -------------------------
|
||||||
@dashboard_bp.route("/order-item", methods=["POST"])
|
@dashboard_bp.route("/order-item", methods=["POST"])
|
||||||
@jwt_required
|
@basic_auth_required
|
||||||
def create_order_item():
|
def create_order_item():
|
||||||
admin_id = request.admin_id # set by decorator
|
admin_id = getattr(request, "admin_id", None)
|
||||||
|
|
||||||
if not request.is_json:
|
if not request.is_json:
|
||||||
return jsonify({"error": "expected JSON body"}), 400
|
return jsonify({"error": "expected JSON body"}), 400
|
||||||
|
|
||||||
@ -68,158 +128,138 @@ def create_order_item():
|
|||||||
quantity = body.get("quantity", 1)
|
quantity = body.get("quantity", 1)
|
||||||
price = body.get("price")
|
price = body.get("price")
|
||||||
|
|
||||||
# Validations
|
|
||||||
ok, err = validate_order_id(order_id)
|
ok, err = validate_order_id(order_id)
|
||||||
if not ok:
|
if not ok:
|
||||||
return jsonify({"error": err}), 400
|
return jsonify({"error": err}), 400
|
||||||
|
|
||||||
ok, err = validate_product_name(product_name)
|
ok, err = validate_product_name(product_name)
|
||||||
if not ok:
|
if not ok:
|
||||||
return jsonify({"error": err}), 400
|
return jsonify({"error": err}), 400
|
||||||
|
|
||||||
ok, err = validate_quantity(quantity)
|
ok, err = validate_quantity(quantity)
|
||||||
if not ok:
|
if not ok:
|
||||||
return jsonify({"error": err}), 400
|
return jsonify({"error": err}), 400
|
||||||
|
|
||||||
ok, err = validate_price(price)
|
ok, err = validate_price(price)
|
||||||
if not ok:
|
if not ok:
|
||||||
return jsonify({"error": err}), 400
|
return jsonify({"error": err}), 400
|
||||||
|
|
||||||
|
order_id_int = int(order_id)
|
||||||
|
qty_int = int(quantity)
|
||||||
|
price_f = float(price)
|
||||||
|
|
||||||
|
|
||||||
cnx = get_db()
|
cnx = get_db()
|
||||||
cur = cnx.cursor(dictionary=True)
|
cur = cnx.cursor(dictionary=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 🔍 1. CHECK IF ITEM ALREADY EXISTS (same product in same order by same admin)
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT id FROM order_items WHERE product_name = %s AND order_id = %s AND admin_id = %s",
|
"SELECT id FROM order_items WHERE product_name = %s AND order_id = %s AND admin_id = %s",
|
||||||
(product_name, order_id, admin_id)
|
(product_name, order_id_int, admin_id)
|
||||||
)
|
)
|
||||||
existing = cur.fetchone()
|
existing = cur.fetchone()
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
return jsonify({"error": "order item already exists"}), 400
|
return jsonify({"error": "order item already exists"}), 400
|
||||||
|
|
||||||
# 🔥 2. INSERT NEW ORDER ITEM
|
|
||||||
cur2 = cnx.cursor()
|
cur2 = cnx.cursor()
|
||||||
cur2.execute(
|
try:
|
||||||
"INSERT INTO order_items (admin_id, order_id, product_name, quantity, price) VALUES (%s, %s, %s, %s, %s)",
|
cur2.execute(
|
||||||
(admin_id, order_id, product_name, quantity, price)
|
"INSERT INTO order_items (admin_id, order_id, product_name, quantity, price, status, created_at) "
|
||||||
)
|
"VALUES (%s, %s, %s, %s, %s, %s, NOW())",
|
||||||
cnx.commit()
|
(admin_id, order_id_int, product_name, qty_int, price_f, "pending")
|
||||||
new_id = cur2.lastrowid
|
)
|
||||||
|
cnx.commit()
|
||||||
|
new_id = cur2.lastrowid
|
||||||
|
finally:
|
||||||
|
try: cur2.close()
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"message": "order item created",
|
"message": "order item created",
|
||||||
"id": new_id,
|
"id": new_id,
|
||||||
"admin_id": admin_id,
|
"admin_id": admin_id,
|
||||||
"order_id": order_id,
|
"order_id": order_id_int,
|
||||||
"product_name": product_name,
|
"product_name": product_name,
|
||||||
"quantity": quantity,
|
"quantity": qty_int,
|
||||||
"price": float(price)
|
"price": price_f,
|
||||||
|
"status": "pending"
|
||||||
}), 201
|
}), 201
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
cnx.rollback()
|
cnx.rollback()
|
||||||
|
current_app.logger.exception("create_order_item error")
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
try:
|
try: cur.close()
|
||||||
cur.close()
|
|
||||||
except: pass
|
except: pass
|
||||||
try:
|
try: cnx.close()
|
||||||
cnx.close()
|
|
||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# List / Get order item(s)
|
# List / Get order item(s)
|
||||||
# -------------------------
|
# -------------------------
|
||||||
@dashboard_bp.route("/order-item", methods=["GET"])
|
@dashboard_bp.route("/order-item", methods=["GET"])
|
||||||
@jwt_required
|
@basic_auth_required
|
||||||
def list_or_get_order_item():
|
def list_or_get_order_item():
|
||||||
admin_id = request.admin_id
|
admin_id = getattr(request, "admin_id", None)
|
||||||
id_param = request.args.get("id", "").strip()
|
id_param = request.args.get("id", "").strip()
|
||||||
order_id_param = request.args.get("order_id", "").strip()
|
order_id_param = request.args.get("order_id", "").strip()
|
||||||
|
|
||||||
cnx = get_db()
|
cnx = get_db()
|
||||||
cur = cnx.cursor(dictionary=True)
|
cur = cnx.cursor(dictionary=True)
|
||||||
try:
|
try:
|
||||||
if id_param == "" and order_id_param == "":
|
if id_param == "" and order_id_param == "":
|
||||||
# List all order items for this admin
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT id, admin_id, order_id, product_name, quantity, price, total, status, created_at FROM order_items WHERE admin_id = %s",
|
"SELECT id, admin_id, order_id, product_name, quantity, price, status, created_at "
|
||||||
|
"FROM order_items WHERE admin_id = %s",
|
||||||
(admin_id,)
|
(admin_id,)
|
||||||
)
|
)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
return jsonify({
|
return jsonify({"rows": rows}), 200
|
||||||
"rows": rows
|
|
||||||
}), 200
|
|
||||||
|
|
||||||
elif order_id_param != "":
|
elif order_id_param != "":
|
||||||
# List all items for a specific order
|
|
||||||
try:
|
try:
|
||||||
order_id_int = int(order_id_param)
|
order_id_int = int(order_id_param)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return jsonify({"error": "order_id must be integer"}), 400
|
return jsonify({"error": "order_id must be integer"}), 400
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT id, admin_id, order_id, product_name, quantity, price, total, status, created_at FROM order_items WHERE order_id = %s AND admin_id = %s",
|
"SELECT id, admin_id, order_id, product_name, quantity, price, status, created_at "
|
||||||
|
"FROM order_items WHERE order_id = %s AND admin_id = %s",
|
||||||
(order_id_int, admin_id)
|
(order_id_int, admin_id)
|
||||||
)
|
)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
|
return jsonify({"rows": rows}), 200
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Get specific order item by id
|
|
||||||
try:
|
try:
|
||||||
id_int = int(id_param)
|
id_int = int(id_param)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return jsonify({"error": "id must be integer"}), 400
|
return jsonify({"error": "id must be integer"}), 400
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT id, admin_id, order_id, product_name, quantity, price, total, status, created_at FROM order_items WHERE id = %s AND admin_id = %s",
|
"SELECT id, admin_id, order_id, product_name, quantity, price, status, created_at "
|
||||||
|
"FROM order_items WHERE id = %s AND admin_id = %s",
|
||||||
(id_int, admin_id)
|
(id_int, admin_id)
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
return jsonify({"error": "order item not found"}), 404
|
return jsonify({"error": "order item not found"}), 404
|
||||||
return jsonify({
|
return jsonify({"row": row}), 200
|
||||||
"rows": rows
|
|
||||||
}), 200
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
current_app.logger.exception("list_or_get_order_item error")
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
finally:
|
finally:
|
||||||
try:
|
try: cur.close()
|
||||||
cur.close()
|
except: pass
|
||||||
except Exception:
|
try: cnx.close()
|
||||||
pass
|
except: pass
|
||||||
try:
|
|
||||||
cnx.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------------
|
|
||||||
# Update order item
|
|
||||||
# -------------------------
|
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# Delete order item
|
# Delete order item
|
||||||
# -------------------------
|
# -------------------------
|
||||||
from flask import current_app, jsonify, request
|
|
||||||
# optionally: from flask_jwt_extended import jwt_required, get_jwt_identity
|
|
||||||
|
|
||||||
@dashboard_bp.route("/order-item/<int:id>", methods=["DELETE"])
|
@dashboard_bp.route("/order-item/<int:id>", methods=["DELETE"])
|
||||||
@jwt_required
|
@basic_auth_required
|
||||||
def delete_order_item(id):
|
def delete_order_item(id):
|
||||||
# get admin id from jwt (adjust according to your JWT middleware)
|
|
||||||
admin_id = getattr(request, "admin_id", None)
|
admin_id = getattr(request, "admin_id", None)
|
||||||
# or if you use flask_jwt_extended: admin_id = get_jwt_identity()
|
|
||||||
|
|
||||||
if admin_id is None:
|
|
||||||
current_app.logger.warning("delete_order_item: missing admin_id on request")
|
|
||||||
return jsonify({"error": "unauthenticated"}), 401
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
id_int = int(id)
|
id_int = int(id)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
@ -232,31 +272,33 @@ def delete_order_item(id):
|
|||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
return jsonify({"error": "order item not found"}), 404
|
return jsonify({"error": "order item not found"}), 404
|
||||||
|
|
||||||
if int(row["admin_id"]) != int(admin_id):
|
if int(row["admin_id"]) != int(admin_id):
|
||||||
return jsonify({"error": "not authorized to delete this order item"}), 403
|
return jsonify({"error": "not authorized to delete this order item"}), 403
|
||||||
|
|
||||||
cur.execute("DELETE FROM order_items WHERE id = %s", (id_int,))
|
cur2 = cnx.cursor()
|
||||||
cnx.commit()
|
try:
|
||||||
return jsonify({"message": "order item deleted", "id": id_int}), 200
|
cur2.execute("DELETE FROM order_items WHERE id = %s", (id_int,))
|
||||||
|
cnx.commit()
|
||||||
|
finally:
|
||||||
|
try: cur2.close()
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
return jsonify({"message": "order item deleted", "id": id_int}), 200
|
||||||
except Exception:
|
except Exception:
|
||||||
cnx.rollback()
|
cnx.rollback()
|
||||||
current_app.logger.exception("Error deleting order_item id=%s", id_int)
|
current_app.logger.exception("Error deleting order_item id=%s", id_int)
|
||||||
return jsonify({"error": "internal server error"}), 500
|
return jsonify({"error": "internal server error"}), 500
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
try: cur.close()
|
try: cur.close()
|
||||||
except: pass
|
except: pass
|
||||||
try: cnx.close()
|
try: cnx.close()
|
||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# Demo endpoint: UNSAFE SQL (for educational purposes only)
|
# Unsafe demo (intentional SQL injection vulnerability)
|
||||||
# -------------------------
|
# -------------------------
|
||||||
@dashboard_bp.route("/order-item/unsafe", methods=["GET"])
|
@dashboard_bp.route("/order-item/unsafe", methods=["GET"])
|
||||||
@jwt_required
|
@basic_auth_required
|
||||||
def get_order_item_unsafe():
|
def get_order_item_unsafe():
|
||||||
"""
|
"""
|
||||||
UNSAFE VERSION - DO NOT USE IN PRODUCTION
|
UNSAFE VERSION - DO NOT USE IN PRODUCTION
|
||||||
|
|||||||
@ -1,15 +1,12 @@
|
|||||||
# admin_signin.py
|
# admin_signin.py
|
||||||
import os
|
import os
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from functools import wraps
|
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
import mysql.connector
|
import mysql.connector
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
import jwt
|
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# Config from env
|
# Config from env (adjust if needed)
|
||||||
# -------------------------
|
# -------------------------
|
||||||
DB_HOST = os.getenv("MYSQL_HOST", "127.0.0.1")
|
DB_HOST = os.getenv("MYSQL_HOST", "127.0.0.1")
|
||||||
DB_USER = os.getenv("MYSQL_USER", "root")
|
DB_USER = os.getenv("MYSQL_USER", "root")
|
||||||
@ -17,11 +14,6 @@ DB_PASS = os.getenv("MYSQL_PASSWORD", "12345!")
|
|||||||
DB_NAME = os.getenv("MYSQL_DB", "sqlilab")
|
DB_NAME = os.getenv("MYSQL_DB", "sqlilab")
|
||||||
DB_PORT = int(os.getenv("MYSQL_PORT", 3306))
|
DB_PORT = int(os.getenv("MYSQL_PORT", 3306))
|
||||||
|
|
||||||
JWT_SECRET = os.getenv("JWT_SECRET", "SUPER_SECRET_KEY_SHOULD_CHANGE")
|
|
||||||
JWT_ALGO = "HS256"
|
|
||||||
ACCESS_TOKEN_EXPIRES_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRES_MINUTES", 15))
|
|
||||||
REFRESH_TOKEN_EXPIRES_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRES_DAYS", 7))
|
|
||||||
|
|
||||||
auth_bp = Blueprint("auth", __name__)
|
auth_bp = Blueprint("auth", __name__)
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
@ -39,173 +31,46 @@ def get_db():
|
|||||||
return mysql.connector.connect(**conn_args)
|
return mysql.connector.connect(**conn_args)
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# Ensure admins table has refresh token columns (runs at import)
|
# Ensure admins table has refresh columns (best-effort, safe)
|
||||||
# -------------------------
|
# -------------------------
|
||||||
def ensure_admin_refresh_columns():
|
def ensure_admin_refresh_columns():
|
||||||
"""
|
"""
|
||||||
Adds columns refresh_token and refresh_expires_at to admins table if missing.
|
Best-effort: ensure admins_cred table exists and has common columns.
|
||||||
Safe to call at app startup.
|
This will not throw if table missing; useful at startup.
|
||||||
"""
|
"""
|
||||||
cnx = None
|
cnx = None
|
||||||
cur = None
|
cur = None
|
||||||
try:
|
try:
|
||||||
cnx = get_db()
|
cnx = get_db()
|
||||||
cur = cnx.cursor(dictionary=True)
|
cur = cnx.cursor()
|
||||||
# check if 'admins' table exists and columns exist
|
# Try a harmless check; if table doesn't exist this will raise and we ignore
|
||||||
cur.execute("""
|
cur.execute(
|
||||||
SELECT COLUMN_NAME
|
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS "
|
||||||
FROM INFORMATION_SCHEMA.COLUMNS
|
"WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'admins_cred' AND COLUMN_NAME IN ('refresh_token','refresh_expires_at')",
|
||||||
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'admins_cred' AND COLUMN_NAME IN ('refresh_token','refresh_expires_at')
|
(DB_NAME,)
|
||||||
""", (DB_NAME,))
|
)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
existing = {r["COLUMN_NAME"] for r in rows}
|
# If you want ALTER logic, paste here — keeping minimal to avoid accidental schema changes.
|
||||||
to_add = []
|
except Exception:
|
||||||
if "refresh_token" not in existing:
|
# ignore errors (table may not exist in dev)
|
||||||
to_add.append("ADD COLUMN refresh_token VARCHAR(255) DEFAULT NULL")
|
pass
|
||||||
if "refresh_expires_at" not in existing:
|
|
||||||
to_add.append("ADD COLUMN refresh_expires_at DATETIME DEFAULT NULL")
|
|
||||||
if to_add:
|
|
||||||
alter_sql = "ALTER TABLE admins " + ", ".join(to_add)
|
|
||||||
cur.execute(alter_sql)
|
|
||||||
# add an index on refresh_token (if not exists)
|
|
||||||
try:
|
|
||||||
cur.execute("CREATE INDEX idx_admins_refresh_token ON admins (refresh_token)")
|
|
||||||
except Exception:
|
|
||||||
# index may already exist or DB may throw; ignore silently
|
|
||||||
pass
|
|
||||||
cnx.commit()
|
|
||||||
except mysql.connector.Error:
|
|
||||||
# If admins table doesn't exist or other DB error, don't crash import.
|
|
||||||
# The app should create 'admins' table via your normal migrations/SQL.
|
|
||||||
if cnx:
|
|
||||||
cnx.rollback()
|
|
||||||
finally:
|
finally:
|
||||||
if cur:
|
try:
|
||||||
cur.close()
|
if cur:
|
||||||
if cnx:
|
cur.close()
|
||||||
cnx.close()
|
except:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if cnx:
|
||||||
|
cnx.close()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
# Try to ensure columns exist (best-effort, won't raise on missing table)
|
# call it so import won't fail
|
||||||
ensure_admin_refresh_columns()
|
ensure_admin_refresh_columns()
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# Token helpers (use admins table for single refresh token per-admin)
|
# Signup & Login endpoints (simple)
|
||||||
# -------------------------
|
|
||||||
def create_access_token(admin_id):
|
|
||||||
exp = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRES_MINUTES)
|
|
||||||
payload = {"admin_id": admin_id, "exp": exp, "typ": "access"}
|
|
||||||
token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGO)
|
|
||||||
# pyjwt may return bytes in some versions
|
|
||||||
return token if isinstance(token, str) else token.decode("utf-8")
|
|
||||||
|
|
||||||
def create_refresh_token_str():
|
|
||||||
return str(uuid.uuid4())
|
|
||||||
|
|
||||||
def store_refresh_token(admin_id, token_str):
|
|
||||||
"""Store (or replace) the refresh token for the given admin id."""
|
|
||||||
expires_at = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRES_DAYS)
|
|
||||||
cnx = get_db()
|
|
||||||
cur = cnx.cursor()
|
|
||||||
try:
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE admins_cred SET refresh_token = %s, refresh_expires_at = %s WHERE id = %s",
|
|
||||||
(token_str, expires_at, admin_id),
|
|
||||||
)
|
|
||||||
# If no row was updated maybe admin doesn't exist; commit anyway
|
|
||||||
cnx.commit()
|
|
||||||
except Exception:
|
|
||||||
cnx.rollback()
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
cur.close()
|
|
||||||
cnx.close()
|
|
||||||
|
|
||||||
def find_refresh_token(token_str):
|
|
||||||
"""Find the admin row by refresh token. Returns dict with admin_id and refresh_expires_at or None."""
|
|
||||||
cnx = get_db()
|
|
||||||
cur = cnx.cursor(dictionary=True)
|
|
||||||
try:
|
|
||||||
cur.execute(
|
|
||||||
"SELECT id AS admin_id, refresh_token, refresh_expires_at FROM admins WHERE refresh_token = %s",
|
|
||||||
(token_str,),
|
|
||||||
)
|
|
||||||
return cur.fetchone()
|
|
||||||
finally:
|
|
||||||
cur.close()
|
|
||||||
cnx.close()
|
|
||||||
|
|
||||||
def delete_refresh_token(token_str):
|
|
||||||
"""Remove refresh token by clearing fields on admins table. Returns rowcount."""
|
|
||||||
cnx = get_db()
|
|
||||||
cur = cnx.cursor()
|
|
||||||
try:
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE admins_cred SET refresh_token = NULL, refresh_expires_at = NULL WHERE refresh_token = %s",
|
|
||||||
(token_str,),
|
|
||||||
)
|
|
||||||
rowcount = cur.rowcount
|
|
||||||
cnx.commit()
|
|
||||||
return rowcount
|
|
||||||
except Exception:
|
|
||||||
cnx.rollback()
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
cur.close()
|
|
||||||
cnx.close()
|
|
||||||
|
|
||||||
def rotate_refresh_token(old_token, admin_id):
|
|
||||||
"""
|
|
||||||
Atomically rotate the refresh token for the admin.
|
|
||||||
This updates the admins row only if it currently belongs to that admin (or is NULL).
|
|
||||||
Returns new token string on success, or None if rotation failed.
|
|
||||||
"""
|
|
||||||
new_token = create_refresh_token_str()
|
|
||||||
expires_at = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRES_DAYS)
|
|
||||||
cnx = get_db()
|
|
||||||
cur = cnx.cursor()
|
|
||||||
try:
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE admins_cred SET refresh_token = %s, refresh_expires_at = %s "
|
|
||||||
"WHERE id = %s AND (refresh_token = %s OR refresh_token IS NULL)",
|
|
||||||
(new_token, expires_at, admin_id, old_token),
|
|
||||||
)
|
|
||||||
cnx.commit()
|
|
||||||
if cur.rowcount == 0:
|
|
||||||
# rotation failed (token mismatch or admin doesn't have that token)
|
|
||||||
return None
|
|
||||||
return new_token
|
|
||||||
except Exception:
|
|
||||||
cnx.rollback()
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
cur.close()
|
|
||||||
cnx.close()
|
|
||||||
|
|
||||||
# -------------------------
|
|
||||||
# jwt_required decorator
|
|
||||||
# -------------------------
|
|
||||||
def jwt_required(f):
|
|
||||||
@wraps(f)
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
auth = request.headers.get("Authorization", "")
|
|
||||||
if not auth or not auth.startswith("Bearer "):
|
|
||||||
return jsonify({"error": "Missing Authorization header"}), 401
|
|
||||||
token = auth.split(" ", 1)[1]
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGO])
|
|
||||||
if payload.get("typ") != "access":
|
|
||||||
return jsonify({"error": "Invalid token type"}), 401
|
|
||||||
# attach admin_id for handlers to use
|
|
||||||
request.admin_id = int(payload["admin_id"])
|
|
||||||
except jwt.ExpiredSignatureError:
|
|
||||||
return jsonify({"error": "Access token expired"}), 401
|
|
||||||
except jwt.InvalidTokenError:
|
|
||||||
return jsonify({"error": "Invalid access token"}), 401
|
|
||||||
return f(*args, **kwargs)
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
# -------------------------
|
|
||||||
# Routes: signup, login, refresh, logout
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
@auth_bp.route("/admin_signup", methods=["POST"])
|
@auth_bp.route("/admin_signup", methods=["POST"])
|
||||||
def admin_signup():
|
def admin_signup():
|
||||||
@ -218,98 +83,59 @@ def admin_signup():
|
|||||||
return jsonify({"error": "username and password required"}), 400
|
return jsonify({"error": "username and password required"}), 400
|
||||||
|
|
||||||
hashed = generate_password_hash(password)
|
hashed = generate_password_hash(password)
|
||||||
cnx = get_db()
|
cnx = None
|
||||||
cur = cnx.cursor()
|
cur = None
|
||||||
try:
|
try:
|
||||||
cur.execute("INSERT INTO admins_cred (username, password) VALUES (%s, %s)", (username, hashed))
|
cnx = get_db()
|
||||||
|
cur = cnx.cursor()
|
||||||
|
cur.execute("INSERT INTO admins_cred (username, password, created_at) VALUES (%s, %s, %s)",
|
||||||
|
(username, hashed, datetime.utcnow()))
|
||||||
cnx.commit()
|
cnx.commit()
|
||||||
new_id = cur.lastrowid
|
new_id = cur.lastrowid
|
||||||
return jsonify({"message": "admin signed up", "id": new_id}), 201
|
return jsonify({"message": "admin signed up", "id": new_id}), 201
|
||||||
except mysql.connector.IntegrityError:
|
except mysql.connector.IntegrityError:
|
||||||
cnx.rollback()
|
if cnx:
|
||||||
|
cnx.rollback()
|
||||||
return jsonify({"error": "username already exists"}), 400
|
return jsonify({"error": "username already exists"}), 400
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
cnx.rollback()
|
if cnx:
|
||||||
|
cnx.rollback()
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
finally:
|
finally:
|
||||||
cur.close()
|
try:
|
||||||
cnx.close()
|
if cur: cur.close()
|
||||||
|
except: pass
|
||||||
|
try:
|
||||||
|
if cnx: cnx.close()
|
||||||
|
except: pass
|
||||||
|
|
||||||
@auth_bp.route("/safe/admin/login", methods=["POST"])
|
@auth_bp.route("/admin_login", methods=["POST"])
|
||||||
def safe_admin_login():
|
def admin_login():
|
||||||
data = request.get_json() or {}
|
if not request.is_json:
|
||||||
|
return jsonify({"error": "expected JSON body"}), 400
|
||||||
|
data = request.get_json()
|
||||||
username = (data.get("username") or "").strip()
|
username = (data.get("username") or "").strip()
|
||||||
password = (data.get("password") or "").strip()
|
password = (data.get("password") or "").strip()
|
||||||
if not username or not password:
|
if not username or not password:
|
||||||
return jsonify({"error": "username and password required"}), 400
|
return jsonify({"error": "username and password required"}), 400
|
||||||
|
|
||||||
cnx = get_db()
|
cnx = None
|
||||||
cur = cnx.cursor(dictionary=True)
|
cur = None
|
||||||
try:
|
try:
|
||||||
|
cnx = get_db()
|
||||||
|
cur = cnx.cursor(dictionary=True)
|
||||||
cur.execute("SELECT id, username, password FROM admins_cred WHERE username = %s", (username,))
|
cur.execute("SELECT id, username, password FROM admins_cred WHERE username = %s", (username,))
|
||||||
admin = cur.fetchone()
|
admin = cur.fetchone()
|
||||||
if not admin or not check_password_hash(admin["password"], password):
|
if not admin or not check_password_hash(admin["password"], password):
|
||||||
return jsonify({"error": "invalid credentials"}), 401
|
return jsonify({"error": "invalid credentials"}), 401
|
||||||
|
|
||||||
admin_id = int(admin["id"])
|
return jsonify({"message": "login success", "admin": {"id": int(admin["id"]), "username": admin["username"]}}), 200
|
||||||
access_token = create_access_token(admin_id)
|
|
||||||
refresh_token = create_refresh_token_str()
|
|
||||||
store_refresh_token(admin_id, refresh_token)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"message": "login success",
|
|
||||||
"access_token": access_token,
|
|
||||||
"refresh_token": refresh_token,
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"expires_in": ACCESS_TOKEN_EXPIRES_MINUTES * 60
|
|
||||||
}), 200
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
finally:
|
finally:
|
||||||
cur.close()
|
|
||||||
cnx.close()
|
|
||||||
|
|
||||||
@auth_bp.route("/token/refresh", methods=["POST"])
|
|
||||||
def token_refresh():
|
|
||||||
body = request.get_json() or {}
|
|
||||||
old_refresh = (body.get("refresh_token") or "").strip()
|
|
||||||
if not old_refresh:
|
|
||||||
return jsonify({"error": "refresh_token required"}), 400
|
|
||||||
|
|
||||||
row = find_refresh_token(old_refresh)
|
|
||||||
if not row:
|
|
||||||
return jsonify({"error": "invalid refresh token"}), 401
|
|
||||||
|
|
||||||
expires_at = row.get("refresh_expires_at")
|
|
||||||
if expires_at and datetime.utcnow() > expires_at:
|
|
||||||
# expired - remove it and return error
|
|
||||||
delete_refresh_token(old_refresh)
|
|
||||||
return jsonify({"error": "refresh token expired"}), 401
|
|
||||||
|
|
||||||
admin_id = int(row["admin_id"])
|
|
||||||
# rotate (atomic update). rotate_refresh_token returns None on failure.
|
|
||||||
new_refresh = rotate_refresh_token(old_refresh, admin_id)
|
|
||||||
if not new_refresh:
|
|
||||||
# rotation failed - treat as invalid
|
|
||||||
return jsonify({"error": "invalid refresh token"}), 401
|
|
||||||
|
|
||||||
new_access = create_access_token(admin_id)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"access_token": new_access,
|
|
||||||
"refresh_token": new_refresh,
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"expires_in": ACCESS_TOKEN_EXPIRES_MINUTES * 60
|
|
||||||
}), 200
|
|
||||||
|
|
||||||
@auth_bp.route("/safe/admin/logout", methods=["POST"])
|
|
||||||
def admin_logout():
|
|
||||||
body = request.get_json() or {}
|
|
||||||
refresh = (body.get("refresh_token") or "").strip()
|
|
||||||
if refresh:
|
|
||||||
try:
|
try:
|
||||||
delete_refresh_token(refresh)
|
if cur: cur.close()
|
||||||
except Exception:
|
except: pass
|
||||||
# ignore errors during logout - still respond success to client
|
try:
|
||||||
pass
|
if cnx: cnx.close()
|
||||||
return jsonify({"message": "logged out"}), 200
|
except: pass
|
||||||
|
|||||||
58
deploy.sh
58
deploy.sh
@ -266,32 +266,32 @@ else
|
|||||||
BACKEND_PIDFILE="$ROOT_DIR/backend.pid"
|
BACKEND_PIDFILE="$ROOT_DIR/backend.pid"
|
||||||
start_bg "$BACKEND_CMD" "$ROOT_DIR/$BACKEND_LOG" "$BACKEND_PIDFILE"
|
start_bg "$BACKEND_CMD" "$ROOT_DIR/$BACKEND_LOG" "$BACKEND_PIDFILE"
|
||||||
fi
|
fi
|
||||||
# ---------- FRONTEND ----------
|
# # ---------- FRONTEND ----------
|
||||||
info "Starting frontend..."
|
# info "Starting frontend..."
|
||||||
if [ -f "$ROOT_DIR/$FRONTEND_DIR/package.json" ]; then
|
# if [ -f "$ROOT_DIR/$FRONTEND_DIR/package.json" ]; then
|
||||||
cd "$ROOT_DIR/$FRONTEND_DIR"
|
# cd "$ROOT_DIR/$FRONTEND_DIR"
|
||||||
if [[ "$(pwd)" == /mnt/* ]]; then
|
# if [[ "$(pwd)" == /mnt/* ]]; then
|
||||||
info "Detected WSL/Windows FS. Cleaning node_modules..."
|
# info "Detected WSL/Windows FS. Cleaning node_modules..."
|
||||||
rm -rf node_modules package-lock.json
|
# rm -rf node_modules package-lock.json
|
||||||
npm install
|
# npm install
|
||||||
else
|
# else
|
||||||
if [ -f "package-lock.json" ]; then
|
# if [ -f "package-lock.json" ]; then
|
||||||
if ! npm ci; then
|
# if ! npm ci; then
|
||||||
rm -rf node_modules
|
# rm -rf node_modules
|
||||||
npm install
|
# npm install
|
||||||
fi
|
# fi
|
||||||
else
|
# else
|
||||||
npm install
|
# npm install
|
||||||
fi
|
# fi
|
||||||
fi
|
# fi
|
||||||
|
|
||||||
FRONTEND_START_CMD=${FRONTEND_START_CMD:-"npm run dev"}
|
# FRONTEND_START_CMD=${FRONTEND_START_CMD:-"npm run dev"}
|
||||||
FRONTEND_PIDFILE="$ROOT_DIR/frontend.pid"
|
# FRONTEND_PIDFILE="$ROOT_DIR/frontend.pid"
|
||||||
start_bg "$FRONTEND_START_CMD" "$ROOT_DIR/$FRONTEND_LOG" "$FRONTEND_PIDFILE"
|
# start_bg "$FRONTEND_START_CMD" "$ROOT_DIR/$FRONTEND_LOG" "$FRONTEND_PIDFILE"
|
||||||
cd "$ROOT_DIR"
|
# cd "$ROOT_DIR"
|
||||||
else
|
# else
|
||||||
warn "No package.json in frontend/ — skipping frontend start."
|
# warn "No package.json in frontend/ — skipping frontend start."
|
||||||
fi
|
# fi
|
||||||
|
|
||||||
# ---------- FINISH ----------
|
# ---------- FINISH ----------
|
||||||
echo ""
|
echo ""
|
||||||
@ -306,14 +306,14 @@ echo "$(timestamp) Backend → http://localhost:5000"
|
|||||||
echo " Log: $BACKEND_LOG"
|
echo " Log: $BACKEND_LOG"
|
||||||
echo " PID: $(cat backend.pid 2>/dev/null || echo 'N/A')"
|
echo " PID: $(cat backend.pid 2>/dev/null || echo 'N/A')"
|
||||||
echo ""
|
echo ""
|
||||||
echo "$(timestamp) Frontend → http://localhost:5173"
|
# echo "$(timestamp) Frontend → http://localhost:5173"
|
||||||
echo " Log: $FRONTEND_LOG"
|
# echo " Log: $FRONTEND_LOG"
|
||||||
echo " PID: $(cat frontend.pid 2>/dev/null || echo 'N/A')"
|
# echo " PID: $(cat frontend.pid 2>/dev/null || echo 'N/A')"
|
||||||
echo "========================================="
|
echo "========================================="
|
||||||
echo ""
|
echo ""
|
||||||
echo "Useful commands:"
|
echo "Useful commands:"
|
||||||
echo " tail -f $BACKEND_LOG"
|
echo " tail -f $BACKEND_LOG"
|
||||||
echo " tail -f $FRONTEND_LOG"
|
# echo " tail -f $FRONTEND_LOG"
|
||||||
echo " $DOCKER_CMD logs -f $MYSQL_CONTAINER"
|
echo " $DOCKER_CMD logs -f $MYSQL_CONTAINER"
|
||||||
echo " $DOCKER_CMD exec -it $MYSQL_CONTAINER mysql -uroot -p$MYSQL_ROOT_PASSWORD $MYSQL_DATABASE"
|
echo " $DOCKER_CMD exec -it $MYSQL_CONTAINER mysql -uroot -p$MYSQL_ROOT_PASSWORD $MYSQL_DATABASE"
|
||||||
echo " kill \$(cat backend.pid 2>/dev/null || echo '') && kill \$(cat frontend.pid 2>/dev/null || echo '') && $DOCKER_CMD stop $MYSQL_CONTAINER"
|
echo " kill \$(cat backend.pid 2>/dev/null || echo '') && kill \$(cat frontend.pid 2>/dev/null || echo '') && $DOCKER_CMD stop $MYSQL_CONTAINER"
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
7587
|
3920
|
||||||
|
|||||||
2398
frontend/package-lock.json
generated
2398
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user