| """ |
| REC Durgapur 1988 Batch Yearbook & Directory System |
| =================================================== |
| |
| This module serves as the primary backend for the REC Durgapur 1988 Batch |
| Yearbook application. It implements a secure, role-based, end-to-end encrypted |
| directory system. |
| |
| Key features include: |
| - Dual-database architecture (Structured metadata and BLOB storage) |
| - End-to-end Fernet AES-256 encryption for data at rest |
| - Role-based Access Control (RBAC) |
| - Multi-pass image compression with local storage quotas |
| - Background Hugging Face dataset synchronization for resilience |
| """ |
|
|
| import os |
| import sys |
| import json |
| import random |
| import logging |
| import sqlite3 |
| import shutil |
| import threading |
| import signal |
| import secrets |
| import hashlib |
| import time |
| import socket |
| import platform |
| from datetime import datetime, timedelta |
| from functools import wraps |
| from io import BytesIO |
| from werkzeug.utils import secure_filename |
|
|
| from flask import ( |
| Flask, render_template, redirect, url_for, request, |
| session, flash, send_file, Response, abort, jsonify |
| ) |
| import pandas as pd |
| from cryptography.fernet import Fernet |
| from werkzeug.security import generate_password_hash, check_password_hash |
| import pyotp |
| import qrcode |
|
|
| |
| |
| |
|
|
| logging.basicConfig( |
| level=logging.INFO, |
| format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' |
| ) |
| logger = logging.getLogger('recdgp88') |
|
|
| |
| logging.getLogger('werkzeug').setLevel(logging.WARNING) |
|
|
| app = Flask(__name__, template_folder='app/templates', static_folder='app/static') |
|
|
| |
| BASE_DIR = os.path.dirname(os.path.abspath(__file__)) |
| DB_DIR = os.path.join(BASE_DIR, 'app/databases') |
| IMG_DIR = os.path.join(BASE_DIR, 'app/images') |
| os.makedirs(DB_DIR, exist_ok=True) |
| os.makedirs(IMG_DIR, exist_ok=True) |
|
|
| STRUCTURED_DB_FILE = os.path.join(DB_DIR, 'structured.db') |
| BLOBS_DB_FILE = os.path.join(DB_DIR, 'blobs.db') |
| KEY_FILE = os.path.join(DB_DIR, '.encryption_key') |
|
|
| HF_REPO_ID = "Sam-max1/recdgp88_data" |
|
|
| |
| |
| |
|
|
| class EncryptionManager: |
| _key = None |
|
|
| @classmethod |
| def get_key(cls): |
| if cls._key: |
| return cls._key |
| |
| |
| env_key = os.environ.get('APP_ENCRYPTION_KEY') |
| if env_key: |
| cls._key = env_key.encode() if isinstance(env_key, str) else env_key |
| logger.info("Loaded encryption key from environment variable.") |
| return cls._key |
|
|
| |
| if os.path.exists(KEY_FILE): |
| try: |
| os.chmod(KEY_FILE, 0o600) |
| except Exception: |
| pass |
| with open(KEY_FILE, 'rb') as f: |
| cls._key = f.read() |
| logger.info(f"Loaded encryption key from file: {KEY_FILE}") |
| return cls._key |
|
|
| |
| cls._key = Fernet.generate_key() |
| with open(KEY_FILE, 'wb') as f: |
| f.write(cls._key) |
| try: |
| os.chmod(KEY_FILE, 0o600) |
| except Exception: |
| pass |
| logger.info(f"Generated new encryption key and saved to: {KEY_FILE}") |
| |
| |
| sync_to_hf_async(KEY_FILE, "app/databases/.encryption_key") |
| |
| return cls._key |
|
|
| @classmethod |
| def reload_key(cls): |
| """Clears the cached key so it will be re-read from disk on next get_key() call. |
| Must be called after startup HF sync downloads the real key file.""" |
| cls._key = None |
| logger.info("EncryptionManager: cleared cached key – will reload from disk on next use.") |
| return cls.get_key() |
|
|
| @classmethod |
| def encrypt_string(cls, plaintext: str) -> str: |
| if not plaintext: |
| return "" |
| suite = Fernet(cls.get_key()) |
| return suite.encrypt(plaintext.encode('utf-8')).decode('utf-8') |
|
|
| @classmethod |
| def decrypt_string(cls, ciphertext: str) -> str: |
| if not ciphertext: |
| return "" |
| try: |
| suite = Fernet(cls.get_key()) |
| return suite.decrypt(ciphertext.encode('utf-8')).decode('utf-8') |
| except Exception as e: |
| logger.error(f"Decryption error: {e}") |
| return "" |
|
|
| @classmethod |
| def encrypt_bytes(cls, raw_bytes: bytes) -> bytes: |
| if not raw_bytes: |
| return b"" |
| suite = Fernet(cls.get_key()) |
| return suite.encrypt(raw_bytes) |
|
|
| @classmethod |
| def decrypt_bytes(cls, ciphertext_bytes: bytes) -> bytes: |
| if not ciphertext_bytes: |
| return b"" |
| try: |
| suite = Fernet(cls.get_key()) |
| return suite.decrypt(ciphertext_bytes) |
| except Exception as e: |
| logger.error(f"Bytes decryption error: {e}") |
| return b"" |
|
|
| def is_hf_mode() -> bool: |
| """ |
| Identifies whether the app is running in Hugging Face (Spaces) mode. |
| Hugging Face automatically sets environment variables such as SPACE_ID. |
| Returns False if LOCAL_RUN is explicitly set to true/1, or if none of the HF env vars are present. |
| """ |
| if os.environ.get("LOCAL_RUN") in ["true", "1"]: |
| return False |
| return ( |
| (os.environ.get("SPACE_ID") is not None or |
| os.environ.get("SPACE_REPO_NAME") is not None or |
| os.environ.get("RUNNING_ON_HF") == "true") and |
| os.environ.get("HF_TOKEN") is not None |
| ) |
|
|
| |
| app.secret_key = os.environ.get('FLASK_SECRET_KEY') |
| if not app.secret_key: |
| import hashlib |
| try: |
| app.secret_key = hashlib.sha256(EncryptionManager.get_key()).hexdigest() |
| except Exception: |
| app.secret_key = 'recdgp88-fallback-secret-key-1988-durgapur' |
|
|
| |
| |
| |
| if is_hf_mode(): |
| app.config.update( |
| SESSION_COOKIE_HTTPONLY=True, |
| SESSION_COOKIE_SAMESITE='None', |
| SESSION_COOKIE_SECURE=True |
| ) |
| else: |
| app.config.update( |
| SESSION_COOKIE_HTTPONLY=True, |
| SESSION_COOKIE_SAMESITE='Lax', |
| SESSION_COOKIE_SECURE=not (os.environ.get('LOCAL_RUN') in ["true", "1"]) |
| ) |
|
|
| @app.after_request |
| def add_security_headers(response): |
| """Inject hardened HTTP security headers per OWASP, NIST, and ISO 27001.""" |
| response.headers['Content-Security-Policy'] = ( |
| "default-src 'self'; " |
| "script-src 'self' 'unsafe-inline'; " |
| "style-src 'self' 'unsafe-inline'; " |
| "img-src 'self' data:; " |
| "media-src 'self'; " |
| "frame-ancestors 'none'; " |
| "object-src 'none'; " |
| "base-uri 'self';" |
| ) |
| response.headers['X-Frame-Options'] = 'DENY' |
| response.headers['X-Content-Type-Options'] = 'nosniff' |
| response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' |
| response.headers['X-Permitted-Cross-Domain-Policies'] = 'none' |
| response.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()' |
|
|
| |
| if session.get('userid'): |
| response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, private' |
| response.headers['Pragma'] = 'no-cache' |
|
|
| if not (os.environ.get('LOCAL_RUN') in ["true", "1"]): |
| response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains; preload' |
|
|
| return response |
|
|
|
|
| |
| |
| |
|
|
| _IP_ANON_SALT = secrets.token_hex(16) |
|
|
| def _anonymize_ip(ip: str) -> str: |
| """GDPR/DPDPA: Return a stable pseudonym for an IP address within this process lifetime. |
| The salt rotates on restart, so IPs cannot be reconstructed from the hash.""" |
| if not ip: |
| return 'unknown' |
| return 'ip-' + hashlib.sha256(f"{_IP_ANON_SALT}{ip}".encode()).hexdigest()[:12] |
|
|
|
|
| |
| |
| |
|
|
| def generate_csrf_token() -> str: |
| """Generate and store a cryptographically secure CSRF token in the session.""" |
| if 'csrf_token' not in session or not session.get('csrf_token'): |
| session['csrf_token'] = secrets.token_hex(32) |
| return session['csrf_token'] |
|
|
| def validate_csrf_token() -> bool: |
| """Validate the CSRF token from the request form against the session token.""" |
| if app.testing or app.config.get('TESTING'): |
| return True |
| request_token = request.form.get('csrf_token', '') |
| session_token = session.get('csrf_token', '') |
| if not request_token or not session_token: |
| return False |
| return secrets.compare_digest(request_token, session_token) |
|
|
| |
| CSRF_EXEMPT_ENDPOINTS = frozenset([ |
| 'login', 'captcha_img', 'static', 'privacy', 'logout', |
| 'setup_password', 'setup_2fa', 'verify_2fa', 'force_change_password', |
| 'privacy_accept', 'privacy_banner_dismiss', |
| 'surveys_delete', 'announcements_delete', 'album_delete_folder', |
| 'album_delete_media', 'personal_album_delete_folder', 'personal_album_delete_media', |
| 'delete_photo', 'admin_action' |
| ]) |
|
|
|
|
|
|
|
|
|
|
| @app.before_request |
| def enforce_csrf(): |
| """Enforce CSRF token validation on all state-changing requests.""" |
| if request.method in ('POST', 'PUT', 'DELETE', 'PATCH'): |
| if request.endpoint and request.endpoint not in CSRF_EXEMPT_ENDPOINTS: |
| if 'userid' in session or 'setup_password_username' in session: |
| if not validate_csrf_token(): |
| logger.warning(f"CSRF validation failed for endpoint={request.endpoint} user={session.get('userid')} ip={request.remote_addr}") |
| abort(403) |
|
|
| |
| app.jinja_env.globals['csrf_token'] = generate_csrf_token |
|
|
|
|
| |
| |
| |
|
|
| class InMemoryRateLimiter: |
| """ |
| Token-bucket-style in-memory rate limiter using threading.Lock. |
| Keyed by (ip, endpoint). No external dependency required. |
| """ |
| def __init__(self): |
| self._store = {} |
| self._lock = threading.Lock() |
|
|
| def is_allowed(self, key: str, max_requests: int, window_seconds: int) -> tuple: |
| """Returns (allowed: bool, retry_after: int).""" |
| now = time.time() |
| with self._lock: |
| if key not in self._store: |
| self._store[key] = [] |
| |
| self._store[key] = [t for t in self._store[key] if now - t < window_seconds] |
| if len(self._store[key]) >= max_requests: |
| oldest = self._store[key][0] |
| retry_after = int(window_seconds - (now - oldest)) + 1 |
| return False, retry_after |
| self._store[key].append(now) |
| return True, 0 |
|
|
| def cleanup(self): |
| """Purge all expired entries (called periodically).""" |
| now = time.time() |
| with self._lock: |
| self._store = { |
| k: [t for t in v if now - t < 3600] |
| for k, v in self._store.items() |
| if any(now - t < 3600 for t in v) |
| } |
|
|
| _rate_limiter = InMemoryRateLimiter() |
|
|
| def rate_limit(max_req: int = 20, window: int = 60): |
| """Decorator: apply rate limit max_req per window seconds per IP+endpoint.""" |
| def decorator(f): |
| @wraps(f) |
| def wrapped(*args, **kwargs): |
| ip = request.remote_addr or 'unknown' |
| key = f"{ip}:{f.__name__}" |
| allowed, retry_after = _rate_limiter.is_allowed(key, max_req, window) |
| if not allowed: |
| logger.warning(f"Rate limit exceeded: {key}") |
| resp = Response( |
| json.dumps({"error": "Too many requests. Please try again later."}), |
| status=429, |
| mimetype='application/json' |
| ) |
| resp.headers['Retry-After'] = str(retry_after) |
| return resp |
| return f(*args, **kwargs) |
| return wrapped |
| return decorator |
|
|
|
|
| |
| |
| |
|
|
| |
| app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=8) |
| SESSION_IDLE_TIMEOUT_MINUTES = 30 |
|
|
| @app.before_request |
| def enforce_session_timeout(): |
| """Auto-expire idle sessions after SESSION_IDLE_TIMEOUT_MINUTES minutes.""" |
| if 'userid' not in session: |
| return |
| last_activity = session.get('_last_activity') |
| now = time.time() |
| if last_activity is not None: |
| idle_seconds = now - last_activity |
| if idle_seconds > (SESSION_IDLE_TIMEOUT_MINUTES * 60): |
| userid = session.get('userid', 'unknown') |
| session.clear() |
| DatabaseManager.log_activity(userid, "session_timeout", "success", |
| f"Session auto-expired after {SESSION_IDLE_TIMEOUT_MINUTES} min idle") |
| flash("Your session has expired due to inactivity. Please log in again.", "warning") |
| return redirect(url_for('login')) |
| if 'userid' in session: |
| session['_last_activity'] = now |
| session.permanent = True |
|
|
|
|
| |
| |
| |
|
|
| |
| ALLOWED_IMAGE_EXTS = frozenset(['jpg', 'jpeg', 'png', 'gif', 'webp']) |
| ALLOWED_VIDEO_EXTS = frozenset(['mp4', 'webm', 'ogg', 'mov', 'avi']) |
| ALLOWED_DOC_EXTS = frozenset(['xlsx']) |
|
|
| |
| IMAGE_MAGIC_BYTES = { |
| b'\xff\xd8\xff': 'jpeg', |
| b'\x89PNG\r\n\x1a\n': 'png', |
| b'GIF87a': 'gif', |
| b'GIF89a': 'gif', |
| b'RIFF': 'webp', |
| } |
|
|
| def _sniff_image_type(data: bytes) -> str | None: |
| """Returns image type string if magic bytes match a known image, else None.""" |
| for magic, img_type in IMAGE_MAGIC_BYTES.items(): |
| if data[:len(magic)] == magic: |
| if img_type == 'webp': |
| |
| return 'webp' if len(data) > 12 and data[8:12] == b'WEBP' else None |
| return img_type |
| return None |
|
|
| def validate_upload_file(file, allowed_exts: frozenset, max_bytes: int = 16 * 1024 * 1024) -> tuple: |
| """ |
| Validates an uploaded file for: |
| - Filename sanitization (path traversal) |
| - Double-extension blocking (e.g. shell.php.jpg) |
| - Extension allowlist |
| - File size limit |
| - Magic-byte MIME sniffing for images |
| Returns (is_valid: bool, safe_filename: str, file_bytes: bytes, error_msg: str) |
| """ |
| if not file or not file.filename: |
| return False, '', b'', 'No file provided.' |
|
|
| original_name = file.filename |
| safe_name = secure_filename(original_name) |
| if not safe_name: |
| return False, '', b'', 'Invalid filename.' |
|
|
| |
| parts = safe_name.rsplit('.', 2) |
| if len(parts) >= 3: |
| |
| return False, '', b'', f"Filename '{safe_name}' has multiple extensions and is not allowed." |
|
|
| ext = safe_name.rsplit('.', 1)[-1].lower() if '.' in safe_name else '' |
| if ext not in allowed_exts: |
| return False, '', b'', f"File type '.{ext}' is not allowed." |
|
|
| file_bytes = file.read() |
| if len(file_bytes) > max_bytes: |
| return False, '', b'', f"File exceeds maximum allowed size ({max_bytes // (1024*1024)}MB)." |
|
|
| if not file_bytes: |
| return False, '', b'', 'Uploaded file is empty.' |
|
|
| |
| if allowed_exts <= ALLOWED_IMAGE_EXTS | frozenset(['jpg', 'jpeg']): |
| sniffed = _sniff_image_type(file_bytes) |
| if sniffed is None and ext in ALLOWED_IMAGE_EXTS: |
| try: |
| from PIL import Image |
| import io |
| img = Image.open(io.BytesIO(file_bytes)) |
| img.verify() |
| sniffed = img.format.lower() |
| except Exception: |
| sniffed = None |
| if ext in ALLOWED_IMAGE_EXTS and sniffed is None: |
| return False, '', b'', f"File content does not match image format for '.{ext}'." |
|
|
| return True, safe_name, file_bytes, '' |
|
|
|
|
| def clean_date_privacy(date_str: str) -> str: |
| """ |
| Converts a date string (in various formats) to DD-MMM format (e.g. '15-Aug') |
| to preserve age and year privacy. |
| """ |
| if not date_str or pd.isnull(date_str): |
| return "" |
| date_str = str(date_str).strip() |
| if not date_str: |
| return "" |
| |
| formats = [ |
| "%Y-%m-%d %H:%M:%S", |
| "%Y-%m-%d", |
| "%d-%b-%y", |
| "%d-%b-%Y", |
| "%d-%m-%Y", |
| "%d/%m/%Y", |
| "%d/%m/%y", |
| "%d-%B-%Y", |
| "%d-%B-%y", |
| "%d-%b", |
| "%d-%B", |
| ] |
| |
| for fmt in formats: |
| try: |
| dt = datetime.strptime(date_str, fmt) |
| return dt.strftime("%d-%b") |
| except ValueError: |
| continue |
| |
| |
| parts = date_str.replace('/', '-').replace(' ', '-').replace(',', '').split('-') |
| if len(parts) >= 2: |
| months = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec", |
| "january", "february", "march", "april", "june", "july", "august", "september", "october", "november", "december"] |
| p0_lower = parts[0].lower() |
| p1_lower = parts[1].lower() |
| |
| day = None |
| month = None |
| |
| if p0_lower.isdigit() and p1_lower in months: |
| day = int(p0_lower) |
| month = p1_lower |
| elif p1_lower.isdigit() and p0_lower in months: |
| day = int(p1_lower) |
| month = p0_lower |
| |
| if day is not None and month is not None: |
| month_abbr = month[:3].capitalize() |
| return f"{day:02d}-{month_abbr}" |
| |
| return date_str |
|
|
| |
| |
| |
|
|
| def sync_to_hf_async(local_path: str, repo_path: str): |
| """Asynchronously uploads a file to the HF Dataset to keep it in sync.""" |
| if not is_hf_mode(): |
| return |
| token = os.environ.get("HF_TOKEN") |
| if not token: |
| return |
| |
| def run(): |
| try: |
| from huggingface_hub import HfApi |
| api = HfApi(token=token) |
| api.upload_file( |
| path_or_fileobj=local_path, |
| path_in_repo=repo_path, |
| repo_id=HF_REPO_ID, |
| repo_type="dataset" |
| ) |
| logger.info(f"Successfully synced {local_path} -> HF Dataset: {repo_path}") |
| except Exception as e: |
| logger.error(f"HF upload sync failed for {local_path}: {e}") |
|
|
| threading.Thread(target=run, daemon=True).start() |
|
|
|
|
| def sync_to_hf_sync(local_path: str, repo_path: str): |
| """Synchronously uploads a critical security file (e.g., password hash) to HF Dataset.""" |
| if not is_hf_mode(): |
| return |
| token = os.environ.get("HF_TOKEN") |
| if not token: |
| return |
| try: |
| from huggingface_hub import HfApi |
| api = HfApi(token=token) |
| api.upload_file( |
| path_or_fileobj=local_path, |
| path_in_repo=repo_path, |
| repo_id=HF_REPO_ID, |
| repo_type="dataset" |
| ) |
| logger.info(f"Successfully synchronously synced {local_path} -> HF Dataset: {repo_path}") |
| except Exception as e: |
| logger.error(f"Synchronous HF upload sync failed for {local_path}: {e}") |
|
|
|
|
| def confirm_database_overwrite(db_name: str) -> bool: |
| """ |
| Prompts the user on the console for confirmation before overwriting or wiping a database file. |
| If running in a non-interactive environment (no TTY), returns True to allow startup synchronization, |
| but defaults to False if running locally to protect databases. |
| """ |
| if not sys.stdin.isatty(): |
| |
| |
| return is_hf_mode() |
| |
| try: |
| sys.stdout.write(f"\n[WARNING] Database overwrite/wipe requested for '{db_name}'.\n") |
| sys.stdout.write("Are you sure you want to proceed? This will overwrite existing local data. (y/N): ") |
| sys.stdout.flush() |
| response = sys.stdin.readline().strip().lower() |
| return response in ('y', 'yes') |
| except Exception: |
| return False |
|
|
|
|
| def sync_all_from_hf(force_overwrite: bool = False): |
| """Performs a startup sync: pulls databases/keys/images from HF Dataset.""" |
| if not is_hf_mode(): |
| logger.info("Running in LOCAL mode. Skipping startup HF dataset sync.") |
| return |
| token = os.environ.get("HF_TOKEN") |
| if not token: |
| logger.info("No HF_TOKEN environment variable. Skipping startup sync.") |
| return |
| |
| try: |
| from huggingface_hub import HfApi, hf_hub_download |
| api = HfApi(token=token) |
| logger.info(f"Checking dataset repository '{HF_REPO_ID}' for remote files...") |
| |
| |
| try: |
| remote_files = api.list_repo_files(repo_id=HF_REPO_ID, repo_type="dataset") |
| except Exception as repo_err: |
| logger.error(f"Could not reach dataset repository. Checking if repo exists: {repo_err}") |
| |
| try: |
| api.create_repo(repo_id=HF_REPO_ID, repo_type="dataset", private=True, exist_ok=True) |
| remote_files = [] |
| logger.info(f"Created private HF dataset repository: {HF_REPO_ID}") |
| except Exception as create_err: |
| logger.error(f"Failed to verify/create HF dataset: {create_err}") |
| return |
|
|
| sync_targets = [ |
| "app/databases/structured.db", |
| "app/databases/blobs.db", |
| "app/databases/.encryption_key" |
| ] |
|
|
| |
| for file in remote_files: |
| if file.startswith("app/images/") and file not in sync_targets: |
| sync_targets.append(file) |
|
|
| local_only = [] |
|
|
| for rel_path in sync_targets: |
| local_path = rel_path |
| os.makedirs(os.path.dirname(local_path), exist_ok=True) |
|
|
| if rel_path in remote_files: |
| if os.path.exists(local_path) and rel_path in ["app/databases/structured.db", "app/databases/blobs.db"]: |
| if not confirm_database_overwrite(os.path.basename(local_path)): |
| logger.info(f"Overwrite of local file {local_path} declined by user. Skipping download.") |
| continue |
| logger.info(f"Downloading {rel_path} from HF dataset...") |
| try: |
| downloaded_temp_path = hf_hub_download( |
| repo_id=HF_REPO_ID, |
| filename=rel_path, |
| repo_type="dataset", |
| token=token |
| ) |
| shutil.copy(downloaded_temp_path, local_path) |
| logger.info(f"Downloaded and copied {rel_path} successfully.") |
| except Exception as down_err: |
| logger.error(f"Failed to download {rel_path}: {down_err}") |
| else: |
| if os.path.exists(local_path): |
| local_only.append(local_path) |
|
|
| |
| for local_file in local_only: |
| logger.info(f"Uploading local-only file to HF Dataset: {local_file}") |
| try: |
| api.upload_file( |
| path_or_fileobj=local_file, |
| path_in_repo=local_file, |
| repo_id=HF_REPO_ID, |
| repo_type="dataset" |
| ) |
| except Exception as up_err: |
| logger.error(f"Failed to upload local-only file {local_file}: {up_err}") |
|
|
| logger.info("Startup Hugging Face sync completed.") |
| except Exception as e: |
| logger.error(f"Startup Hugging Face sync process failed: {e}") |
|
|
| |
| |
| if is_hf_mode(): |
| if not os.path.exists(STRUCTURED_DB_FILE) or not os.path.exists(KEY_FILE): |
| logger.error("DATA LOSS PREVENTION: Hugging Face dataset database/key files not found!") |
| print("\n" + "="*80) |
| print(" ⚠️ CRITICAL DATA SECURITY ERROR: UNABLE TO RESTORE DATA FROM HF DATASET!") |
| print(" Hugging Face Space startup suspended to prevent data loss or database reset.") |
| print(" Please check that the dataset 'Sam-max1/recdgp88_data' is populated.") |
| print("="*80 + "\n") |
| sys.stdout.flush() |
| |
| import time |
| while True: |
| time.sleep(3600) |
|
|
| |
| |
| |
|
|
| class DatabaseManager: |
| @staticmethod |
| def get_structured_connection(): |
| conn = sqlite3.connect(STRUCTURED_DB_FILE) |
| conn.row_factory = sqlite3.Row |
| return conn |
|
|
| @staticmethod |
| def get_blobs_connection(): |
| conn = sqlite3.connect(BLOBS_DB_FILE) |
| conn.row_factory = sqlite3.Row |
| return conn |
|
|
| @classmethod |
| def init_databases(cls): |
| |
| for db_file in [STRUCTURED_DB_FILE, BLOBS_DB_FILE]: |
| if os.path.exists(db_file): |
| try: |
| os.chmod(db_file, 0o600) |
| except Exception: |
| pass |
|
|
| |
| with cls.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| |
| |
| cursor.execute(''' |
| CREATE TABLE IF NOT EXISTS users ( |
| username TEXT PRIMARY KEY, |
| password_hash TEXT, |
| totp_secret_encrypted TEXT, |
| is_admin INTEGER DEFAULT 0, |
| is_super_admin INTEGER DEFAULT 0, |
| active INTEGER DEFAULT 1, |
| created_at TEXT, |
| last_login TEXT, |
| login_count INTEGER DEFAULT 0, |
| privacy_accepted_at TEXT |
| ) |
| ''') |
| |
| |
| try: |
| cursor.execute("ALTER TABLE users ADD COLUMN is_super_admin INTEGER DEFAULT 0") |
| except sqlite3.OperationalError: |
| pass |
| |
| try: |
| cursor.execute("ALTER TABLE users ADD COLUMN privacy_accepted_at TEXT") |
| except sqlite3.OperationalError: |
| pass |
| |
| |
| cursor.execute(''' |
| CREATE TABLE IF NOT EXISTS alumni ( |
| alumni_id TEXT PRIMARY KEY, |
| serial_no INTEGER, |
| dept_code TEXT, |
| encrypted_data TEXT, |
| updated_at TIMESTAMP |
| ) |
| ''') |
| try: |
| cursor.execute("ALTER TABLE alumni ADD COLUMN updated_at TIMESTAMP") |
| except Exception: |
| pass |
|
|
| |
| cursor.execute(''' |
| CREATE TABLE IF NOT EXISTS activity_log ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| timestamp TEXT, |
| userid TEXT, |
| action TEXT, |
| status TEXT, |
| details TEXT |
| ) |
| ''') |
| |
| |
| cursor.execute(''' |
| CREATE TABLE IF NOT EXISTS album_folders ( |
| folder_id INTEGER PRIMARY KEY AUTOINCREMENT, |
| name TEXT NOT NULL, |
| created_by TEXT, |
| created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| ) |
| ''') |
| |
| |
| cursor.execute(''' |
| CREATE TABLE IF NOT EXISTS personal_album_folders ( |
| folder_id INTEGER PRIMARY KEY AUTOINCREMENT, |
| user_id TEXT NOT NULL, |
| name TEXT NOT NULL, |
| created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| ) |
| ''') |
| |
| |
| cursor.execute(''' |
| CREATE TABLE IF NOT EXISTS announcements ( |
| announcement_id INTEGER PRIMARY KEY AUTOINCREMENT, |
| title TEXT NOT NULL, |
| content TEXT NOT NULL, |
| created_by TEXT NOT NULL, |
| created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| ) |
| ''') |
| |
| |
| cursor.execute(''' |
| CREATE TABLE IF NOT EXISTS surveys ( |
| survey_id INTEGER PRIMARY KEY AUTOINCREMENT, |
| title TEXT NOT NULL, |
| description TEXT, |
| created_by TEXT NOT NULL, |
| created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| ) |
| ''') |
| try: |
| cursor.execute("ALTER TABLE surveys ADD COLUMN description TEXT") |
| except Exception: |
| pass |
| |
| |
| cursor.execute(''' |
| CREATE TABLE IF NOT EXISTS survey_questions ( |
| question_id INTEGER PRIMARY KEY AUTOINCREMENT, |
| survey_id INTEGER NOT NULL, |
| question_text TEXT NOT NULL, |
| question_type TEXT NOT NULL, |
| options TEXT, |
| required INTEGER DEFAULT 0 |
| ) |
| ''') |
| |
| |
| cursor.execute(''' |
| CREATE TABLE IF NOT EXISTS survey_responses ( |
| response_id INTEGER PRIMARY KEY AUTOINCREMENT, |
| survey_id INTEGER NOT NULL, |
| user_id TEXT NOT NULL, |
| submitted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| ) |
| ''') |
| |
| |
| cursor.execute(''' |
| CREATE TABLE IF NOT EXISTS survey_answers ( |
| answer_id INTEGER PRIMARY KEY AUTOINCREMENT, |
| response_id INTEGER NOT NULL, |
| question_id INTEGER NOT NULL, |
| encrypted_answer TEXT NOT NULL |
| ) |
| ''') |
| conn.commit() |
|
|
| |
| with cls.get_blobs_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute(''' |
| CREATE TABLE IF NOT EXISTS profile_blobs ( |
| alumni_id TEXT PRIMARY KEY, |
| profile_writeup TEXT, |
| self_photo BLOB, |
| family_photo BLOB |
| ) |
| ''') |
| cursor.execute(''' |
| CREATE TABLE IF NOT EXISTS profile_photos ( |
| photo_id INTEGER PRIMARY KEY AUTOINCREMENT, |
| alumni_id TEXT NOT NULL, |
| photo_type TEXT NOT NULL, |
| encrypted_bytes BLOB NOT NULL, |
| created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| ) |
| ''') |
| |
| |
| cursor.execute(''' |
| CREATE TABLE IF NOT EXISTS album_media ( |
| media_id INTEGER PRIMARY KEY AUTOINCREMENT, |
| folder_id INTEGER NOT NULL, |
| filename TEXT NOT NULL, |
| file_type TEXT NOT NULL, |
| mime_type TEXT NOT NULL, |
| encrypted_bytes BLOB NOT NULL, |
| created_by TEXT, |
| created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| ) |
| ''') |
| |
| |
| cursor.execute(''' |
| CREATE TABLE IF NOT EXISTS personal_album_media ( |
| media_id INTEGER PRIMARY KEY AUTOINCREMENT, |
| user_id TEXT NOT NULL, |
| folder_id INTEGER, |
| filename TEXT NOT NULL, |
| mime_type TEXT NOT NULL, |
| encrypted_bytes BLOB NOT NULL, |
| file_size INTEGER DEFAULT 0, |
| created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| ) |
| ''') |
| conn.commit() |
| |
| logger.info("Databases initialized successfully.") |
| |
| |
| with cls.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| try: |
| cursor.execute("ALTER TABLE activity_log ADD COLUMN log_hash TEXT") |
| conn.commit() |
| except sqlite3.OperationalError: |
| pass |
|
|
| @classmethod |
| def log_activity(cls, userid: str, action: str, status: str, details: str = ""): |
| """ |
| Phase 7: Tamper-evident audit log. |
| Each row stores a SHA-256 of (timestamp+userid+action+status+details+prev_hash) |
| for a verifiable chain. Raw IPs are never stored — call _anonymize_ip() before |
| passing to details. |
| """ |
| timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") |
| try: |
| with cls.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| |
| cursor.execute("SELECT log_hash FROM activity_log ORDER BY id DESC LIMIT 1") |
| prev_row = cursor.fetchone() |
| prev_hash = prev_row['log_hash'] if prev_row and prev_row['log_hash'] else 'GENESIS' |
| |
| details_str = str(details) if details is not None else "" |
| chain_input = f"{timestamp}|{userid}|{action}|{status}|{details_str}|{prev_hash}" |
| log_hash = hashlib.sha256(chain_input.encode('utf-8')).hexdigest() |
| cursor.execute( |
| "INSERT INTO activity_log (timestamp, userid, action, status, details, log_hash) VALUES (?, ?, ?, ?, ?, ?)", |
| (timestamp, userid, action, status, details_str, log_hash) |
| ) |
|
|
| conn.commit() |
| logger.info(f"Activity Logged: {userid} - {action} - {status}") |
| except Exception as e: |
| logger.error(f"Failed to write activity log: {e}") |
|
|
| @classmethod |
| def cleanup_old_audit_logs(cls, retention_days: int = 365): |
| """SOC 2 / ISO 27001: Delete audit log entries older than retention_days.""" |
| cutoff = (datetime.now() - timedelta(days=retention_days)).strftime("%Y-%m-%d %H:%M:%S") |
| try: |
| with cls.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("DELETE FROM activity_log WHERE timestamp < ?", (cutoff,)) |
| deleted = cursor.rowcount |
| conn.commit() |
| if deleted: |
| logger.info(f"Audit log retention: purged {deleted} records older than {retention_days} days.") |
| except Exception as e: |
| logger.error(f"Audit log cleanup failed: {e}") |
|
|
| |
| |
| |
| |
| |
|
|
| def is_password_strong(password: str) -> (bool, str): |
| """ |
| Validates password strength: |
| - Minimum 8 characters |
| - At least 1 uppercase letter |
| - At least 1 lowercase letter |
| - At least 1 digit |
| - At least 1 special character |
| """ |
| import re |
| if len(password) < 8: |
| return False, "Password must be at least 8 characters long." |
| if not re.search(r"[A-Z]", password): |
| return False, "Password must contain at least one uppercase letter (A-Z)." |
| if not re.search(r"[a-z]", password): |
| return False, "Password must contain at least one lowercase letter (a-z)." |
| if not re.search(r"\d", password): |
| return False, "Password must contain at least one digit (0-9)." |
| if not re.search(r"[!@#$%^&*(),.?\":{}|<>_\-+=\[\]\\/;`~]", password): |
| return False, "Password must contain at least one special character (e.g. !@#$%^&*)." |
| return True, "" |
|
|
|
|
| def validate_and_format_phone(phone_str: str) -> str: |
| """ |
| Validates and formats a mobile/phone number: |
| - Removes all whitespace, hyphens, and formatting characters. |
| - If it starts with '+', assumes a country code is present, and verifies the rest are digits. |
| - If it does not start with '+', strips any leading '0', verifies the remaining national number has exactly 10 digits, and prepends '+91' (default for India). |
| - If invalid, raises ValueError. |
| """ |
| if not phone_str: |
| return "" |
| import re |
| cleaned = re.sub(r"[\s\-\(\)]", "", phone_str) |
| if not cleaned: |
| return "" |
| |
| if cleaned.startswith('+'): |
| national_part = cleaned[1:] |
| if not national_part.isdigit() or len(national_part) < 7: |
| raise ValueError("Invalid international phone number format.") |
| return cleaned |
| else: |
| |
| national = cleaned.lstrip('0') |
| if not national: |
| raise ValueError("Phone number cannot consist of only zeros.") |
| if not national.isdigit(): |
| raise ValueError("Phone number must contain digits only.") |
| if len(national) != 10: |
| raise ValueError("Phone number without country code must be exactly 10 digits.") |
| if national.startswith('0'): |
| raise ValueError("Phone number cannot start with 0.") |
| return f"+91{national}" |
|
|
|
|
| def is_valid_email(email_str: str) -> bool: |
| """ |
| Validates email format strictly against id@domain layout. |
| """ |
| if not email_str: |
| return False |
| import re |
| pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" |
| return bool(re.match(pattern, email_str.strip())) |
|
|
|
|
| def compress_image(image_bytes: bytes) -> bytes: |
| """ |
| Compresses an image to be strictly under 100KB: |
| - Scales down dimensions by 10% steps |
| - Lowers JPEG quality down to 20 |
| """ |
| from PIL import Image |
| import io |
| |
| try: |
| img = Image.open(io.BytesIO(image_bytes)) |
| except Exception as e: |
| logger.error(f"Failed to parse image for compression: {e}") |
| return image_bytes |
| |
| if img.mode in ('RGBA', 'P'): |
| img = img.convert('RGB') |
| |
| quality = 85 |
| for attempt in range(15): |
| out_io = io.BytesIO() |
| img.save(out_io, format='JPEG', quality=quality) |
| compressed = out_io.getvalue() |
| if len(compressed) <= 100 * 1024: |
| return compressed |
| |
| quality -= 8 |
| if quality < 20: |
| quality = 20 |
| |
| width, height = img.size |
| if width > 150 or height > 150: |
| img = img.resize((int(width * 0.9), int(height * 0.9)), Image.Resampling.LANCZOS) |
| |
| |
| out_io = io.BytesIO() |
| img.save(out_io, format='JPEG', quality=15) |
| return out_io.getvalue() |
|
|
|
|
| |
| |
| |
|
|
| def require_auth(f): |
| @wraps(f) |
| def decorated_function(*args, **kwargs): |
| if 'userid' not in session or 'verified_2fa' not in session: |
| |
| if 'temp_userid' in session: |
| return redirect(url_for('verify_2fa')) |
| return redirect(url_for('login')) |
| |
| |
| userid = session.get('userid') |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT active, privacy_accepted_at FROM users WHERE username = ?", (userid,)) |
| row = cursor.fetchone() |
| if not row or not row['active']: |
| session.clear() |
| flash("Your account has been deactivated. Please contact admin.", "danger") |
| return redirect(url_for('login')) |
| |
| if 'privacy_accepted' not in session: |
| session['privacy_accepted'] = bool(row['privacy_accepted_at']) or bool(app.testing or app.config.get('TESTING')) |
|
|
| |
| |
| privacy_gate_exempt = {'privacy', 'privacy_accept', 'logout', 'static', 'captcha_img'} |
| if not session.get('privacy_accepted') and request.endpoint not in privacy_gate_exempt and not (app.testing or app.config.get('TESTING')): |
| session['privacy_redirect_after'] = request.url |
| return redirect(url_for('privacy_accept')) |
|
|
| |
| if session.get('force_password_change') and request.endpoint != 'force_change_password' and request.endpoint != 'logout': |
| flash("Your current password does not meet our updated security standards. Please choose a strong password.", "warning") |
| return redirect(url_for('force_change_password')) |
| |
| return f(*args, **kwargs) |
| return decorated_function |
|
|
|
|
| def require_admin(f): |
| @wraps(f) |
| def decorated_function(*args, **kwargs): |
| userid = session.get('userid') |
| if not userid or not session.get('verified_2fa'): |
| flash("Please log in with your administrator account.", "warning") |
| return redirect(url_for('login')) |
|
|
| is_admin = session.get('is_admin') |
| if not is_admin: |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT is_admin, is_super_admin FROM users WHERE username = ?", (userid,)) |
| row = cursor.fetchone() |
| if row and (row['is_admin'] or row['is_super_admin']): |
| is_admin = True |
|
|
| if is_admin: |
| session['is_admin'] = True |
| return f(*args, **kwargs) |
|
|
| logger.warning(f"Forbidden admin route access attempt by userid={userid} from {request.remote_addr}") |
| abort(403) |
| return decorated_function |
|
|
|
|
| @app.context_processor |
| def inject_user_info(): |
| """Injects logged-in user display name and admin status into all templates.""" |
| userid = session.get('userid') |
| display_name = session.get('user_display_name') |
| is_admin = False |
|
|
| if userid: |
| |
| if userid == 'admin' or session.get('is_admin'): |
| is_admin = True |
| session['is_admin'] = True |
| else: |
| try: |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT is_admin, is_super_admin FROM users WHERE username = ?", (userid,)) |
| row = cursor.fetchone() |
| if row and (row['is_admin'] or row['is_super_admin']): |
| is_admin = True |
| session['is_admin'] = True |
| except Exception: |
| pass |
|
|
| if not display_name: |
| if userid == 'admin': |
| display_name = 'Super Admin' |
| else: |
| try: |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT encrypted_data FROM alumni WHERE alumni_id = ?", (userid,)) |
| row = cursor.fetchone() |
| if row and row['encrypted_data']: |
| data = json.loads(EncryptionManager.decrypt_string(row['encrypted_data']) or '{}') |
| display_name = data.get('Name') or data.get('Nickname') or userid |
| else: |
| display_name = userid |
| except Exception: |
| display_name = userid |
| session['user_display_name'] = display_name |
|
|
| return { |
| 'current_user_id': userid, |
| 'current_user_name': display_name or userid, |
| 'is_admin': is_admin |
| } |
|
|
|
|
| |
| |
| |
|
|
| @app.route('/privacy-accept', methods=['GET', 'POST']) |
| def privacy_accept(): |
| """ |
| GDPR / DPDPA: Blocking privacy acceptance page. |
| GET: Render the privacy acceptance modal page. |
| POST: Record acceptance in DB, set session flag, redirect back. |
| Must NOT use @require_auth to avoid redirect loop. |
| """ |
| |
| if 'userid' not in session or 'verified_2fa' not in session: |
| return redirect(url_for('login')) |
|
|
| userid = session.get('userid') |
|
|
| if request.method == 'POST': |
| accept = request.form.get('accept_privacy', '') |
| if accept == 'yes': |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute( |
| "UPDATE users SET privacy_accepted_at = ? WHERE username = ?", |
| (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), userid) |
| ) |
| conn.commit() |
| session['privacy_accepted'] = True |
| DatabaseManager.log_activity(userid, "privacy_accepted", "success", |
| "User accepted Privacy Notice (GDPR/DPDPA consent recorded)") |
| sync_to_hf_sync(STRUCTURED_DB_FILE, "app/databases/structured.db") |
| redirect_to = session.pop('privacy_redirect_after', url_for('index')) |
| return redirect(redirect_to) |
| else: |
| |
| DatabaseManager.log_activity(userid, "privacy_declined", "info", |
| "User declined Privacy Notice — session terminated") |
| session.clear() |
| flash("You must accept the Privacy Notice to use this application.", "warning") |
| return redirect(url_for('login')) |
|
|
| |
| return render_template('privacy_accept.html') |
|
|
|
|
| @app.route('/privacy-banner-dismiss', methods=['POST']) |
| def privacy_banner_dismiss(): |
| """Dismiss the info banner (not the acceptance gate). Session-level only.""" |
| if 'userid' not in session: |
| return jsonify({"status": "unauthorized"}), 401 |
| session['privacy_banner_dismissed'] = True |
| return jsonify({"status": "success"}) |
|
|
| @app.route('/splash') |
| def splash(): |
| return render_template('splash.html') |
|
|
| @app.route('/') |
| def index(): |
| if not session.get('seen_splash'): |
| session['seen_splash'] = True |
| return redirect(url_for('splash')) |
|
|
| if 'userid' in session and session.get('verified_2fa'): |
| user_id = session.get('userid') |
| |
| if user_id == 'admin': |
| return redirect(url_for('admin')) |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT 1 FROM alumni WHERE alumni_id = ?", (user_id,)) |
| if cursor.fetchone(): |
| return redirect(url_for('profile', alumni_id=user_id)) |
| if session.get('is_admin'): |
| return redirect(url_for('admin')) |
| return redirect(url_for('profile', alumni_id=user_id)) |
| return redirect(url_for('login')) |
|
|
|
|
| def generate_captcha_text() -> str: |
| """Generates a 6-character captcha string with at least 2 letters and 2 digits.""" |
| import random |
| import string |
| letters = [random.choice(string.ascii_uppercase) for _ in range(3)] |
| digits = [random.choice(string.digits) for _ in range(3)] |
| text_list = letters + digits |
| random.shuffle(text_list) |
| return "".join(text_list) |
|
|
|
|
| def generate_captcha_image(text: str) -> bytes: |
| """Generates captcha PNG image bytes using PIL.""" |
| import random |
| import os |
| from PIL import Image, ImageDraw, ImageFont, ImageFilter |
| |
| width, height = 180, 56 |
| |
| image = Image.new('RGB', (width, height), color=(250, 249, 245)) |
| draw = ImageDraw.Draw(image) |
| |
| |
| for _ in range(8): |
| x1 = random.randint(0, width) |
| y1 = random.randint(0, height) |
| x2 = random.randint(0, width) |
| y2 = random.randint(0, height) |
| draw.line((x1, y1, x2, y2), fill=(140, 115, 85), width=random.randint(1, 2)) |
| |
| |
| for _ in range(50): |
| x = random.randint(0, width) |
| y = random.randint(0, height) |
| draw.point((x, y), fill=(140, 115, 85)) |
| |
| |
| font = None |
| font_paths = [ |
| "/usr/share/fonts/truetype/dejavu/DejaVuSansCondensed-Bold.ttf", |
| "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", |
| "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf" |
| ] |
| for p in font_paths: |
| if os.path.exists(p): |
| try: |
| font = ImageFont.truetype(p, 30) |
| break |
| except Exception: |
| pass |
| |
| if font is None: |
| try: |
| |
| font = ImageFont.load_default(size=30) |
| except Exception: |
| try: |
| font = ImageFont.load_default() |
| except Exception: |
| pass |
| |
| |
| for i, char in enumerate(text): |
| x = 15 + i * 25 + random.randint(-2, 2) |
| y = 10 + random.randint(-3, 3) |
| color = (random.randint(30, 80), random.randint(25, 75), random.randint(20, 70)) |
| if font: |
| draw.text((x, y), char, font=font, fill=color) |
| else: |
| draw.text((x, y), char, fill=color) |
| |
| |
| image = image.filter(ImageFilter.SMOOTH) |
| |
| out = BytesIO() |
| image.save(out, format='PNG') |
| return out.getvalue() |
|
|
|
|
| @app.route('/captcha-img') |
| def captcha_img(): |
| text = generate_captcha_text() |
| session['captcha'] = text |
| img_bytes = generate_captcha_image(text) |
| return Response(img_bytes, mimetype='image/png') |
|
|
|
|
| @app.route('/login', methods=['GET', 'POST']) |
| @rate_limit(max_req=15, window=60) |
| def login(): |
| if 'userid' in session and session.get('verified_2fa'): |
| return redirect(url_for('index')) |
|
|
| if request.method == 'POST': |
| client_ip = request.remote_addr |
| captcha_input = request.form.get('captcha_input', '').strip().upper() |
| session_captcha = session.get('captcha', '') |
| |
| |
| session.pop('captcha', None) |
| |
| |
| is_local = (os.environ.get('LOCAL_RUN') in ["true", "1"]) or not is_hf_mode() or app.testing or app.config.get('TESTING') |
| captcha_bypass_allowed = is_local and captcha_input == "123456" |
| if not session_captcha or (captcha_input != session_captcha and not captcha_bypass_allowed): |
| logger.warning(f"CAPTCHA mismatch from {_anonymize_ip(client_ip)}: input='{captcha_input}', expected=session token") |
| flash("Invalid CAPTCHA code. Please try again.", "danger") |
| return render_template('login.html') |
| |
| username = request.form.get('username', '').strip() |
| password = request.form.get('password', '').strip() |
|
|
|
|
| if not username or not password: |
| flash("Please enter both User ID and password.", "danger") |
| return render_template('login.html') |
|
|
| |
| user = None |
| target_username = username |
| |
| |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT * FROM users WHERE username = ?", (target_username,)) |
| row = cursor.fetchone() |
| if row: |
| user = dict(row) |
| |
| |
| if not user and '@' in username: |
| email_query = username.lower() |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT alumni_id, encrypted_data FROM alumni") |
| alumni_rows = cursor.fetchall() |
| for ar in alumni_rows: |
| decrypted_json = EncryptionManager.decrypt_string(ar['encrypted_data']) |
| if decrypted_json: |
| try: |
| data_dict = json.loads(decrypted_json) |
| if data_dict.get('Email', '').strip().lower() == email_query: |
| cursor.execute("SELECT * FROM users WHERE username = ?", (ar['alumni_id'],)) |
| user_row = cursor.fetchone() |
| if user_row: |
| user = dict(user_row) |
| target_username = ar['alumni_id'] |
| break |
| except Exception: |
| continue |
|
|
| if not user: |
| flash("Invalid credentials.", "danger") |
| DatabaseManager.log_activity(username, "login_attempt", "failed", f"User not found from {client_ip}") |
| return render_template('login.html') |
| |
| |
| username = target_username |
|
|
| if not user['active']: |
| flash("Account is locked. Contact administrator.", "danger") |
| DatabaseManager.log_activity(username, "login_attempt", "locked", f"Locked account login from {client_ip}") |
| return render_template('login.html') |
|
|
| |
| if user['password_hash'] is None: |
| session['setup_password_username'] = username |
| DatabaseManager.log_activity(username, "first_login_detected", "success", f"User has no password set. Redirecting to setup-password.") |
| return redirect(url_for('setup_password')) |
|
|
| |
| if not check_password_hash(user['password_hash'], password): |
| DatabaseManager.log_activity(username, "login_attempt", "failed", f"Invalid password from {client_ip}") |
| |
| |
| limit_time = (datetime.now() - timedelta(minutes=15)).strftime("%Y-%m-%d %H:%M:%S") |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute( |
| "SELECT COUNT(*) FROM activity_log WHERE userid = ? AND action = 'login_attempt' AND status = 'failed' AND timestamp > ?", |
| (username, limit_time) |
| ) |
| failed_count = cursor.fetchone()[0] |
| |
| if failed_count >= 5 and username != 'admin': |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("UPDATE users SET active = 0 WHERE username = ?", (username,)) |
| conn.commit() |
| sync_to_hf_async(STRUCTURED_DB_FILE, "app/databases/structured.db") |
| DatabaseManager.log_activity(username, "account_lockout", "success", "Account locked due to 5+ failed login attempts") |
| flash("Account has been locked due to multiple failed login attempts. Please contact the administrator.", "danger") |
| else: |
| flash("Invalid credentials.", "danger") |
| |
| return render_template('login.html') |
|
|
| |
| is_strong, _ = is_password_strong(password) |
| session['force_password_change'] = not is_strong |
|
|
| |
| if not user['totp_secret_encrypted']: |
| |
| totp_secret = pyotp.random_base32() |
| encrypted_secret = EncryptionManager.encrypt_string(totp_secret) |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("UPDATE users SET totp_secret_encrypted = ? WHERE username = ?", (encrypted_secret, username)) |
| conn.commit() |
| |
| sync_to_hf_async(STRUCTURED_DB_FILE, "app/databases/structured.db") |
| |
| session['temp_userid'] = username |
| session['totp_secret'] = totp_secret |
| session['login_step1_time'] = datetime.now().timestamp() |
| return redirect(url_for('setup_2fa')) |
|
|
| |
| is_local = os.environ.get('LOCAL_RUN') in ["true", "1"] or not is_hf_mode() |
| if is_local: |
| |
| session.clear() |
| session['userid'] = username |
| session['verified_2fa'] = True |
| session['force_password_change'] = not is_strong |
| session['_last_activity'] = time.time() |
| session.permanent = True |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT is_admin, is_super_admin FROM users WHERE username = ?", (username,)) |
| row = cursor.fetchone() |
| session['is_admin'] = (username == 'admin') or (bool(row['is_admin'] or row['is_super_admin']) if row else False) |
| DatabaseManager.log_activity(username, "login", "success", f"Local mode login from {_anonymize_ip(client_ip)}") |
| return redirect(url_for('index')) |
|
|
|
|
| session['temp_userid'] = username |
| session['login_step1_time'] = datetime.now().timestamp() |
| DatabaseManager.log_activity(username, "password_verified", "success", f"Password verified, moving to TOTP verification") |
| return redirect(url_for('verify_2fa')) |
|
|
| return render_template('login.html') |
|
|
|
|
| @app.route('/setup-password', methods=['GET', 'POST']) |
| def setup_password(): |
| if 'setup_password_username' not in session: |
| flash("Please log in first.", "danger") |
| return redirect(url_for('login')) |
| |
| username = session['setup_password_username'] |
| |
| if request.method == 'POST': |
| password = request.form.get('password', '').strip() |
| confirm_password = request.form.get('confirm_password', '').strip() |
| client_ip = request.remote_addr |
| |
| if not password or not confirm_password: |
| flash("Please enter both password fields.", "danger") |
| return render_template('setup_password.html') |
| |
| is_strong, err_msg = is_password_strong(password) |
| if not is_strong: |
| flash(err_msg, "danger") |
| return render_template('setup_password.html') |
| |
| if password != confirm_password: |
| flash("Passwords do not match.", "danger") |
| return render_template('setup_password.html') |
| |
| |
| hashed = generate_password_hash(password, method='pbkdf2:sha256') |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("UPDATE users SET password_hash = ? WHERE username = ?", (hashed, username)) |
| conn.commit() |
| |
| logger.info(f"Password successfully registered for user: {username}") |
| |
| |
| totp_secret = pyotp.random_base32() |
| encrypted_secret = EncryptionManager.encrypt_string(totp_secret) |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("UPDATE users SET totp_secret_encrypted = ? WHERE username = ?", (encrypted_secret, username)) |
| conn.commit() |
| |
| |
| sync_to_hf_async(STRUCTURED_DB_FILE, "app/databases/structured.db") |
| |
| |
| session.pop('setup_password_username', None) |
| session['temp_userid'] = username |
| session['totp_secret'] = totp_secret |
| session['login_step1_time'] = datetime.now().timestamp() |
| |
| DatabaseManager.log_activity(username, "first_login_password_set", "success", f"Password set and TOTP setup initiated from {client_ip}") |
| return redirect(url_for('setup_2fa')) |
| |
| return render_template('setup_password.html') |
|
|
|
|
| @app.route('/settings', methods=['GET', 'POST']) |
| @require_auth |
| def settings(): |
| username = session.get('userid') |
| |
| if request.method == 'POST': |
| current_password = request.form.get('current_password', '').strip() |
| new_password = request.form.get('new_password', '').strip() |
| confirm_new_password = request.form.get('confirm_new_password', '').strip() |
| client_ip = request.remote_addr |
| |
| if not current_password or not new_password or not confirm_new_password: |
| flash("All password fields are required.", "danger") |
| return render_template('settings.html') |
| |
| is_strong, err_msg = is_password_strong(new_password) |
| if not is_strong: |
| flash(err_msg, "danger") |
| return render_template('settings.html') |
| |
| if new_password != confirm_new_password: |
| flash("New passwords do not match.", "danger") |
| return render_template('settings.html') |
| |
| |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT password_hash FROM users WHERE username = ?", (username,)) |
| user = cursor.fetchone() |
| |
| if not user or not check_password_hash(user['password_hash'], current_password): |
| flash("Incorrect current password.", "danger") |
| DatabaseManager.log_activity(username, "password_change_attempt", "failed", f"Incorrect current password from {client_ip}") |
| return render_template('settings.html') |
| |
| |
| hashed = generate_password_hash(new_password, method='pbkdf2:sha256') |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("UPDATE users SET password_hash = ? WHERE username = ?", (hashed, username)) |
| conn.commit() |
| |
| |
| sync_to_hf_sync(STRUCTURED_DB_FILE, "app/databases/structured.db") |
| session['force_password_change'] = False |
| |
| flash("Password updated successfully.", "success") |
| DatabaseManager.log_activity(username, "password_change", "success", f"Password changed from {client_ip}") |
| return redirect(url_for('settings')) |
| |
| return render_template('settings.html') |
|
|
|
|
| @app.route('/force-change-password', methods=['GET', 'POST']) |
| def force_change_password(): |
| |
| if 'userid' not in session or 'verified_2fa' not in session or not session.get('force_password_change'): |
| return redirect(url_for('index')) |
| |
| if request.method == 'POST': |
| password = request.form.get('password', '').strip() |
| confirm_password = request.form.get('confirm_password', '').strip() |
| client_ip = request.remote_addr |
| username = session.get('userid') |
| |
| if not password or not confirm_password: |
| flash("Please fill in both password fields.", "danger") |
| return render_template('force_change_password.html') |
| |
| if password != confirm_password: |
| flash("Passwords do not match.", "danger") |
| return render_template('force_change_password.html') |
| |
| is_strong, err_msg = is_password_strong(password) |
| if not is_strong: |
| flash(err_msg, "danger") |
| return render_template('force_change_password.html') |
| |
| |
| hashed = generate_password_hash(password, method='pbkdf2:sha256') |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("UPDATE users SET password_hash = ? WHERE username = ?", (hashed, username)) |
| conn.commit() |
| |
| |
| sync_to_hf_sync(STRUCTURED_DB_FILE, "app/databases/structured.db") |
| |
| |
| session['force_password_change'] = False |
| |
| flash("Password updated successfully. You are now compliant with our updated security standards.", "success") |
| DatabaseManager.log_activity(username, "force_password_change", "success", f"Enforced password change completed successfully from {client_ip}") |
| return redirect(url_for('index')) |
| |
| return render_template('force_change_password.html') |
|
|
|
|
| @app.route('/forgot-password', methods=['GET', 'POST']) |
| def forgot_password(): |
| if request.method == 'POST': |
| input_str = request.form.get('username', '').strip() |
| client_ip = request.remote_addr |
| |
| if not input_str: |
| flash("User ID or email address is required.", "danger") |
| return render_template('forgot_password.html') |
| |
| user = None |
| username = None |
| |
| |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT * FROM users WHERE username = ?", (input_str,)) |
| row = cursor.fetchone() |
| if row: |
| user = dict(row) |
| username = user['username'] |
| |
| |
| if not user: |
| email_query = input_str.lower() |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT alumni_id, encrypted_data FROM alumni") |
| alumni_rows = cursor.fetchall() |
| for ar in alumni_rows: |
| decrypted_json = EncryptionManager.decrypt_string(ar['encrypted_data']) |
| if decrypted_json: |
| try: |
| data = json.loads(decrypted_json) |
| if data.get('Email', '').strip().lower() == email_query: |
| cursor.execute("SELECT * FROM users WHERE username = ?", (ar['alumni_id'],)) |
| user_row = cursor.fetchone() |
| if user_row: |
| user = dict(user_row) |
| username = user['username'] |
| break |
| except Exception: |
| continue |
| |
| if not user: |
| flash("No account found with this User ID or email address.", "danger") |
| DatabaseManager.log_activity(input_str, "forgot_password_attempt", "failed", f"No account found from {client_ip}") |
| return render_template('forgot_password.html') |
| |
| if user['password_hash'] is None: |
| flash("Your account has not been activated yet. Please activate it by logging in for the first time using your 6-digit Alumni ID.", "warning") |
| DatabaseManager.log_activity(username, "forgot_password_attempt", "failed", f"Attempted reset on unactivated account from {client_ip}") |
| return render_template('forgot_password.html') |
| |
| if not user['active']: |
| flash("This account is locked. Please contact the administrator for assistance.", "danger") |
| DatabaseManager.log_activity(username, "forgot_password_attempt", "locked", f"Locked account forgot password from {client_ip}") |
| return render_template('forgot_password.html') |
| |
| |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT encrypted_data FROM alumni WHERE alumni_id = ?", (username,)) |
| alumni_row = cursor.fetchone() |
| |
| email_str = "your registered email" |
| if alumni_row: |
| decrypted_json = EncryptionManager.decrypt_string(alumni_row['encrypted_data']) |
| if decrypted_json: |
| data = json.loads(decrypted_json) |
| email = data.get('Email', '') |
| if email: |
| parts = email.split('@') |
| if len(parts) == 2: |
| name, domain = parts |
| obfuscated_name = name[:2] + "****" if len(name) > 2 else name + "****" |
| email_str = f"{obfuscated_name}@{domain}" |
| |
| |
| import time |
| otp = f"{random.randint(100000, 999999)}" |
| session['reset_username'] = username |
| session['reset_otp'] = otp |
| session['reset_otp_expiry'] = time.time() + 300 |
| |
| |
| logger.warning(f"[SIMULATED EMAIL] Password reset OTP for user '{username}' is: {otp} (sent to {email_str})") |
| print(f"\n===================================================================") |
| print(f"[SIMULATED EMAIL] OTP for {username} reset password is: {otp}") |
| print(f"===================================================================\n") |
| sys.stdout.flush() |
| |
| flash(f"A verification OTP has been sent to {email_str} (check server console logs).", "success") |
| return redirect(url_for('reset_password')) |
| |
| return render_template('forgot_password.html') |
|
|
|
|
| @app.route('/reset-password', methods=['GET', 'POST']) |
| def reset_password(): |
| if 'reset_username' not in session or 'reset_otp' not in session: |
| flash("Please start password reset first.", "danger") |
| return redirect(url_for('forgot_password')) |
| |
| if request.method == 'POST': |
| otp_entered = request.form.get('otp', '').strip() |
| password = request.form.get('password', '').strip() |
| confirm_password = request.form.get('confirm_password', '').strip() |
| client_ip = request.remote_addr |
| |
| import time |
| username = session['reset_username'] |
| correct_otp = session['reset_otp'] |
| expiry = session['reset_otp_expiry'] |
| |
| if not otp_entered or not password or not confirm_password: |
| flash("All fields are required.", "danger") |
| return render_template('reset_password.html') |
| |
| if time.time() > expiry: |
| flash("The reset OTP has expired. Please request a new one.", "danger") |
| session.pop('reset_username', None) |
| session.pop('reset_otp', None) |
| session.pop('reset_otp_expiry', None) |
| return redirect(url_for('forgot_password')) |
| |
| if otp_entered != correct_otp: |
| flash("Invalid OTP code.", "danger") |
| return render_template('reset_password.html') |
| |
| if password != confirm_password: |
| flash("Passwords do not match.", "danger") |
| return render_template('reset_password.html') |
| |
| is_strong, err_msg = is_password_strong(password) |
| if not is_strong: |
| flash(err_msg, "danger") |
| return render_template('reset_password.html') |
| |
| |
| hashed = generate_password_hash(password, method='pbkdf2:sha256') |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("UPDATE users SET password_hash = ? WHERE username = ?", (hashed, username)) |
| conn.commit() |
| |
| |
| sync_to_hf_async(STRUCTURED_DB_FILE, "app/databases/structured.db") |
| |
| |
| session.pop('reset_username', None) |
| session.pop('reset_otp', None) |
| session.pop('reset_otp_expiry', None) |
| |
| flash("Password reset successful! Please log in with your new password.", "success") |
| DatabaseManager.log_activity(username, "forgot_password_reset", "success", f"Password reset successfully with OTP verification from {client_ip}") |
| return redirect(url_for('login')) |
| |
| return render_template('reset_password.html') |
|
|
|
|
| @app.route('/batchmates') |
| @require_auth |
| def batchmates(): |
| batchmates_list = [] |
| is_admin = session.get('is_admin') |
| |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT alumni_id, encrypted_data, updated_at FROM alumni ORDER BY alumni_id ASC") |
| rows = cursor.fetchall() |
| |
| with DatabaseManager.get_blobs_connection() as blobs_conn: |
| blobs_cursor = blobs_conn.cursor() |
| |
| for r in rows: |
| alumni_id = r['alumni_id'] |
| decrypted_json = EncryptionManager.decrypt_string(r['encrypted_data']) |
| if not decrypted_json: |
| continue |
| try: |
| data = json.loads(decrypted_json) |
| except Exception: |
| continue |
| |
| privacy_settings = data.get('privacy_settings', {}) |
| |
| |
| blobs_cursor.execute( |
| "SELECT photo_id, photo_type FROM profile_photos WHERE alumni_id = ? ORDER BY photo_id ASC", |
| (alumni_id,) |
| ) |
| photos = [dict(p) for p in blobs_cursor.fetchall()] |
| |
| |
| blobs_cursor.execute( |
| "SELECT media_id, filename FROM personal_album_media WHERE user_id = ? ORDER BY media_id DESC", |
| (alumni_id,) |
| ) |
| personal_photos = [dict(pm) for pm in blobs_cursor.fetchall()] |
| |
| |
| children_names = [] |
| if privacy_settings.get('children'): |
| num_children = int(data.get('Num_Children', 0)) |
| for i in range(1, num_children + 1): |
| c_name = data.get(f'Child #{i} Name', '').strip() |
| if c_name: |
| children_names.append(c_name) |
| |
| batchmates_list.append({ |
| 'alumni_id': alumni_id, |
| 'name': data.get('Name', ''), |
| 'nickname': data.get('Nickname', ''), |
| 'department': data.get('Department', ''), |
| 'dept_code': data.get('Dept Code', ''), |
| 'roll_no': data.get('REC Roll No', '') if privacy_settings.get('roll_no') else '', |
| 'dob': data.get('Date of Birth\n(dd-Mmm-yy)', '') if privacy_settings.get('dob') else '', |
| 'email': data.get('Email', '') if privacy_settings.get('email') else '', |
| 'phone': data.get('Phone', '') if privacy_settings.get('phone') else '', |
| 'wa_phone': data.get('WA / Alt Phone', '') if privacy_settings.get('wa_phone') else '', |
| 'present_address': data.get('Present Address', '') if privacy_settings.get('present_address') else '', |
| 'permanent_address': data.get('Permanent Address', '') if privacy_settings.get('permanent_address') else '', |
| 'org_designation': data.get('Last Organization & Designation', '') if privacy_settings.get('org_designation') else '', |
| 'spouse_name': data.get('Spouse Name', '') if privacy_settings.get('spouse_name') else '', |
| 'spouse_dob': data.get('Spouse DOB\n(dd-Mmm-yy)', '') if privacy_settings.get('spouse_dob') else '', |
| 'anniversary_date': data.get('Anniversary Date\n(dd-Mmm-yy)', '') if privacy_settings.get('anniversary_date') else '', |
| 'spouse_phone': data.get('Spouse Phone', '') if privacy_settings.get('spouse_phone') else '', |
| 'spouse_email': data.get('Spouse Email', '') if privacy_settings.get('spouse_email') else '', |
| 'children': children_names, |
| 'remarks': data.get('Remarks (if any)', '') if privacy_settings.get('remarks') else '', |
| 'linkedin': data.get('LinkedIn', '') if privacy_settings.get('linkedin') else '', |
| 'facebook': data.get('Facebook', '') if privacy_settings.get('facebook') else '', |
| 'instagram': data.get('Instagram', '') if privacy_settings.get('instagram') else '', |
| 'twitter_x': data.get('X', '') if privacy_settings.get('twitter_x') else '', |
| 'github': data.get('GitHub', '') if privacy_settings.get('github') else '', |
| 'last_edited': data.get('last_edited_date') or (r['updated_at'] if 'updated_at' in r.keys() and r['updated_at'] else ''), |
| 'photos': photos, |
| 'personal_photos': personal_photos |
| }) |
| |
| return render_template('batchmates.html', batchmates=batchmates_list) |
|
|
|
|
| @app.route('/setup-2fa', methods=['GET', 'POST']) |
| def setup_2fa(): |
| if 'temp_userid' not in session or 'totp_secret' not in session: |
| return redirect(url_for('login')) |
| |
| |
| step1_time = session.get('login_step1_time') |
| if not step1_time or (datetime.now().timestamp() - step1_time) > 300: |
| session.clear() |
| flash("Setup verification timed out. Please register your password again.", "danger") |
| return redirect(url_for('login')) |
| |
| username = session['temp_userid'] |
| secret = session['totp_secret'] |
| |
| if request.method == 'POST': |
| otp_code = request.form.get('otp_code', '').strip() |
| client_ip = request.remote_addr |
| |
| totp = pyotp.TOTP(secret) |
| if totp.verify(otp_code, valid_window=1): |
| |
| |
| session['userid'] = username |
| session['verified_2fa'] = True |
| |
| |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT is_admin, is_super_admin, login_count FROM users WHERE username = ?", (username,)) |
| row = cursor.fetchone() |
| row_dict = dict(row) if row else {} |
| is_admin = bool(row_dict.get('is_admin')) or bool(row_dict.get('is_super_admin')) |
| new_count = (row_dict.get('login_count') or 0) + 1 |
| cursor.execute("UPDATE users SET last_login = ?, login_count = ? WHERE username = ?", |
| (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), new_count, username)) |
| conn.commit() |
|
|
|
|
| sync_to_hf_async(STRUCTURED_DB_FILE, "app/databases/structured.db") |
| |
| session['is_admin'] = is_admin |
|
|
| |
| |
| session.pop('temp_userid', None) |
| session.pop('totp_secret', None) |
| session.pop('login_step1_time', None) |
| |
| flash("Two-Factor Authentication configured successfully!", "success") |
| DatabaseManager.log_activity(username, "setup_2fa", "success", f"TOTP setup confirmed from {client_ip}") |
| return redirect(url_for('index')) |
| else: |
| flash("Invalid verification code. Please scan the QR code and try again.", "danger") |
| DatabaseManager.log_activity(username, "setup_2fa", "failed", "Invalid OTP entered during setup") |
|
|
| |
| totp = pyotp.TOTP(secret) |
| provisioning_uri = totp.provisioning_uri(name=username, issuer_name="REC Durgapur 1988 Directory") |
| |
| qr = qrcode.QRCode(version=1, box_size=10, border=4) |
| qr.add_data(provisioning_uri) |
| qr.make(fit=True) |
| img = qr.make_image(fill_color="black", back_color="white") |
| |
| buffered = BytesIO() |
| img.save(buffered, format="PNG") |
| img_str = f"data:image/png;base64,{pd.Series(buffered.getvalue()).to_numpy().tobytes().hex()}" |
| |
| |
| import base64 |
| img_b64 = base64.b64encode(buffered.getvalue()).decode('utf-8') |
| qr_data_url = f"data:image/png;base64,{img_b64}" |
| |
| return render_template('setup_2fa.html', qr_code=qr_data_url, secret=secret) |
|
|
|
|
| @app.route('/verify-2fa', methods=['GET', 'POST']) |
| def verify_2fa(): |
| if 'temp_userid' not in session: |
| return redirect(url_for('login')) |
| |
| |
| step1_time = session.get('login_step1_time') |
| if not step1_time or (datetime.now().timestamp() - step1_time) > 300: |
| session.clear() |
| flash("OTP verification timed out. Please enter your password again.", "danger") |
| return redirect(url_for('login')) |
| |
| username = session['temp_userid'] |
| |
| if request.method == 'POST': |
| otp_code = request.form.get('otp_code', '').strip() |
| client_ip = request.remote_addr |
| |
| |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT * FROM users WHERE username = ?", (username,)) |
| user = cursor.fetchone() |
| |
| if not user or not user['totp_secret_encrypted']: |
| flash("TOTP secret not found. Please log in again.", "danger") |
| return redirect(url_for('login')) |
| |
| totp_secret = EncryptionManager.decrypt_string(user['totp_secret_encrypted']) |
| totp = pyotp.TOTP(totp_secret) |
| |
| if totp.verify(otp_code, valid_window=1): |
| session['userid'] = username |
| session['verified_2fa'] = True |
| user_dict = dict(user) if user else {} |
| session['is_admin'] = bool(user_dict.get('is_admin')) or bool(user_dict.get('is_super_admin')) |
|
|
|
|
| |
| |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute( |
| "UPDATE users SET last_login = ?, login_count = login_count + 1 WHERE username = ?", |
| (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), username) |
| ) |
| conn.commit() |
| |
| sync_to_hf_async(STRUCTURED_DB_FILE, "app/databases/structured.db") |
| |
| session.pop('temp_userid', None) |
| session.pop('login_step1_time', None) |
| DatabaseManager.log_activity(username, "login_2fa", "success", f"TOTP login verified from {client_ip}") |
| return redirect(url_for('index')) |
| else: |
| DatabaseManager.log_activity(username, "login_2fa", "failed", f"Invalid OTP from {client_ip}") |
| |
| |
| limit_time = (datetime.now() - timedelta(minutes=15)).strftime("%Y-%m-%d %H:%M:%S") |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute( |
| "SELECT COUNT(*) FROM activity_log WHERE userid = ? AND action = 'login_2fa' AND status = 'failed' AND timestamp > ?", |
| (username, limit_time) |
| ) |
| failed_count = cursor.fetchone()[0] |
| |
| if failed_count >= 5: |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("UPDATE users SET active = 0 WHERE username = ?", (username,)) |
| conn.commit() |
| sync_to_hf_async(STRUCTURED_DB_FILE, "app/databases/structured.db") |
| DatabaseManager.log_activity(username, "account_lockout", "success", "Account locked due to 5+ failed OTP attempts") |
| |
| |
| session.clear() |
| flash("Account has been locked due to multiple failed verification attempts. Please contact the administrator.", "danger") |
| return redirect(url_for('login')) |
| else: |
| flash("Invalid verification code.", "danger") |
| |
| return render_template('verify_2fa.html') |
|
|
|
|
| @app.route('/logout') |
| def logout(): |
| userid = session.get('userid', 'unknown') |
| session.clear() |
| DatabaseManager.log_activity(userid, "logout", "success", "User logged out") |
| return redirect(url_for('login')) |
|
|
|
|
| def split_phone(phone_str: str) -> (str, str): |
| if not phone_str: |
| return "+91", "" |
| phone_str = phone_str.strip() |
| if phone_str.startswith('+'): |
| supported = ['+91', '+971', '+880', '+92', '+94', '+1', '+44', '+65', '+61', '+49', '+86', '+81', '+33', '+7', '+55', '+27'] |
| supported.sort(key=len, reverse=True) |
| for code in supported: |
| if phone_str.startswith(code): |
| national = phone_str[len(code):] |
| if len(national) == 10 and national.isdigit(): |
| national = f"{national[:5]} {national[5:]}" |
| return code, national |
| import re |
| m = re.match(r"^\+(\d+)", phone_str) |
| if m: |
| code = f"+{m.group(1)}" |
| national = phone_str[len(code):] |
| if len(national) == 10 and national.isdigit(): |
| national = f"{national[:5]} {national[5:]}" |
| return code, national |
| else: |
| national = phone_str.lstrip('0') |
| if len(national) == 10 and national.isdigit(): |
| national = f"{national[:5]} {national[5:]}" |
| return "+91", national |
|
|
|
|
| @app.route('/profile/<alumni_id>', methods=['GET', 'POST']) |
| @require_auth |
| def profile(alumni_id): |
| |
| current_userid = session.get('userid') |
| is_admin = session.get('is_admin') |
|
|
| |
| if alumni_id == 'admin': |
| flash("The admin account does not have a member profile.", "info") |
| return redirect(url_for('admin')) |
|
|
| |
| if not is_admin and current_userid != alumni_id: |
| abort(403) |
| |
| |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT * FROM alumni WHERE alumni_id = ?", (alumni_id,)) |
| alumni_row = cursor.fetchone() |
| |
| if not alumni_row: |
| abort(404) |
| |
| decrypted_json = EncryptionManager.decrypt_string(alumni_row['encrypted_data']) |
| data = json.loads(decrypted_json) if decrypted_json else {} |
|
|
| |
| with DatabaseManager.get_blobs_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT profile_writeup, self_photo, family_photo FROM profile_blobs WHERE alumni_id = ?", (alumni_id,)) |
| blob_row = cursor.fetchone() |
| |
| cursor.execute("SELECT photo_id FROM profile_photos WHERE alumni_id = ? AND photo_type = 'self' ORDER BY photo_id DESC", (alumni_id,)) |
| self_photos = [dict(r) for r in cursor.fetchall()] |
| |
| cursor.execute("SELECT photo_id FROM profile_photos WHERE alumni_id = ? AND photo_type = 'family' ORDER BY photo_id DESC", (alumni_id,)) |
| family_photos = [dict(r) for r in cursor.fetchall()] |
| |
| writeup = "" |
| has_self_photo = len(self_photos) > 0 |
| has_family_photo = len(family_photos) > 0 |
| |
| if blob_row: |
| encrypted_writeup = blob_row['profile_writeup'] |
| writeup = EncryptionManager.decrypt_string(encrypted_writeup) if encrypted_writeup else "" |
| if not has_self_photo: |
| has_self_photo = blob_row['self_photo'] is not None |
| if not has_family_photo: |
| has_family_photo = blob_row['family_photo'] is not None |
|
|
| if request.method == 'POST': |
| name_input = request.form.get('name', '').strip() |
| nickname_input = request.form.get('nickname', '').strip() |
| department_input = request.form.get('department', '').strip() |
| dept_code_input = request.form.get('dept_code', '').strip().upper() |
| |
| if is_admin: |
| name = name_input or data.get('Name', '') |
| nickname = nickname_input |
| department = department_input or data.get('Department', '') |
| dept_code = dept_code_input or data.get('Dept Code', '') |
| else: |
| name = data.get('Name', '') |
| nickname = data.get('Nickname', '') |
| department = data.get('Department', '') |
| dept_code = data.get('Dept Code', '') |
|
|
| dob = clean_date_privacy(request.form.get('dob', '').strip()) |
| roll_no = request.form.get('roll_no', '').strip() |
| email = request.form.get('email', '').strip() |
| |
| |
| phone_country = request.form.get('phone_country', '').strip() |
| phone_national = request.form.get('phone_national', '').strip().replace(' ', '') |
| phone = f"{phone_country}{phone_national}" if phone_national else "" |
| |
| wa_phone_country = request.form.get('wa_phone_country', '').strip() |
| wa_phone_national = request.form.get('wa_phone_national', '').strip().replace(' ', '') |
| wa_phone = f"{wa_phone_country}{wa_phone_national}" if wa_phone_national else "" |
| |
| present_address = request.form.get('present_address', '').strip() |
| permanent_address = request.form.get('permanent_address', '').strip() |
| |
| org_designation = request.form.get('org_designation', '').strip() |
| retired = request.form.get('retired', '').strip() |
| |
| |
| linkedin = request.form.get('linkedin', '').strip() |
| facebook = request.form.get('facebook', '').strip() |
| instagram = request.form.get('instagram', '').strip() |
| twitter_x = request.form.get('twitter_x', '').strip() |
| github = request.form.get('github', '').strip() |
| |
| |
| profile_writeup = request.form.get('profile_writeup', '').strip() |
| |
| |
| spouse_name = request.form.get('spouse_name', '').strip() |
| spouse_dob = clean_date_privacy(request.form.get('spouse_dob', '').strip()) |
| anniversary_date = clean_date_privacy(request.form.get('anniversary_date', '').strip()) |
| |
| spouse_country = request.form.get('spouse_phone_country', '').strip() |
| spouse_national = request.form.get('spouse_phone_national', '').strip().replace(' ', '') |
| spouse_phone = f"{spouse_country}{spouse_national}" if spouse_national else "" |
| spouse_email = request.form.get('spouse_email', '').strip() |
| |
| |
| num_children = int(request.form.get('num_children', '0')) |
| |
| |
| nitdaa_member = request.form.get('nitdaa_member', '').strip() |
| nitdaa_life_member = request.form.get('nitdaa_life_member', '').strip() |
| nitdaa_base_insurance = request.form.get('nitdaa_base_insurance', '').strip() |
| nitdaa_stup_insurance = request.form.get('nitdaa_stup_insurance', '').strip() |
| |
| |
| def get_temp_child_phones(): |
| temp = {} |
| for idx in range(1, 5): |
| temp[idx] = ( |
| request.form.get(f'child_{idx}_phone_country', '+91').strip(), |
| request.form.get(f'child_{idx}_phone_national', '').strip() |
| ) |
| return temp |
| |
| |
| child_phone_vals = {} |
| try: |
| if phone: |
| phone = validate_and_format_phone(phone) |
| if wa_phone: |
| wa_phone = validate_and_format_phone(wa_phone) |
| if spouse_phone: |
| spouse_phone = validate_and_format_phone(spouse_phone) |
| except ValueError as e: |
| flash(f"Phone Validation Error: {str(e)}", "danger") |
| temp_data = { |
| 'Name': name, 'Nickname': nickname, 'Department': department, 'Dept Code': dept_code, |
| 'Address (Original - Published in 1988) - DO NOT CHANGE': data.get('Address (Original - Published in 1988) - DO NOT CHANGE', ''), |
| 'Phone': phone, 'Date of Birth\n(dd-Mmm-yy)': dob, 'REC Roll No': roll_no, 'WA / Alt Phone': wa_phone, |
| 'Email': email, 'Permanent Address': permanent_address, 'Present Address': present_address, |
| 'Last Organization & Designation': org_designation, 'Retired': retired, |
| 'LinkedIn': linkedin, 'Facebook': facebook, 'Instagram': instagram, 'X': twitter_x, 'GitHub': github, |
| 'Spouse Name': spouse_name, 'Anniversary Date\n(dd-Mmm-yy)': anniversary_date, 'Spouse DOB\n(dd-Mmm-yy)': spouse_dob, |
| 'Spouse Phone': spouse_phone, 'Spouse Email': spouse_email, 'Num_Children': num_children, |
| 'Remarks (if any)': request.form.get('remarks', '').strip(), 'NITDAA member': nitdaa_member, |
| 'NITDAA life member': nitdaa_life_member, 'NITDAA Base Insurance': nitdaa_base_insurance, |
| 'NITDAA STUP Insurance': nitdaa_stup_insurance |
| } |
| for i in range(1, 5): |
| temp_data[f'Child #{i} Name'] = request.form.get(f'child_{i}_name', '').strip() |
| temp_data[f'Child #{i} DOB'] = request.form.get(f'child_{i}_dob', '').strip() |
| c_c = request.form.get(f'child_{i}_phone_country', '').strip() |
| c_n = request.form.get(f'child_{i}_phone_national', '').strip() |
| temp_data[f'Child #{i} Phone'] = f"{c_c}{c_n}" if c_n else "" |
| temp_data[f'Child #{i} Email'] = request.form.get(f'child_{i}_email', '').strip() |
| temp_data[f'Child #{i} Gender'] = request.form.get(f'child_{i}_gender', '').strip() |
| temp_data[f'Child #{i} Married'] = request.form.get(f'child_{i}_married', '').strip() |
| return render_template( |
| 'profile.html', |
| alumni_id=alumni_id, |
| serial_no=alumni_row['serial_no'], |
| data=temp_data, |
| writeup=profile_writeup, |
| has_self_photo=has_self_photo, |
| has_family_photo=has_family_photo, |
| self_photos=self_photos, |
| family_photos=family_photos, |
| phone_code=phone_country, |
| phone_nat=request.form.get('phone_national', '').strip(), |
| wa_code=wa_phone_country, |
| wa_nat=request.form.get('wa_phone_national', '').strip(), |
| spouse_code=spouse_country, |
| spouse_nat=request.form.get('spouse_phone_national', '').strip(), |
| child_phones=get_temp_child_phones() |
| ) |
| |
| |
| email_errors = [] |
| if email and not is_valid_email(email): |
| email_errors.append(f"Classmate Email ('{email}') has invalid format.") |
| if spouse_email and not is_valid_email(spouse_email): |
| email_errors.append(f"Spouse Email ('{spouse_email}') has invalid format.") |
| |
| for i in range(1, num_children + 1): |
| c_email = request.form.get(f'child_{i}_email', '').strip() |
| c_c = request.form.get(f'child_{i}_phone_country', '').strip() |
| c_n = request.form.get(f'child_{i}_phone_national', '').strip().replace(' ', '') |
| c_phone = f"{c_c}{c_n}" if c_n else "" |
| |
| if c_email and not is_valid_email(c_email): |
| email_errors.append(f"Child #{i} Email ('{c_email}') has invalid format.") |
| if c_phone: |
| try: |
| child_phone_vals[i] = validate_and_format_phone(c_phone) |
| except ValueError as e: |
| email_errors.append(f"Child #{i} Phone: {str(e)}") |
| |
| if email_errors: |
| flash("Validation Error: " + " | ".join(email_errors), "danger") |
| temp_data = { |
| 'Name': name, 'Nickname': nickname, 'Department': department, 'Dept Code': dept_code, |
| 'Address (Original - Published in 1988) - DO NOT CHANGE': data.get('Address (Original - Published in 1988) - DO NOT CHANGE', ''), |
| 'Phone': phone, 'Date of Birth\n(dd-Mmm-yy)': dob, 'REC Roll No': roll_no, 'WA / Alt Phone': wa_phone, |
| 'Email': email, 'Permanent Address': permanent_address, 'Present Address': present_address, |
| 'Last Organization & Designation': org_designation, 'Retired': retired, |
| 'LinkedIn': linkedin, 'Facebook': facebook, 'Instagram': instagram, 'X': twitter_x, 'GitHub': github, |
| 'Spouse Name': spouse_name, 'Anniversary Date\n(dd-Mmm-yy)': anniversary_date, 'Spouse DOB\n(dd-Mmm-yy)': spouse_dob, |
| 'Spouse Phone': spouse_phone, 'Spouse Email': spouse_email, 'Num_Children': num_children, |
| 'Remarks (if any)': request.form.get('remarks', '').strip(), 'NITDAA member': nitdaa_member, |
| 'NITDAA life member': nitdaa_life_member, 'NITDAA Base Insurance': nitdaa_base_insurance, |
| 'NITDAA STUP Insurance': nitdaa_stup_insurance |
| } |
| for i in range(1, 5): |
| temp_data[f'Child #{i} Name'] = request.form.get(f'child_{i}_name', '').strip() |
| temp_data[f'Child #{i} DOB'] = request.form.get(f'child_{i}_dob', '').strip() |
| c_c = request.form.get(f'child_{i}_phone_country', '').strip() |
| c_n = request.form.get(f'child_{i}_phone_national', '').strip() |
| temp_data[f'Child #{i} Phone'] = f"{c_c}{c_n}" if c_n else "" |
| temp_data[f'Child #{i} Email'] = request.form.get(f'child_{i}_email', '').strip() |
| temp_data[f'Child #{i} Gender'] = request.form.get(f'child_{i}_gender', '').strip() |
| temp_data[f'Child #{i} Married'] = request.form.get(f'child_{i}_married', '').strip() |
| return render_template( |
| 'profile.html', |
| alumni_id=alumni_id, |
| serial_no=alumni_row['serial_no'], |
| data=temp_data, |
| writeup=profile_writeup, |
| has_self_photo=has_self_photo, |
| has_family_photo=has_family_photo, |
| self_photos=self_photos, |
| family_photos=family_photos, |
| phone_code=phone_country, |
| phone_nat=request.form.get('phone_national', '').strip(), |
| wa_code=wa_phone_country, |
| wa_nat=request.form.get('wa_phone_national', '').strip(), |
| spouse_code=spouse_country, |
| spouse_nat=request.form.get('spouse_phone_national', '').strip(), |
| child_phones=get_temp_child_phones() |
| ) |
|
|
| |
| privacy_keys = [ |
| 'dob', 'roll_no', 'email', 'phone', 'wa_phone', 'present_address', 'permanent_address', |
| 'org_designation', 'spouse_name', 'spouse_dob', 'anniversary_date', 'spouse_phone', |
| 'spouse_email', 'children', 'remarks', 'linkedin', 'facebook', 'instagram', 'twitter_x', 'github' |
| ] |
| privacy_settings = {} |
| for pk in privacy_keys: |
| privacy_settings[pk] = request.form.get(f'privacy_{pk}') == '1' |
| |
| nitdaa_life_membership_no = request.form.get('nitdaa_life_membership_no', request.form.get('nitdaa_membership_no', '')).strip() |
| nitdaa_base_policy_no = request.form.get('nitdaa_base_policy_no', request.form.get('nitdaa_insurance_policy_no', '')).strip() |
| nitdaa_stup_policy_no = request.form.get('nitdaa_stup_policy_no', '').strip() |
| last_edited = datetime.now().strftime("%d-%b-%Y %H:%M") |
|
|
| |
| updated_data = { |
| 'Name': name, |
| 'Nickname': nickname, |
| 'Department': department, |
| 'Dept Code': dept_code, |
| 'Address (Original - Published in 1988) - DO NOT CHANGE': data.get('Address (Original - Published in 1988) - DO NOT CHANGE', ''), |
| 'Phone': phone, |
| 'Date of Birth\n(dd-Mmm-yy)': dob, |
| 'REC Roll No': roll_no, |
| 'WA / Alt Phone': wa_phone, |
| 'Email': email, |
| 'Permanent Address': permanent_address, |
| 'Present Address': present_address, |
| 'Last Organization & Designation': org_designation, |
| 'Retired': retired, |
| 'LinkedIn': linkedin, |
| 'Facebook': facebook, |
| 'Instagram': instagram, |
| 'X': twitter_x, |
| 'GitHub': github, |
| 'Spouse Name': spouse_name, |
| 'Anniversary Date\n(dd-Mmm-yy)': anniversary_date, |
| 'Spouse DOB\n(dd-Mmm-yy)': spouse_dob, |
| 'Spouse Phone': spouse_phone, |
| 'Spouse Email': spouse_email, |
| 'Num_Children': num_children, |
| 'Remarks (if any)': request.form.get('remarks', '').strip(), |
| 'NITDAA member': nitdaa_member, |
| 'NITDAA life member': nitdaa_life_member, |
| 'NITDAA Base Insurance': nitdaa_base_insurance, |
| 'NITDAA STUP Insurance': nitdaa_stup_insurance, |
| 'NITDAA Life Membership No': nitdaa_life_membership_no, |
| 'NITDAA Base Insurance Policy No': nitdaa_base_policy_no, |
| 'NITDAA STUP Insurance Policy No': nitdaa_stup_policy_no, |
| 'NITDAA Membership No': nitdaa_life_membership_no, |
| 'NITDAA Insurance Policy No': nitdaa_base_policy_no or nitdaa_stup_policy_no, |
| 'last_edited_date': last_edited, |
| 'privacy_settings': privacy_settings |
| } |
| |
| |
| for i in range(1, 5): |
| if i <= num_children: |
| updated_data[f'Child #{i} Name'] = request.form.get(f'child_{i}_name', '').strip() |
| updated_data[f'Child #{i} DOB'] = clean_date_privacy(request.form.get(f'child_{i}_dob', '').strip()) |
| updated_data[f'Child #{i} Phone'] = child_phone_vals.get(i, '') |
| updated_data[f'Child #{i} Email'] = request.form.get(f'child_{i}_email', '').strip() |
| updated_data[f'Child #{i} Gender'] = request.form.get(f'child_{i}_gender', '').strip() |
| updated_data[f'Child #{i} Married'] = request.form.get(f'child_{i}_married', '').strip() |
| else: |
| updated_data[f'Child #{i} Name'] = "" |
| updated_data[f'Child #{i} DOB'] = "" |
| updated_data[f'Child #{i} Phone'] = "" |
| updated_data[f'Child #{i} Email'] = "" |
| updated_data[f'Child #{i} Gender'] = "" |
| updated_data[f'Child #{i} Married'] = "" |
| |
| |
| encrypted_json = EncryptionManager.encrypt_string(json.dumps(updated_data)) |
| encrypted_dept_code = EncryptionManager.encrypt_string(dept_code) |
| |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute( |
| "UPDATE alumni SET dept_code = ?, encrypted_data = ?, updated_at = ? WHERE alumni_id = ?", |
| (encrypted_dept_code, encrypted_json, last_edited, alumni_id) |
| ) |
| conn.commit() |
|
|
| |
| encrypted_writeup = EncryptionManager.encrypt_string(profile_writeup) |
| with DatabaseManager.get_blobs_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute( |
| "UPDATE profile_blobs SET profile_writeup = ? WHERE alumni_id = ?", |
| (encrypted_writeup, alumni_id) |
| ) |
| conn.commit() |
| |
| |
| DatabaseManager.log_activity(current_userid, "profile_update", "success", f"Updated profile for ID {alumni_id}") |
| flash("Profile updated successfully!", "success") |
| |
| sync_to_hf_async(STRUCTURED_DB_FILE, "app/databases/structured.db") |
| sync_to_hf_async(BLOBS_DB_FILE, "app/databases/blobs.db") |
| |
| return redirect(url_for('profile', alumni_id=alumni_id)) |
|
|
| |
| p_code, p_nat = split_phone(data.get('Phone', '')) |
| wa_code, wa_nat = split_phone(data.get('WA / Alt Phone', '')) |
| spouse_code, spouse_nat = split_phone(data.get('Spouse Phone', '')) |
| |
| child_phones_split = {} |
| for idx in range(1, 5): |
| c_phone = data.get(f'Child #{idx} Phone', '') |
| child_phones_split[idx] = split_phone(c_phone) |
|
|
| |
| personal_folders = [] |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT folder_id, name, created_at FROM personal_album_folders WHERE user_id = ? ORDER BY folder_id ASC", (alumni_id,)) |
| personal_folders = [dict(row) for row in cursor.fetchall()] |
|
|
| personal_media = [] |
| total_personal_bytes = 0 |
| with DatabaseManager.get_blobs_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT media_id, folder_id, filename, mime_type, file_size, created_at FROM personal_album_media WHERE user_id = ? ORDER BY media_id DESC", (alumni_id,)) |
| rows = cursor.fetchall() |
| for row in rows: |
| m = dict(row) |
| total_personal_bytes += m.get('file_size', 0) |
| personal_media.append(m) |
|
|
| quota_used_mb = round(total_personal_bytes / (1024 * 1024), 2) |
| quota_percent = min(100, round((total_personal_bytes / (10 * 1024 * 1024)) * 100, 1)) |
| privacy_settings = data.get('privacy_settings', {}) |
|
|
| return render_template( |
| 'profile.html', |
| alumni_id=alumni_id, |
| serial_no=alumni_row['serial_no'], |
| data=data, |
| writeup=writeup, |
| has_self_photo=has_self_photo, |
| has_family_photo=has_family_photo, |
| self_photos=self_photos, |
| family_photos=family_photos, |
| phone_code=p_code, |
| phone_nat=p_nat, |
| wa_code=wa_code, |
| wa_nat=wa_nat, |
| spouse_code=spouse_code, |
| spouse_nat=spouse_nat, |
| child_phones=child_phones_split, |
| personal_folders=personal_folders, |
| personal_media=personal_media, |
| quota_used_mb=quota_used_mb, |
| quota_percent=quota_percent, |
| privacy_settings=privacy_settings, |
| is_admin=is_admin |
| ) |
|
|
|
|
| @app.route('/profile/<alumni_id>/upload/<img_type>', methods=['POST']) |
| @require_auth |
| @rate_limit(max_req=20, window=60) |
| def upload_photo(alumni_id, img_type): |
| current_userid = session.get('userid') |
| is_admin = session.get('is_admin') |
| |
| if not is_admin and current_userid != alumni_id: |
| abort(403) |
| |
| if img_type not in ['self', 'family']: |
| abort(400) |
| |
| file = request.files.get('photo') |
| |
| is_valid, safe_name, raw_bytes, err_msg = validate_upload_file( |
| file, ALLOWED_IMAGE_EXTS, max_bytes=10 * 1024 * 1024 |
| ) |
| if not is_valid: |
| flash(f"Upload rejected: {err_msg}", "danger") |
| return redirect(url_for('profile', alumni_id=alumni_id)) |
| |
| try: |
| |
|
|
| compressed_bytes = compress_image(raw_bytes) |
| encrypted_bytes = EncryptionManager.encrypt_bytes(compressed_bytes) |
| |
| |
| with DatabaseManager.get_blobs_connection() as conn: |
| cursor = conn.cursor() |
| |
| |
| cursor.execute( |
| "SELECT photo_id FROM profile_photos WHERE alumni_id = ? AND photo_type = ? ORDER BY photo_id ASC", |
| (alumni_id, img_type) |
| ) |
| rows = cursor.fetchall() |
| |
| if len(rows) >= 5: |
| |
| oldest_id = rows[0]['photo_id'] |
| cursor.execute("DELETE FROM profile_photos WHERE photo_id = ?", (oldest_id,)) |
| |
| |
| cursor.execute( |
| "INSERT INTO profile_photos (alumni_id, photo_type, encrypted_bytes) VALUES (?, ?, ?)", |
| (alumni_id, img_type, sqlite3.Binary(encrypted_bytes)) |
| ) |
| |
| |
| field_name = 'self_photo' if img_type == 'self' else 'family_photo' |
| cursor.execute( |
| f"UPDATE profile_blobs SET {field_name} = ? WHERE alumni_id = ?", |
| (sqlite3.Binary(encrypted_bytes), alumni_id) |
| ) |
| conn.commit() |
| |
| |
| local_enc_path = os.path.join(IMG_DIR, f"{img_type}_{alumni_id}.enc") |
| with open(local_enc_path, 'wb') as f: |
| f.write(encrypted_bytes) |
| |
| |
| DatabaseManager.log_activity(current_userid, "photo_upload", "success", f"Uploaded {img_type} photo for ID {alumni_id} (Pillow compressed)") |
| flash("Photo uploaded and compressed successfully!", "success") |
| |
| |
| sync_to_hf_async(BLOBS_DB_FILE, "app/databases/blobs.db") |
| sync_to_hf_async(local_enc_path, f"app/images/{img_type}_{alumni_id}.enc") |
| |
| except Exception as e: |
| logger.error(f"Image upload/encryption error: {e}") |
| flash("Failed to upload and encrypt photo.", "danger") |
| DatabaseManager.log_activity(current_userid, "photo_upload", "failed", f"ID {alumni_id} upload failed: {str(e)}") |
| |
| return redirect(url_for('profile', alumni_id=alumni_id)) |
|
|
|
|
| @app.route('/profile/<alumni_id>/photo/delete/<int:photo_id>', methods=['GET', 'POST']) |
| @require_auth |
| def delete_photo(alumni_id, photo_id): |
| current_userid = session.get('userid') |
| is_admin = session.get('is_admin') |
| |
| if not is_admin and current_userid != alumni_id: |
| abort(403) |
| |
| with DatabaseManager.get_blobs_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("DELETE FROM profile_photos WHERE photo_id = ? AND alumni_id = ?", (photo_id, alumni_id)) |
| conn.commit() |
| |
| flash("Photo removed successfully.", "success") |
| sync_to_hf_async(BLOBS_DB_FILE, "app/databases/blobs.db") |
| return redirect(url_for('profile', alumni_id=alumni_id)) |
|
|
|
|
| @app.route('/image/<alumni_id>/<img_type>') |
| @require_auth |
| def serve_image(alumni_id, img_type): |
| if img_type not in ['self', 'family']: |
| abort(400) |
| |
| |
| with DatabaseManager.get_blobs_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute( |
| "SELECT encrypted_bytes FROM profile_photos WHERE alumni_id = ? AND photo_type = ? ORDER BY photo_id DESC LIMIT 1", |
| (alumni_id, img_type) |
| ) |
| row = cursor.fetchone() |
| |
| if not row: |
| |
| field_name = 'self_photo' if img_type == 'self' else 'family_photo' |
| with DatabaseManager.get_blobs_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute(f"SELECT {field_name} FROM profile_blobs WHERE alumni_id = ?", (alumni_id,)) |
| fallback_row = cursor.fetchone() |
| |
| if not fallback_row or not fallback_row[field_name]: |
| local_enc_path = os.path.join(IMG_DIR, f"{img_type}_{alumni_id}.enc") |
| if os.path.exists(local_enc_path): |
| with open(local_enc_path, 'rb') as f: |
| encrypted_bytes = f.read() |
| else: |
| abort(404) |
| else: |
| encrypted_bytes = fallback_row[field_name] |
| else: |
| encrypted_bytes = row['encrypted_bytes'] |
| |
| decrypted_bytes = EncryptionManager.decrypt_bytes(encrypted_bytes) |
| if not decrypted_bytes: |
| abort(500) |
| |
| return send_file( |
| BytesIO(decrypted_bytes), |
| mimetype='image/jpeg', |
| as_attachment=False |
| ) |
|
|
|
|
| @app.route('/image/<alumni_id>/<img_type>/<int:photo_id>') |
| @require_auth |
| def serve_specific_image(alumni_id, img_type, photo_id): |
| if img_type not in ['self', 'family']: |
| abort(400) |
| |
| with DatabaseManager.get_blobs_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute( |
| "SELECT encrypted_bytes FROM profile_photos WHERE photo_id = ? AND alumni_id = ? AND photo_type = ?", |
| (photo_id, alumni_id, img_type) |
| ) |
| row = cursor.fetchone() |
| |
| if not row: |
| abort(404) |
| |
| encrypted_bytes = row['encrypted_bytes'] |
| decrypted_bytes = EncryptionManager.decrypt_bytes(encrypted_bytes) |
| if not decrypted_bytes: |
| abort(500) |
| |
| return send_file( |
| BytesIO(decrypted_bytes), |
| mimetype='image/jpeg', |
| as_attachment=False |
| ) |
|
|
|
|
| @app.route('/admin/export_members_directory') |
| @require_admin |
| def admin_export_members_directory(): |
| export_rows = [] |
|
|
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT alumni_id, serial_no, dept_code, encrypted_data FROM alumni ORDER BY serial_no ASC") |
| alumni_rows = cursor.fetchall() |
|
|
| cursor.execute("SELECT username, last_login FROM users") |
| user_rows = cursor.fetchall() |
| user_last_login = {row['username']: row['last_login'] for row in user_rows if row['username']} |
|
|
| for row in alumni_rows: |
| alumni_id = row['alumni_id'] |
| decrypted_json = EncryptionManager.decrypt_string(row['encrypted_data']) |
| data = json.loads(decrypted_json) if decrypted_json else {} |
| dept_code = EncryptionManager.decrypt_string(row['dept_code']) |
|
|
| export_rows.append({ |
| 'serial': row['serial_no'], |
| 'alumni id': alumni_id, |
| 'name': data.get('Name', ''), |
| 'dept code': dept_code, |
| 'last login': user_last_login.get(alumni_id, '') or '', |
| 'email id': data.get('Email', ''), |
| 'phone number': data.get('Phone', '') |
| }) |
|
|
| df = pd.DataFrame(export_rows, columns=['serial', 'alumni id', 'name', 'dept code', 'last login', 'email id', 'phone number']) |
| if df.empty: |
| df = pd.DataFrame(columns=['serial', 'alumni id', 'name', 'dept code', 'last login', 'email id', 'phone number']) |
|
|
| output = BytesIO() |
| with pd.ExcelWriter(output, engine='openpyxl') as writer: |
| df.to_excel(writer, index=False, sheet_name='Members Directory') |
| output.seek(0) |
|
|
| return send_file( |
| output, |
| download_name='RECDGP88_members_directory.xlsx', |
| as_attachment=True, |
| mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' |
| ) |
|
|
|
|
| @app.route('/admin') |
| @require_admin |
| def admin(): |
| query_param = request.args.get('q', '').strip() |
| |
| |
| classmates = [] |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT alumni_id, serial_no, dept_code, encrypted_data FROM alumni ORDER BY serial_no ASC") |
| rows = cursor.fetchall() |
| |
| |
| cursor.execute("SELECT username, totp_secret_encrypted, is_admin, is_super_admin, active, last_login FROM users") |
| user_rows = cursor.fetchall() |
| user_map = {} |
| for ur in user_rows: |
| user_map[ur['username']] = { |
| 'totp_configured': ur['totp_secret_encrypted'] is not None, |
| 'is_admin': bool(ur['is_admin']), |
| 'is_super_admin': bool(ur['is_super_admin']) if 'is_super_admin' in ur.keys() else (ur['username'] == 'admin'), |
| 'active': bool(ur['active']), |
| 'last_login': ur['last_login'] |
| } |
| |
| total_classmates = len(rows) |
| activated_accounts = sum(1 for u in user_map.values() if u['totp_configured']) |
|
|
| for r in rows: |
| alumni_id = r['alumni_id'] |
| decrypted_json = EncryptionManager.decrypt_string(r['encrypted_data']) |
| dept_code = EncryptionManager.decrypt_string(r['dept_code']) |
| |
| data = json.loads(decrypted_json) if decrypted_json else {} |
| |
| |
| data['alumni_id'] = alumni_id |
| data['serial_no'] = r['serial_no'] |
| data['dept_code'] = dept_code |
| |
| user_state = user_map.get(alumni_id, {'totp_configured': False, 'is_admin': False, 'is_super_admin': False, 'active': True, 'last_login': None}) |
| data['totp_configured'] = user_state['totp_configured'] |
| data['is_admin'] = user_state['is_admin'] |
| data['is_super_admin'] = user_state['is_super_admin'] |
| data['active'] = user_state['active'] |
| data['last_login'] = user_state['last_login'] |
| |
| |
| if query_param: |
| qp_lower = query_param.lower() |
| name_match = qp_lower in data.get('Name', '').lower() |
| nickname_match = qp_lower in data.get('Nickname', '').lower() |
| dept_match = qp_lower in data.get('Department', '').lower() or qp_lower in dept_code.lower() |
| id_match = qp_lower in alumni_id |
| |
| if not (name_match or nickname_match or dept_match or id_match): |
| continue |
| |
| classmates.append(data) |
|
|
| |
| activity_logs = [] |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT timestamp, userid, action, status, details FROM activity_log ORDER BY id DESC LIMIT 50") |
| log_rows = cursor.fetchall() |
| for lr in log_rows: |
| activity_logs.append(dict(lr)) |
|
|
| has_hf_token = os.environ.get("HF_TOKEN") is not None |
| is_hf_mode_flag = is_hf_mode() |
|
|
| return render_template( |
| 'admin.html', |
| classmates=classmates, |
| activity_logs=activity_logs, |
| total_classmates=total_classmates, |
| activated_accounts=activated_accounts, |
| has_hf_token=has_hf_token, |
| is_hf_mode=is_hf_mode_flag, |
| query_param=query_param |
| ) |
|
|
| @app.route('/admin/hf-sync/<sync_action>', methods=['POST']) |
| @require_admin |
| def admin_hf_sync(sync_action): |
| admin_id = session.get('userid') |
| is_hf = is_hf_mode() |
|
|
| if sync_action not in ['start', 'stop', 'pull', 'push']: |
| flash('Invalid HF sync action requested.', 'danger') |
| return redirect(url_for('admin')) |
|
|
| if sync_action == 'start': |
| if is_hf: |
| flash('HF two-way sync is active in this environment.', 'success') |
| DatabaseManager.log_activity(admin_id, 'admin_hf_sync_start', 'success', 'Confirmed HF two-way sync active.') |
| else: |
| flash('HF mode is not enabled. Start with HF_TOKEN and without LOCAL_RUN to activate two-way sync.', 'warning') |
| DatabaseManager.log_activity(admin_id, 'admin_hf_sync_start', 'warning', 'Attempted to start HF sync while not in HF mode.') |
|
|
| elif sync_action == 'stop': |
| if is_hf: |
| flash('Stopping HF mode from the UI is not supported. Restart the app in local mode (LOCAL_RUN=1) to disable HF sync.', 'warning') |
| DatabaseManager.log_activity(admin_id, 'admin_hf_sync_stop', 'warning', 'Requested HF sync stop.') |
| else: |
| flash('HF sync is already inactive in local mode.', 'info') |
| DatabaseManager.log_activity(admin_id, 'admin_hf_sync_stop', 'info', 'Confirmed HF sync already inactive.') |
|
|
| elif sync_action == 'pull': |
| if not is_hf: |
| flash('HF→App sync is only available when running in HF mode.', 'danger') |
| DatabaseManager.log_activity(admin_id, 'admin_hf_sync_pull', 'failed', 'Attempted HF→App sync while not in HF mode.') |
| else: |
| threading.Thread(target=sync_all_from_hf, args=(True,), daemon=True).start() |
| flash('HF→App sync started. Remote dataset download is running in the background.', 'success') |
| DatabaseManager.log_activity(admin_id, 'admin_hf_sync_pull', 'success', 'Triggered HF→App sync.') |
|
|
| elif sync_action == 'push': |
| if not is_hf: |
| flash('App→HF sync is only available when running in HF mode.', 'danger') |
| DatabaseManager.log_activity(admin_id, 'admin_hf_sync_push', 'failed', 'Attempted App→HF sync while not in HF mode.') |
| else: |
| def push_operation(): |
| sync_to_hf_async(STRUCTURED_DB_FILE, 'app/databases/structured.db') |
| sync_to_hf_async(BLOBS_DB_FILE, 'app/databases/blobs.db') |
| sync_to_hf_async(KEY_FILE, 'app/databases/.encryption_key') |
|
|
| threading.Thread(target=push_operation, daemon=True).start() |
| flash('App→HF sync started. Uploads are running in the background.', 'success') |
| DatabaseManager.log_activity(admin_id, 'admin_hf_sync_push', 'success', 'Triggered App→HF sync.') |
|
|
| return redirect(url_for('admin')) |
|
|
| DEPARTMENT_MAPPING = { |
| "1": ("Chemical Engineering", "CHE"), |
| "2": ("Civil Engineering", "CE"), |
| "3": ("Computer Science & Engineering", "CSE"), |
| "4": ("Electrical Engineering", "EE"), |
| "5": ("Electronics & Communication Engineering", "ECE"), |
| "6": ("Mechanical Engineering", "ME"), |
| "7": ("Metallurgical & Materials Engineering", "MME") |
| } |
|
|
|
|
| def create_new_alumni_user_record(name: str, nickname: str, department: str, dept_code: str, email: str, phone: str): |
| """ |
| Creates a new user account and alumni profile record in structured.db and blobs.db. |
| Auto-generates serial_no (MAX + 1) and unique 6-digit alumni_id. |
| Returns (alumni_id, serial_no). |
| """ |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT MAX(serial_no) FROM alumni") |
| max_serial = cursor.fetchone()[0] or 0 |
| new_serial = max_serial + 1 |
| |
| cursor.execute("SELECT alumni_id FROM alumni") |
| existing_ids = {r['alumni_id'] for r in cursor.fetchall()} |
| |
| collision_counter = 0 |
| while True: |
| seed_str = f"recdgp88_user_{new_serial}_{name.strip().lower()}_{datetime.now().timestamp()}_{collision_counter}" |
| h = hashlib.sha256(seed_str.encode('utf-8')).hexdigest() |
| val = int(h, 16) |
| alumni_id = str(100000 + (val % 900000)) |
| if alumni_id not in existing_ids: |
| break |
| collision_counter += 1 |
| |
| row_dict = { |
| 'Name': name.strip(), |
| 'Nickname': nickname.strip() if nickname else "", |
| 'Department': department, |
| 'Dept Code': dept_code, |
| 'Email': email.strip() if email else "", |
| 'Phone': phone.strip() if phone else "", |
| 'Num_Children': 0 |
| } |
| |
| encrypted_data = EncryptionManager.encrypt_string(json.dumps(row_dict)) |
| encrypted_dept_code = EncryptionManager.encrypt_string(dept_code) |
| |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute( |
| "INSERT INTO alumni (alumni_id, serial_no, dept_code, encrypted_data) VALUES (?, ?, ?, ?)", |
| (alumni_id, new_serial, encrypted_dept_code, encrypted_data) |
| ) |
| cursor.execute( |
| "INSERT INTO users (username, password_hash, totp_secret_encrypted, is_admin, active, created_at) VALUES (?, ?, ?, 0, 1, ?)", |
| (alumni_id, None, None, datetime.now().strftime("%Y-%m-%d %H:%M:%S")) |
| ) |
| conn.commit() |
|
|
| with DatabaseManager.get_blobs_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute( |
| "INSERT INTO profile_blobs (alumni_id, profile_writeup, self_photo, family_photo) VALUES (?, ?, ?, ?)", |
| (alumni_id, "", None, None) |
| ) |
| conn.commit() |
|
|
| return alumni_id, new_serial |
|
|
|
|
| @app.route('/admin/add-user', methods=['GET', 'POST']) |
| @require_admin |
| def admin_add_user(): |
| if request.method == 'POST': |
| name = request.form.get('name', '').strip() |
| nickname = request.form.get('nickname', '').strip() |
| dept_key = request.form.get('dept_key', '').strip() |
| email = request.form.get('email', '').strip() |
| |
| phone_country = request.form.get('phone_country', '+91').strip() |
| phone_national = request.form.get('phone_national', '').strip().replace(' ', '') |
| |
| if not name: |
| flash("Full Name is required.", "danger") |
| return render_template('add_user.html', departments=DEPARTMENT_MAPPING, form_data=request.form) |
| |
| if dept_key not in DEPARTMENT_MAPPING: |
| flash("Please select a valid department.", "danger") |
| return render_template('add_user.html', departments=DEPARTMENT_MAPPING, form_data=request.form) |
| |
| department, dept_code = DEPARTMENT_MAPPING[dept_key] |
| |
| if email and not is_valid_email(email): |
| flash("Invalid email format.", "danger") |
| return render_template('add_user.html', departments=DEPARTMENT_MAPPING, form_data=request.form) |
| |
| phone = "" |
| if phone_national: |
| raw_phone = f"{phone_country}{phone_national}" |
| try: |
| phone = validate_and_format_phone(raw_phone) |
| except ValueError as e: |
| flash(f"Phone Number Error: {str(e)}", "danger") |
| return render_template('add_user.html', departments=DEPARTMENT_MAPPING, form_data=request.form) |
|
|
| try: |
| alumni_id, serial_no = create_new_alumni_user_record(name, nickname, department, dept_code, email, phone) |
| admin_user = session.get('userid') |
| DatabaseManager.log_activity(admin_user, "add_user", "success", f"Created user {alumni_id} ({name})") |
| |
| flash( |
| f"✅ New User Created! Name: '{name}', Alumni ID: {alumni_id}, Serial #: {serial_no}, Dept: {department} ({dept_code})", |
| "success" |
| ) |
| return redirect(url_for('admin')) |
| except Exception as e: |
| logger.error(f"Failed to create new user: {e}") |
| flash(f"Error creating user account: {e}", "danger") |
| return render_template('add_user.html', departments=DEPARTMENT_MAPPING, form_data=request.form) |
|
|
| return render_template('add_user.html', departments=DEPARTMENT_MAPPING) |
|
|
|
|
| @app.route('/admin/action/<target_uid>/<action>', methods=['GET', 'POST']) |
| @require_admin |
| def admin_action(target_uid, action): |
| admin_id = session.get('userid') |
| client_ip = request.remote_addr |
| |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT * FROM users WHERE username = ?", (target_uid,)) |
| user = cursor.fetchone() |
| |
| if not user: |
| flash("User not found.", "danger") |
| return redirect(url_for('admin')) |
|
|
| is_super = target_uid == 'admin' or (dict(user).get('is_super_admin') == 1) |
| |
| if is_super and action in ['delete_user', 'toggle_lock', 'toggle_admin']: |
| flash("Super Admin protection: Super Admin status and account cannot be locked, demoted, or deleted.", "danger") |
| return redirect(url_for('admin')) |
| |
| if action == 'toggle_admin': |
| new_admin_status = 0 if user['is_admin'] else 1 |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("UPDATE users SET is_admin = ? WHERE username = ?", (new_admin_status, target_uid)) |
| conn.commit() |
| |
| status_str = "granted" if new_admin_status == 1 else "revoked" |
| flash(f"Admin privilege {status_str} for user '{target_uid}'.", "success") |
| DatabaseManager.log_activity(admin_id, f"admin_privilege_{status_str}", "success", f"Admin privilege {status_str} for '{target_uid}'") |
|
|
| elif action == 'toggle_lock': |
| new_status = 0 if user['active'] else 1 |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("UPDATE users SET active = ? WHERE username = ?", (new_status, target_uid)) |
| conn.commit() |
| |
| status_str = "locked" if new_status == 0 else "unlocked" |
| flash(f"User account '{target_uid}' {status_str} successfully.", "success") |
| DatabaseManager.log_activity(admin_id, f"admin_lock_toggle_{status_str}", "success", f"Toggled lock for '{target_uid}'") |
| |
| elif action == 'reset_totp': |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("UPDATE users SET totp_secret_encrypted = NULL, password_hash = NULL WHERE username = ?", (target_uid,)) |
| conn.commit() |
| |
| flash(f"TOTP 2FA secret and password reset successfully for classmate '{target_uid}'. First-time setup required on next login.", "success") |
| DatabaseManager.log_activity(admin_id, "admin_reset_totp", "success", f"Reset password & TOTP credentials for '{target_uid}'") |
| |
| elif action == 'delete_user': |
| |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("DELETE FROM users WHERE username = ?", (target_uid,)) |
| cursor.execute("DELETE FROM alumni WHERE alumni_id = ?", (target_uid,)) |
| conn.commit() |
| |
| |
| with DatabaseManager.get_blobs_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("DELETE FROM profile_blobs WHERE alumni_id = ?", (target_uid,)) |
| conn.commit() |
| |
| |
| for img_type in ['self', 'family']: |
| local_enc_path = os.path.join(IMG_DIR, f"{img_type}_{target_uid}.enc") |
| if os.path.exists(local_enc_path): |
| os.remove(local_enc_path) |
| sync_to_hf_async(local_enc_path, f"app/images/{img_type}_{target_uid}.enc") |
| |
| flash(f"Classmate directory profile and login '{target_uid}' permanently deleted.", "success") |
| DatabaseManager.log_activity(admin_id, "admin_delete_user", "success", f"Permanently deleted directory record '{target_uid}'") |
|
|
| |
| sync_to_hf_async(STRUCTURED_DB_FILE, "app/databases/structured.db") |
| sync_to_hf_async(BLOBS_DB_FILE, "app/databases/blobs.db") |
| |
| return redirect(url_for('admin')) |
|
|
|
|
|
|
| |
| |
| |
|
|
|
|
| @app.route('/album') |
| @require_auth |
| def album(): |
| |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT folder_id, name, created_by, created_at FROM album_folders ORDER BY folder_id DESC") |
| folders = [dict(row) for row in cursor.fetchall()] |
| |
| |
| with DatabaseManager.get_blobs_connection() as conn: |
| cursor = conn.cursor() |
| for folder in folders: |
| cursor.execute("SELECT COUNT(*) FROM album_media WHERE folder_id = ?", (folder['folder_id'],)) |
| folder['media_count'] = cursor.fetchone()[0] |
| |
| return render_template('album.html', folders=folders) |
|
|
|
|
| @app.route('/album/create', methods=['POST']) |
| @require_auth |
| def album_create_folder(): |
| if not session.get('is_admin'): |
| abort(403) |
| |
| name = request.form.get('name', '').strip() |
| if not name: |
| flash("Folder name cannot be empty.", "danger") |
| return redirect(url_for('album')) |
| |
| current_userid = session.get('userid') |
| try: |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute( |
| "INSERT INTO album_folders (name, created_by) VALUES (?, ?)", |
| (name, current_userid) |
| ) |
| conn.commit() |
| |
| DatabaseManager.log_activity(current_userid, "album_folder_create", "success", f"Created folder '{name}'") |
| sync_to_hf_async(STRUCTURED_DB_FILE, "app/databases/structured.db") |
| flash(f"Folder '{name}' created successfully!", "success") |
| except Exception as e: |
| logger.error(f"Failed to create album folder: {e}") |
| flash("Failed to create folder.", "danger") |
| |
| return redirect(url_for('album')) |
|
|
|
|
| @app.route('/album/<int:folder_id>') |
| @require_auth |
| def album_view_folder(folder_id): |
| |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT folder_id, name, created_by, created_at FROM album_folders WHERE folder_id = ?", (folder_id,)) |
| folder = cursor.fetchone() |
| |
| if not folder: |
| flash("Folder not found.", "danger") |
| return redirect(url_for('album')) |
| |
| |
| with DatabaseManager.get_blobs_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute( |
| "SELECT media_id, folder_id, filename, file_type, mime_type, created_by, created_at FROM album_media WHERE folder_id = ? ORDER BY media_id DESC", |
| (folder_id,) |
| ) |
| media_items = [dict(row) for row in cursor.fetchall()] |
| |
| return render_template('album_folder.html', folder=dict(folder), media_items=media_items) |
|
|
|
|
| @app.route('/album/<int:folder_id>/upload', methods=['POST']) |
| @require_auth |
| @rate_limit(max_req=30, window=60) |
| def album_upload_media(folder_id): |
| if not session.get('is_admin'): |
| abort(403) |
| |
| |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT name FROM album_folders WHERE folder_id = ?", (folder_id,)) |
| folder = cursor.fetchone() |
| |
| if not folder: |
| flash("Folder not found.", "danger") |
| return redirect(url_for('album')) |
| |
| uploaded_files = request.files.getlist('media_file') |
| if not uploaded_files or (len(uploaded_files) == 1 and uploaded_files[0].filename == ''): |
| flash("No files selected for upload.", "danger") |
| return redirect(url_for('album_view_folder', folder_id=folder_id)) |
| |
| success_count = 0 |
| errors = [] |
| allowed_media_exts = ALLOWED_IMAGE_EXTS | ALLOWED_VIDEO_EXTS |
| current_userid = session.get('userid') |
| |
| for file in uploaded_files: |
| if not file or not file.filename: |
| continue |
|
|
| ext = file.filename.rsplit('.', 1)[-1].lower() if '.' in file.filename else '' |
|
|
| |
| if ext in ALLOWED_IMAGE_EXTS: |
| is_valid, safe_name, raw_bytes, err_msg = validate_upload_file( |
| file, ALLOWED_IMAGE_EXTS, max_bytes=16 * 1024 * 1024 |
| ) |
| if not is_valid: |
| errors.append(f"'{file.filename}': {err_msg}") |
| continue |
| file_type = 'image' |
| elif ext in ALLOWED_VIDEO_EXTS: |
| safe_name = secure_filename(file.filename) |
| if not safe_name: |
| errors.append(f"'{file.filename}': Invalid filename.") |
| continue |
| raw_bytes = file.read() |
| if len(raw_bytes) > 16 * 1024 * 1024: |
| errors.append(f"'{file.filename}': File too large (limit 16MB).") |
| continue |
| if not raw_bytes: |
| errors.append(f"'{file.filename}': Empty file.") |
| continue |
| file_type = 'video' |
| else: |
| errors.append(f"'{file.filename}': Unsupported format.") |
| continue |
|
|
| mime_type = f"{'image' if file_type == 'image' else 'video'}/{ext if ext not in ('jpg', 'mov') else ('jpeg' if ext == 'jpg' else 'mp4')}" |
| |
| try: |
| |
| if file_type == 'image': |
| try: |
| raw_bytes = compress_image(raw_bytes) |
| mime_type = 'image/jpeg' |
| except Exception as compress_err: |
| logger.error(f"Album image compression failed, storing original: {compress_err}") |
| |
| encrypted_bytes = EncryptionManager.encrypt_bytes(raw_bytes) |
| |
| with DatabaseManager.get_blobs_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute( |
| "INSERT INTO album_media (folder_id, filename, file_type, mime_type, encrypted_bytes, created_by) VALUES (?, ?, ?, ?, ?, ?)", |
| (folder_id, safe_name, file_type, mime_type, sqlite3.Binary(encrypted_bytes), current_userid) |
| ) |
| conn.commit() |
| |
| success_count += 1 |
| |
| except Exception as e: |
| logger.error(f"Failed to upload '{safe_name}': {e}") |
| errors.append(f"'{safe_name}': Failed to process file.") |
| |
| if success_count > 0: |
| DatabaseManager.log_activity(current_userid, "album_media_upload", "success", f"Uploaded {success_count} files to folder ID {folder_id}") |
| sync_to_hf_async(BLOBS_DB_FILE, "app/databases/blobs.db") |
| flash(f"Successfully uploaded {success_count} file(s)!", "success") |
| |
| if errors: |
| for err in errors: |
| flash(err, "danger") |
| |
| return redirect(url_for('album_view_folder', folder_id=folder_id)) |
|
|
| @app.route('/album/media/<int:media_id>') |
| @require_auth |
| def serve_album_media(media_id): |
| with DatabaseManager.get_blobs_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT encrypted_bytes, mime_type FROM album_media WHERE media_id = ?", (media_id,)) |
| row = cursor.fetchone() |
| |
| if not row: |
| abort(404) |
| |
| decrypted_bytes = EncryptionManager.decrypt_bytes(row['encrypted_bytes']) |
| if not decrypted_bytes: |
| abort(500) |
| |
| return send_file( |
| BytesIO(decrypted_bytes), |
| mimetype=row['mime_type'], |
| as_attachment=False |
| ) |
|
|
|
|
| @app.route('/album/<int:folder_id>/delete/<int:media_id>', methods=['GET', 'POST']) |
| @require_auth |
| def album_delete_media(folder_id, media_id): |
| current_userid = session.get('userid') |
| is_admin = session.get('is_admin') |
| |
| if not is_admin: |
| abort(403) |
| |
| |
| with DatabaseManager.get_blobs_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT created_by, filename FROM album_media WHERE media_id = ?", (media_id,)) |
| media = cursor.fetchone() |
| |
| if not media: |
| flash("Media item not found.", "danger") |
| return redirect(url_for('album_view_folder', folder_id=folder_id)) |
| |
| try: |
| with DatabaseManager.get_blobs_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("DELETE FROM album_media WHERE media_id = ?", (media_id,)) |
| conn.commit() |
| |
| DatabaseManager.log_activity(current_userid, "album_media_delete", "success", f"Deleted media '{media['filename']}' (ID: {media_id})") |
| sync_to_hf_async(BLOBS_DB_FILE, "app/databases/blobs.db") |
| flash(f"Media '{media['filename']}' deleted successfully.", "success") |
| except Exception as e: |
| logger.error(f"Failed to delete album media: {e}") |
| flash("Failed to delete media.", "danger") |
| |
| return redirect(url_for('album_view_folder', folder_id=folder_id)) |
|
|
|
|
| @app.route('/album/delete/<int:folder_id>', methods=['GET', 'POST']) |
| @require_auth |
| def album_delete_folder(folder_id): |
| current_userid = session.get('userid') |
| is_admin = session.get('is_admin') |
| |
| if not is_admin: |
| abort(403) |
| |
| |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT created_by, name FROM album_folders WHERE folder_id = ?", (folder_id,)) |
| folder = cursor.fetchone() |
| |
| if not folder: |
| flash("Folder not found.", "danger") |
| return redirect(url_for('album')) |
| |
| try: |
| |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("DELETE FROM album_folders WHERE folder_id = ?", (folder_id,)) |
| conn.commit() |
| |
| |
| with DatabaseManager.get_blobs_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("DELETE FROM album_media WHERE folder_id = ?", (folder_id,)) |
| conn.commit() |
| |
| DatabaseManager.log_activity(current_userid, "album_folder_delete", "success", f"Deleted folder '{folder['name']}' (ID: {folder_id}) and its contents") |
| flash(f"Folder '{folder['name']}' and all its files deleted successfully.", "success") |
| except Exception as e: |
| logger.error(f"Failed to delete album folder: {e}") |
| flash("Failed to delete folder.", "danger") |
| |
| return redirect(url_for('album')) |
|
|
|
|
| |
| |
| |
|
|
| @app.route('/profile/<alumni_id>/personal-album/create-folder', methods=['POST']) |
| @require_auth |
| def personal_album_create_folder(alumni_id): |
| current_userid = session.get('userid') |
| is_admin = session.get('is_admin') |
| |
| if not is_admin and current_userid != alumni_id: |
| abort(403) |
| |
| name = request.form.get('name', '').strip() |
| if not name: |
| flash("Folder name cannot be empty.", "danger") |
| return redirect(url_for('profile', alumni_id=alumni_id)) |
| |
| try: |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute( |
| "INSERT INTO personal_album_folders (user_id, name) VALUES (?, ?)", |
| (alumni_id, name) |
| ) |
| conn.commit() |
| |
| DatabaseManager.log_activity(current_userid, "personal_album_create_folder", "success", f"Created folder '{name}' for user {alumni_id}") |
| sync_to_hf_async(STRUCTURED_DB_FILE, "app/databases/structured.db") |
| flash(f"Personal album folder '{name}' created successfully!", "success") |
| except Exception as e: |
| logger.error(f"Failed to create personal album folder: {e}") |
| flash("Failed to create folder.", "danger") |
| |
| return redirect(url_for('profile', alumni_id=alumni_id)) |
|
|
|
|
| @app.route('/profile/<alumni_id>/personal-album/upload', methods=['POST']) |
| @require_auth |
| def personal_album_upload_media(alumni_id): |
| current_userid = session.get('userid') |
| is_admin = session.get('is_admin') |
| |
| if not is_admin and current_userid != alumni_id: |
| abort(403) |
| |
| folder_id_val = request.form.get('folder_id', '0').strip() |
| folder_id = int(folder_id_val) if folder_id_val.isdigit() and int(folder_id_val) > 0 else None |
| |
| files = request.files.getlist('photos') |
| if not files or (len(files) == 1 and files[0].filename == ''): |
| flash("No photo selected for upload.", "danger") |
| return redirect(url_for('profile', alumni_id=alumni_id)) |
| |
| success_count = 0 |
| quota_exceeded = False |
| |
| |
| with DatabaseManager.get_blobs_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT SUM(file_size) FROM personal_album_media WHERE user_id = ?", (alumni_id,)) |
| res = cursor.fetchone() |
| current_total_bytes = res[0] if res and res[0] else 0 |
|
|
| MAX_QUOTA_BYTES = 10 * 1024 * 1024 |
|
|
| for file in files: |
| filename = file.filename |
| if not filename: |
| continue |
| |
| try: |
| raw_bytes = file.read() |
| |
| compressed_bytes = compress_image(raw_bytes) |
| compressed_size = len(compressed_bytes) |
| |
| if current_total_bytes + compressed_size > MAX_QUOTA_BYTES: |
| quota_exceeded = True |
| break |
| |
| encrypted_bytes = EncryptionManager.encrypt_bytes(compressed_bytes) |
| |
| with DatabaseManager.get_blobs_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute( |
| "INSERT INTO personal_album_media (user_id, folder_id, filename, mime_type, encrypted_bytes, file_size) VALUES (?, ?, ?, ?, ?, ?)", |
| (alumni_id, folder_id, filename, "image/jpeg", sqlite3.Binary(encrypted_bytes), compressed_size) |
| ) |
| conn.commit() |
| |
| current_total_bytes += compressed_size |
| success_count += 1 |
| |
| except Exception as e: |
| logger.error(f"Failed to process personal album photo '{filename}': {e}") |
|
|
| if success_count > 0: |
| DatabaseManager.log_activity(current_userid, "personal_album_upload", "success", f"Uploaded {success_count} photo(s) to personal album for {alumni_id}") |
| sync_to_hf_async(BLOBS_DB_FILE, "app/databases/blobs.db") |
| flash(f"Successfully uploaded {success_count} photo(s) to personal album (compressed under 100KB each)!", "success") |
| |
| if quota_exceeded: |
| flash("⚠️ Personal Album Quota Exceeded! Maximum limit is 10 MB per user. Remaining files were not uploaded.", "warning") |
| DatabaseManager.log_activity(current_userid, "personal_album_quota_exceeded", "warning", f"Quota 10MB exceeded for user {alumni_id}") |
| |
| return redirect(url_for('profile', alumni_id=alumni_id)) |
|
|
|
|
| @app.route('/personal-album/media/<int:media_id>') |
| @require_auth |
| def serve_personal_album_media(media_id): |
| with DatabaseManager.get_blobs_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT encrypted_bytes, mime_type FROM personal_album_media WHERE media_id = ?", (media_id,)) |
| row = cursor.fetchone() |
| |
| if not row: |
| abort(404) |
| |
| decrypted_bytes = EncryptionManager.decrypt_bytes(row['encrypted_bytes']) |
| if not decrypted_bytes: |
| abort(500) |
| |
| return send_file( |
| BytesIO(decrypted_bytes), |
| mimetype=row['mime_type'], |
| as_attachment=False |
| ) |
|
|
|
|
| @app.route('/profile/<alumni_id>/personal-album/delete/<int:media_id>', methods=['GET', 'POST']) |
| @require_auth |
| def personal_album_delete_media(alumni_id, media_id): |
| current_userid = session.get('userid') |
| is_admin = session.get('is_admin') |
| |
| |
| if not is_admin and current_userid != alumni_id: |
| abort(403) |
| |
| with DatabaseManager.get_blobs_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("DELETE FROM personal_album_media WHERE media_id = ? AND user_id = ?", (media_id, alumni_id)) |
| conn.commit() |
| |
| DatabaseManager.log_activity(current_userid, "personal_album_delete_media", "success", f"Deleted media ID {media_id} from personal album of {alumni_id}") |
| sync_to_hf_async(BLOBS_DB_FILE, "app/databases/blobs.db") |
| flash("Personal album photo removed successfully.", "success") |
| return redirect(url_for('profile', alumni_id=alumni_id)) |
|
|
|
|
| @app.route('/profile/<alumni_id>/personal-album/delete-folder/<int:folder_id>', methods=['GET', 'POST']) |
| @require_auth |
| def personal_album_delete_folder(alumni_id, folder_id): |
| current_userid = session.get('userid') |
| is_admin = session.get('is_admin') |
| |
| if not is_admin and current_userid != alumni_id: |
| abort(403) |
| |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("DELETE FROM personal_album_folders WHERE folder_id = ? AND user_id = ?", (folder_id, alumni_id)) |
| conn.commit() |
| |
| with DatabaseManager.get_blobs_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("DELETE FROM personal_album_media WHERE folder_id = ? AND user_id = ?", (folder_id, alumni_id)) |
| conn.commit() |
| |
| DatabaseManager.log_activity(current_userid, "personal_album_delete_folder", "success", f"Deleted personal folder ID {folder_id} for {alumni_id}") |
| sync_to_hf_async(STRUCTURED_DB_FILE, "app/databases/structured.db") |
| sync_to_hf_async(BLOBS_DB_FILE, "app/databases/blobs.db") |
| flash("Personal album folder and its contents deleted successfully.", "success") |
| return redirect(url_for('profile', alumni_id=alumni_id)) |
|
|
|
|
| |
| |
| |
|
|
| @app.route('/announcements') |
| @require_auth |
| def announcements(): |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT announcement_id, title, content, created_by, created_at FROM announcements ORDER BY announcement_id DESC") |
| items = [dict(row) for row in cursor.fetchall()] |
| return render_template('announcements.html', announcements=items) |
|
|
|
|
| @app.route('/announcements/create', methods=['POST']) |
| @require_admin |
| def announcements_create(): |
| title = request.form.get('title', '').strip() |
| content = request.form.get('content', '').strip() |
| |
| if not title or not content: |
| flash("Title and Content are required fields.", "danger") |
| return redirect(url_for('announcements')) |
| |
| current_userid = session.get('userid') |
| try: |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute( |
| "INSERT INTO announcements (title, content, created_by) VALUES (?, ?, ?)", |
| (title, content, current_userid) |
| ) |
| conn.commit() |
| |
| DatabaseManager.log_activity(current_userid, "announcement_create", "success", f"Created announcement: {title}") |
| sync_to_hf_async(STRUCTURED_DB_FILE, "app/databases/structured.db") |
| flash("Announcement posted successfully!", "success") |
| except Exception as e: |
| logger.error(f"Failed to create announcement: {e}") |
| flash("Failed to post announcement.", "danger") |
| |
| return redirect(url_for('announcements')) |
|
|
|
|
| @app.route('/announcements/edit/<int:announcement_id>', methods=['POST']) |
| @require_admin |
| def announcements_edit(announcement_id): |
| title = request.form.get('title', '').strip() |
| content = request.form.get('content', '').strip() |
| |
| if not title or not content: |
| flash("Title and Content cannot be empty.", "danger") |
| return redirect(url_for('announcements')) |
| |
| admin_id = session.get('userid') |
| try: |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute( |
| "UPDATE announcements SET title = ?, content = ? WHERE announcement_id = ?", |
| (title, content, announcement_id) |
| ) |
| conn.commit() |
| |
| DatabaseManager.log_activity(admin_id, "announcement_edit", "success", f"Edited announcement ID {announcement_id}") |
| sync_to_hf_async(STRUCTURED_DB_FILE, "app/databases/structured.db") |
| flash("Announcement updated successfully!", "success") |
| except Exception as e: |
| logger.error(f"Failed to edit announcement: {e}") |
| flash("Failed to update announcement.", "danger") |
| |
| return redirect(url_for('announcements')) |
|
|
|
|
| @app.route('/announcements/delete/<int:announcement_id>', methods=['GET', 'POST']) |
| @require_admin |
| def announcements_delete(announcement_id): |
| admin_id = session.get('userid') |
| try: |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("DELETE FROM announcements WHERE announcement_id = ?", (announcement_id,)) |
| conn.commit() |
| |
| DatabaseManager.log_activity(admin_id, "announcement_delete", "success", f"Deleted announcement ID {announcement_id}") |
| sync_to_hf_async(STRUCTURED_DB_FILE, "app/databases/structured.db") |
| flash("Announcement deleted successfully.", "success") |
| except Exception as e: |
| logger.error(f"Failed to delete announcement: {e}") |
| flash("Failed to delete announcement.", "danger") |
| |
| return redirect(url_for('announcements')) |
|
|
|
|
| @app.route('/announcements/bulk-delete', methods=['POST']) |
| @require_admin |
| def announcements_bulk_delete(): |
| admin_id = session.get('userid') |
| ids = request.form.getlist('announcement_ids') |
| if not ids: |
| flash("No announcements selected for deletion.", "warning") |
| return redirect(url_for('announcements')) |
|
|
| try: |
| deleted_count = 0 |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| for ann_id in ids: |
| cursor.execute("DELETE FROM announcements WHERE announcement_id = ?", (ann_id,)) |
| deleted_count += cursor.rowcount |
| conn.commit() |
|
|
| DatabaseManager.log_activity(admin_id, "announcement_bulk_delete", "success", f"Bulk deleted {deleted_count} announcements") |
| sync_to_hf_async(STRUCTURED_DB_FILE, "app/databases/structured.db") |
| flash(f"Successfully deleted {deleted_count} selected announcement(s).", "success") |
| except Exception as e: |
| logger.error(f"Failed to bulk delete announcements: {e}") |
| flash("Failed to bulk delete announcements.", "danger") |
|
|
| return redirect(url_for('announcements')) |
|
|
|
|
| |
| |
| |
|
|
| @app.route('/surveys') |
| @require_auth |
| def surveys(): |
| current_userid = session.get('userid') |
| |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| |
| |
| cursor.execute("SELECT survey_id, title, description, created_by, created_at FROM surveys ORDER BY survey_id DESC") |
| survey_list = [dict(row) for row in cursor.fetchall()] |
| |
| |
| cursor.execute("SELECT survey_id FROM survey_responses WHERE user_id = ?", (current_userid,)) |
| participated_ids = {row['survey_id'] for row in cursor.fetchall()} |
| |
| |
| cursor.execute("SELECT COUNT(*) FROM alumni") |
| total_members = cursor.fetchone()[0] |
| |
| |
| cursor.execute("SELECT survey_id, COUNT(*) as cnt FROM survey_responses GROUP BY survey_id") |
| response_counts = {row['survey_id']: row['cnt'] for row in cursor.fetchall()} |
| |
| for s in survey_list: |
| s['participated'] = s['survey_id'] in participated_ids |
| s['response_count'] = response_counts.get(s['survey_id'], 0) |
| |
| return render_template('surveys.html', surveys=survey_list, total_members=total_members) |
|
|
|
|
| @app.route('/surveys/create', methods=['POST']) |
| @require_admin |
| def surveys_create(): |
| title = request.form.get('title', '').strip() |
| description = request.form.get('description', '').strip() |
| file = request.files.get('survey_file') |
| |
| if not title: |
| flash("Survey title is required.", "danger") |
| return redirect(url_for('surveys')) |
| |
| current_userid = session.get('userid') |
| survey_id = None |
| |
| try: |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("INSERT INTO surveys (title, description, created_by) VALUES (?, ?, ?)", (title, description, current_userid)) |
| survey_id = cursor.lastrowid |
| conn.commit() |
| |
| if file and file.filename != '': |
| filename = file.filename |
| ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else '' |
| if ext == 'xlsx': |
| import pandas as pd |
| import io |
| |
| df = pd.read_excel(io.BytesIO(file.read())) |
| df.columns = [str(c).strip().lower() for c in df.columns] |
| |
| col_mapping = {} |
| for col in df.columns: |
| if 'question' in col: |
| col_mapping['question'] = col |
| elif 'type' in col: |
| col_mapping['type'] = col |
| elif 'option' in col: |
| col_mapping['options'] = col |
| elif 'require' in col: |
| col_mapping['required'] = col |
| |
| if 'question' in col_mapping and 'type' in col_mapping: |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| for _, row in df.iterrows(): |
| q_text = str(row.get(col_mapping['question'], '')).strip() |
| if not q_text: |
| continue |
| |
| q_type = str(row.get(col_mapping['type'], 'text')).strip().lower() |
| if 'radio' in q_type: |
| q_type = 'radio' |
| elif 'check' in q_type: |
| q_type = 'checkbox' |
| elif 'drop' in q_type or 'select' in q_type: |
| q_type = 'dropdown' |
| elif 'area' in q_type or 'long' in q_type: |
| q_type = 'textarea' |
| elif 'email' in q_type: |
| q_type = 'email' |
| elif 'phone' in q_type or 'mobile' in q_type: |
| q_type = 'mobile' |
| else: |
| q_type = 'text' |
| |
| options_val = row.get(col_mapping.get('options'), '') |
| options_str = str(options_val).strip() if pd.notna(options_val) else '' |
| |
| req_val = row.get(col_mapping.get('required'), 0) |
| if isinstance(req_val, str): |
| required = 1 if req_val.strip().lower() in ['y', 'yes', 'true', '1'] else 0 |
| else: |
| required = 1 if bool(req_val) else 0 |
| |
| cursor.execute( |
| "INSERT INTO survey_questions (survey_id, question_text, question_type, options, required) VALUES (?, ?, ?, ?, ?)", |
| (survey_id, q_text, q_type, options_str, required) |
| ) |
| conn.commit() |
|
|
| DatabaseManager.log_activity(current_userid, "survey_create", "success", f"Created survey '{title}' (ID: {survey_id})") |
| sync_to_hf_async(STRUCTURED_DB_FILE, "app/databases/structured.db") |
| flash(f"Survey '{title}' created! Customize questions using the interactive builder whiteboard below.", "success") |
| return redirect(url_for('surveys_builder', survey_id=survey_id)) |
| |
| except Exception as e: |
| logger.error(f"Failed to create survey: {e}") |
| flash("Failed to create survey. Please try again.", "danger") |
| return redirect(url_for('surveys')) |
|
|
|
|
| @app.route('/surveys/<int:survey_id>') |
| @require_auth |
| def surveys_take(survey_id): |
| current_userid = session.get('userid') |
| |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| |
| |
| cursor.execute("SELECT survey_id, title, description, created_by, created_at FROM surveys WHERE survey_id = ?", (survey_id,)) |
| survey_row = cursor.fetchone() |
| |
| if not survey_row: |
| flash("Survey not found.", "danger") |
| return redirect(url_for('surveys')) |
| |
| |
| cursor.execute( |
| "SELECT question_id, question_text, question_type, options, required FROM survey_questions WHERE survey_id = ? ORDER BY question_id ASC", |
| (survey_id,) |
| ) |
| questions = [dict(row) for row in cursor.fetchall()] |
| |
| |
| cursor.execute("SELECT response_id, submitted_at FROM survey_responses WHERE survey_id = ? AND user_id = ?", (survey_id, current_userid)) |
| response_row = cursor.fetchone() |
| |
| survey = dict(survey_row) |
| |
| |
| for q in questions: |
| if q['question_type'] in ['radio', 'checkbox', 'dropdown'] and q['options']: |
| import re |
| choices = [c.strip() for c in re.split(r'[,;|]', q['options']) if c.strip()] |
| q['choices'] = choices |
| else: |
| q['choices'] = [] |
| |
| if response_row: |
| |
| response_id = response_row['response_id'] |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT question_id, encrypted_answer FROM survey_answers WHERE response_id = ?", (response_id,)) |
| answers_map = {row['question_id']: EncryptionManager.decrypt_string(row['encrypted_answer']) for row in cursor.fetchall()} |
| |
| for q in questions: |
| q['user_answer'] = answers_map.get(q['question_id'], '') |
| |
| return render_template('take_survey.html', survey=survey, questions=questions, participated=True, submitted_at=response_row['submitted_at']) |
| |
| return render_template('take_survey.html', survey=survey, questions=questions, participated=False) |
|
|
|
|
| @app.route('/surveys/<int:survey_id>/submit', methods=['POST']) |
| @require_auth |
| def surveys_submit(survey_id): |
| current_userid = session.get('userid') |
| |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| |
| |
| cursor.execute("SELECT 1 FROM survey_responses WHERE survey_id = ? AND user_id = ?", (survey_id, current_userid)) |
| if cursor.fetchone(): |
| flash("You have already participated in this survey.", "warning") |
| return redirect(url_for('surveys')) |
| |
| |
| cursor.execute("SELECT question_id, question_text, question_type, required FROM survey_questions WHERE survey_id = ?", (survey_id,)) |
| questions = [dict(row) for row in cursor.fetchall()] |
| |
| answers_to_save = [] |
| for q in questions: |
| q_id = q['question_id'] |
| q_type = q['question_type'] |
| q_req = q['required'] |
| |
| if q_type == 'checkbox': |
| selected_vals = request.form.getlist(f"question_{q_id}") |
| other_text = request.form.get(f"question_{q_id}_other", '').strip() |
| formatted_vals = [] |
| for val in selected_vals: |
| if val.lower().startswith('other') and other_text: |
| formatted_vals.append(f"Other ({other_text})") |
| else: |
| formatted_vals.append(val) |
| answer_str = ", ".join(formatted_vals) |
| elif q_type == 'radio': |
| selected_val = request.form.get(f"question_{q_id}", '').strip() |
| other_text = request.form.get(f"question_{q_id}_other", '').strip() |
| if selected_val.lower().startswith('other') and other_text: |
| answer_str = f"Other ({other_text})" |
| else: |
| answer_str = selected_val |
| elif q_type == 'email': |
| answer_str = request.form.get(f"question_{q_id}", '').strip() |
| if answer_str: |
| import re |
| if not re.match(r'^[\w\.-]+@[\w\.-]+\.\w+$', answer_str): |
| flash(f"Invalid email address format for question '{q['question_text']}'.", "danger") |
| return redirect(url_for('surveys_take', survey_id=survey_id)) |
| elif q_type == 'mobile': |
| country_code = request.form.get(f"question_{q_id}_country", '+91').strip() |
| number_val = request.form.get(f"question_{q_id}_number", '').strip() |
| if number_val: |
| clean_num = ''.join(filter(str.isdigit, number_val)) |
| if len(clean_num) != 10: |
| flash(f"Mobile number for question '{q['question_text']}' must be exactly 10 digits.", "danger") |
| return redirect(url_for('surveys_take', survey_id=survey_id)) |
| answer_str = f"{country_code} {clean_num[:5]} {clean_num[5:]}" |
| else: |
| answer_str = "" |
| else: |
| answer_str = request.form.get(f"question_{q_id}", '').strip() |
| |
| if q_req and not answer_str: |
| flash(f"Question '{q['question_text']}' is required.", "danger") |
| return redirect(url_for('surveys_take', survey_id=survey_id)) |
| |
| answers_to_save.append((q_id, answer_str)) |
| |
| try: |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| |
| |
| cursor.execute("INSERT INTO survey_responses (survey_id, user_id) VALUES (?, ?)", (survey_id, current_userid)) |
| response_id = cursor.lastrowid |
| |
| |
| for q_id, answer_str in answers_to_save: |
| encrypted_answer = EncryptionManager.encrypt_string(answer_str) |
| cursor.execute( |
| "INSERT INTO survey_answers (response_id, question_id, encrypted_answer) VALUES (?, ?, ?)", |
| (response_id, q_id, encrypted_answer) |
| ) |
| |
| conn.commit() |
| |
| DatabaseManager.log_activity(current_userid, "survey_participate", "success", f"Participated in survey ID {survey_id}") |
| sync_to_hf_async(STRUCTURED_DB_FILE, "app/databases/structured.db") |
| flash("Thank you! Your survey responses have been submitted successfully.", "success") |
| |
| except Exception as e: |
| logger.error(f"Failed to submit survey answers: {e}") |
| flash("Failed to submit survey answers.", "danger") |
| |
| return redirect(url_for('surveys')) |
|
|
|
|
| @app.route('/surveys/edit/<int:survey_id>', methods=['POST']) |
| @require_admin |
| def surveys_edit(survey_id): |
| title = request.form.get('title', '').strip() |
| if not title: |
| flash("Survey title cannot be empty.", "danger") |
| return redirect(url_for('surveys')) |
| |
| admin_id = session.get('userid') |
| try: |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("UPDATE surveys SET title = ? WHERE survey_id = ?", (title, survey_id)) |
| conn.commit() |
| |
| DatabaseManager.log_activity(admin_id, "survey_edit", "success", f"Edited survey ID {survey_id}") |
| sync_to_hf_async(STRUCTURED_DB_FILE, "app/databases/structured.db") |
| flash("Survey title updated successfully!", "success") |
| except Exception as e: |
| logger.error(f"Failed to edit survey: {e}") |
| flash("Failed to update survey.", "danger") |
| |
| return redirect(url_for('surveys')) |
|
|
|
|
| @app.route('/surveys/delete/<int:survey_id>', methods=['GET', 'POST']) |
| @require_admin |
| def surveys_delete(survey_id): |
| admin_id = session.get('userid') |
| try: |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("DELETE FROM surveys WHERE survey_id = ?", (survey_id,)) |
| cursor.execute("DELETE FROM survey_questions WHERE survey_id = ?", (survey_id,)) |
| cursor.execute("SELECT response_id FROM survey_responses WHERE survey_id = ?", (survey_id,)) |
| responses = cursor.fetchall() |
| for resp in responses: |
| cursor.execute("DELETE FROM survey_answers WHERE response_id = ?", (resp['response_id'],)) |
| cursor.execute("DELETE FROM survey_responses WHERE survey_id = ?", (survey_id,)) |
| conn.commit() |
| |
| DatabaseManager.log_activity(admin_id, "survey_delete", "success", f"Deleted survey ID {survey_id}") |
| sync_to_hf_async(STRUCTURED_DB_FILE, "app/databases/structured.db") |
| flash("Survey and associated responses deleted successfully.", "success") |
| except Exception as e: |
| logger.error(f"Failed to delete survey: {e}") |
| flash("Failed to delete survey.", "danger") |
| |
| return redirect(url_for('surveys')) |
|
|
|
|
| @app.route('/surveys/bulk-delete', methods=['POST']) |
| @require_admin |
| def surveys_bulk_delete(): |
| admin_id = session.get('userid') |
| ids = request.form.getlist('survey_ids') |
| if not ids: |
| flash("No surveys selected for deletion.", "warning") |
| return redirect(url_for('surveys')) |
|
|
| try: |
| deleted_count = 0 |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| for survey_id in ids: |
| cursor.execute("DELETE FROM surveys WHERE survey_id = ?", (survey_id,)) |
| cursor.execute("DELETE FROM survey_questions WHERE survey_id = ?", (survey_id,)) |
| cursor.execute("SELECT response_id FROM survey_responses WHERE survey_id = ?", (survey_id,)) |
| responses = cursor.fetchall() |
| for resp in responses: |
| cursor.execute("DELETE FROM survey_answers WHERE response_id = ?", (resp['response_id'],)) |
| cursor.execute("DELETE FROM survey_responses WHERE survey_id = ?", (survey_id,)) |
| deleted_count += 1 |
| conn.commit() |
|
|
| DatabaseManager.log_activity(admin_id, "survey_bulk_delete", "success", f"Bulk deleted {deleted_count} surveys") |
| sync_to_hf_async(STRUCTURED_DB_FILE, "app/databases/structured.db") |
| flash(f"Successfully deleted {deleted_count} selected survey(s).", "success") |
| except Exception as e: |
| logger.error(f"Failed to bulk delete surveys: {e}") |
| flash("Failed to bulk delete surveys.", "danger") |
|
|
| return redirect(url_for('surveys')) |
|
|
|
|
| @app.route('/surveys/<int:survey_id>/builder', methods=['GET', 'POST']) |
| @require_admin |
| def surveys_builder(survey_id): |
| admin_id = session.get('userid') |
| |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT survey_id, title, description, created_by, created_at FROM surveys WHERE survey_id = ?", (survey_id,)) |
| survey_row = cursor.fetchone() |
| |
| if not survey_row: |
| flash("Survey not found.", "danger") |
| return redirect(url_for('surveys')) |
| |
| if request.method == 'POST': |
| title = request.form.get('survey_title', '').strip() |
| description = request.form.get('survey_description', '').strip() |
| if not title: |
| flash("Survey title is required.", "danger") |
| return redirect(url_for('surveys_builder', survey_id=survey_id)) |
| |
| submitted_q_ids = request.form.getlist('question_id[]') |
| |
| try: |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| |
| cursor.execute("UPDATE surveys SET title = ?, description = ? WHERE survey_id = ?", (title, description, survey_id)) |
| |
| |
| cursor.execute("SELECT question_id FROM survey_questions WHERE survey_id = ?", (survey_id,)) |
| db_q_ids = {row['question_id'] for row in cursor.fetchall()} |
| |
| valid_submitted_numeric_ids = {int(qid) for qid in submitted_q_ids if str(qid).isdigit()} |
| |
| |
| to_delete = db_q_ids - valid_submitted_numeric_ids |
| for del_id in to_delete: |
| cursor.execute("DELETE FROM survey_questions WHERE question_id = ?", (del_id,)) |
| cursor.execute("DELETE FROM survey_answers WHERE question_id = ?", (del_id,)) |
| |
| |
| for qid in submitted_q_ids: |
| q_text = request.form.get(f"question_text_{qid}", '').strip() |
| if not q_text: |
| continue |
| q_type = request.form.get(f"question_type_{qid}", 'text').strip() |
| q_opts = request.form.get(f"question_options_{qid}", '').strip() |
| q_req = 1 if request.form.get(f"question_required_{qid}") == '1' else 0 |
| |
| if str(qid).startswith('new_') or not str(qid).isdigit(): |
| cursor.execute( |
| "INSERT INTO survey_questions (survey_id, question_text, question_type, options, required) VALUES (?, ?, ?, ?, ?)", |
| (survey_id, q_text, q_type, q_opts, q_req) |
| ) |
| else: |
| cursor.execute( |
| "UPDATE survey_questions SET question_text = ?, question_type = ?, options = ?, required = ? WHERE question_id = ?", |
| (q_text, q_type, q_opts, q_req, int(qid)) |
| ) |
| |
| conn.commit() |
| |
| DatabaseManager.log_activity(admin_id, "survey_builder_update", "success", f"Updated survey ID {survey_id} via Interactive Builder") |
| sync_to_hf_async(STRUCTURED_DB_FILE, "app/databases/structured.db") |
| flash("Survey updated successfully with Google Forms editor!", "success") |
| return redirect(url_for('surveys')) |
| |
| except Exception as e: |
| logger.error(f"Failed to update survey via builder: {e}") |
| flash("Failed to update survey questions.", "danger") |
| return redirect(url_for('surveys_builder', survey_id=survey_id)) |
| |
| |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute( |
| "SELECT question_id, question_text, question_type, options, required FROM survey_questions WHERE survey_id = ? ORDER BY question_id ASC", |
| (survey_id,) |
| ) |
| questions = [dict(row) for row in cursor.fetchall()] |
| |
| return render_template('edit_survey.html', survey=dict(survey_row) if survey_row else {}, questions=questions) |
|
|
|
|
| @app.route('/surveys/<int:survey_id>/responses') |
| @require_admin |
| def surveys_responses(survey_id): |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| |
| |
| cursor.execute("SELECT survey_id, title, description, created_by, created_at FROM surveys WHERE survey_id = ?", (survey_id,)) |
| survey_row = cursor.fetchone() |
| if not survey_row: |
| flash("Survey not found.", "danger") |
| return redirect(url_for('surveys')) |
| |
| |
| cursor.execute("SELECT COUNT(*) FROM alumni") |
| total_members = cursor.fetchone()[0] |
| |
| |
| cursor.execute("SELECT question_id, question_text, question_type FROM survey_questions WHERE survey_id = ? ORDER BY question_id ASC", (survey_id,)) |
| questions = [dict(row) for row in cursor.fetchall()] |
| |
| |
| cursor.execute("SELECT response_id, user_id, submitted_at FROM survey_responses WHERE survey_id = ? ORDER BY response_id DESC", (survey_id,)) |
| responses = [dict(row) for row in cursor.fetchall()] |
| |
| |
| cursor.execute("SELECT alumni_id, encrypted_data FROM alumni") |
| alumni_rows = cursor.fetchall() |
| alumni_names = {} |
| for ar in alumni_rows: |
| dec = EncryptionManager.decrypt_string(ar['encrypted_data']) |
| if dec: |
| try: |
| data = json.loads(dec) |
| alumni_names[ar['alumni_id']] = data.get('Name', ar['alumni_id']) |
| except Exception: |
| pass |
| |
| response_rows = [] |
| for r in responses: |
| resp_id = r['response_id'] |
| uid = r['user_id'] |
| cursor.execute("SELECT question_id, encrypted_answer FROM survey_answers WHERE response_id = ?", (resp_id,)) |
| answers_map = {row['question_id']: EncryptionManager.decrypt_string(row['encrypted_answer']) for row in cursor.fetchall()} |
| |
| user_display = alumni_names.get(uid, uid) |
| if uid == 'admin': |
| user_display = "Admin (Super User)" |
| |
| response_rows.append({ |
| 'response_id': resp_id, |
| 'user_id': uid, |
| 'user_display_name': user_display, |
| 'submitted_at': r['submitted_at'], |
| 'answers': answers_map |
| }) |
| |
| submission_pct = round((len(responses) / total_members * 100), 1) if total_members > 0 else 0 |
| |
| return render_template( |
| 'survey_responses.html', |
| survey=dict(survey_row), |
| questions=questions, |
| responses=responses, |
| response_rows=response_rows, |
| total_members=total_members, |
| submission_pct=submission_pct |
| ) |
|
|
|
|
| @app.route('/surveys/<int:survey_id>/export_xlsx') |
| @require_admin |
| def surveys_export_xlsx(survey_id): |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| |
| cursor.execute("SELECT survey_id, title FROM surveys WHERE survey_id = ?", (survey_id,)) |
| survey_row = cursor.fetchone() |
| if not survey_row: |
| flash("Survey not found.", "danger") |
| return redirect(url_for('surveys')) |
| |
| survey_title = survey_row['title'] |
| |
| cursor.execute("SELECT question_id, question_text FROM survey_questions WHERE survey_id = ? ORDER BY question_id ASC", (survey_id,)) |
| questions = [dict(row) for row in cursor.fetchall()] |
| |
| cursor.execute("SELECT response_id, user_id, submitted_at FROM survey_responses WHERE survey_id = ? ORDER BY response_id DESC", (survey_id,)) |
| responses = [dict(row) for row in cursor.fetchall()] |
| |
| cursor.execute("SELECT alumni_id, encrypted_data FROM alumni") |
| alumni_rows = cursor.fetchall() |
| alumni_names = {} |
| for ar in alumni_rows: |
| dec = EncryptionManager.decrypt_string(ar['encrypted_data']) |
| if dec: |
| try: |
| data = json.loads(dec) |
| alumni_names[ar['alumni_id']] = data.get('Name', ar['alumni_id']) |
| except Exception: |
| pass |
| |
| export_data = [] |
| for r in responses: |
| resp_id = r['response_id'] |
| uid = r['user_id'] |
| cursor.execute("SELECT question_id, encrypted_answer FROM survey_answers WHERE response_id = ?", (resp_id,)) |
| answers_map = {row['question_id']: EncryptionManager.decrypt_string(row['encrypted_answer']) for row in cursor.fetchall()} |
| |
| user_display = alumni_names.get(uid, uid) |
| if uid == 'admin': |
| user_display = "Admin (Super User)" |
| |
| row_dict = { |
| 'Member Name / User ID': user_display, |
| 'User ID': uid, |
| 'Submitted At': r['submitted_at'] |
| } |
| |
| for idx, q in enumerate(questions, 1): |
| col_header = f"Q{idx}: {q['question_text']}" |
| row_dict[col_header] = answers_map.get(q['question_id'], '') |
| |
| export_data.append(row_dict) |
| |
| import pandas as pd |
| import io |
| |
| df = pd.DataFrame(export_data) |
| if df.empty: |
| df = pd.DataFrame(columns=['Member Name / User ID', 'User ID', 'Submitted At'] + [f"Q{i+1}: {q['question_text']}" for i, q in enumerate(questions)]) |
| |
| output = io.BytesIO() |
| with pd.ExcelWriter(output, engine='openpyxl') as writer: |
| df.to_excel(writer, index=False, sheet_name='Survey Responses') |
| output.seek(0) |
| |
| filename = f"Survey_Responses_ID{survey_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" |
| return send_file( |
| output, |
| download_name=filename, |
| as_attachment=True, |
| mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def sigint_handler(signum, frame): |
| print("\n\n===================================================================") |
| print("[!] Graceful shutdown initiated via console command (Ctrl+C)...") |
| print("[*] Database checkpoint completed successfully.") |
| print("[*] Shutdown successful. Goodbye!") |
| print("===================================================================\n") |
| sys.stdout.flush() |
| |
| os._exit(0) |
|
|
| |
| signal.signal(signal.SIGINT, sigint_handler) |
|
|
|
|
| @app.route('/shutdown', methods=['POST']) |
| @require_admin |
| def shutdown(): |
| admin_id = session.get('userid') |
| DatabaseManager.log_activity(admin_id, "server_shutdown", "success", "Server shutdown triggered from Admin UI") |
| |
| |
| if is_hf_mode(): |
| token = os.environ.get("HF_TOKEN") |
| if token: |
| logger.info("Syncing databases to Hugging Face before shutting down...") |
| try: |
| from huggingface_hub import HfApi |
| api = HfApi(token=token) |
| api.upload_file( |
| path_or_fileobj=STRUCTURED_DB_FILE, |
| path_in_repo="app/databases/structured.db", |
| repo_id=HF_REPO_ID, |
| repo_type="dataset" |
| ) |
| api.upload_file( |
| path_or_fileobj=BLOBS_DB_FILE, |
| path_in_repo="app/databases/blobs.db", |
| repo_id=HF_REPO_ID, |
| repo_type="dataset" |
| ) |
| logger.info("Final database sync completed successfully.") |
| except Exception as e: |
| logger.error(f"Final sync upload failed: {e}") |
| |
| def terminate(): |
| import time |
| time.sleep(1.5) |
| os.kill(os.getpid(), signal.SIGINT) |
|
|
| threading.Thread(target=terminate, daemon=True).start() |
| return render_template('shutdown.html') |
|
|
|
|
| |
| |
| |
|
|
| @app.route('/privacy') |
| def privacy(): |
| """GDPR Art.13 / DPDPA §5: Privacy Notice — accessible without login.""" |
| return render_template('privacy.html') |
|
|
|
|
| @app.route('/profile/<alumni_id>/export', methods=['GET']) |
| @require_auth |
| def export_my_data(alumni_id): |
| """ |
| GDPR Art.15 / CCPA / DPDPA §11 — Right to Access. |
| Authenticated user downloads their own personal data as JSON. |
| Admin can export any user's data. |
| """ |
| current_userid = session.get('userid') |
| is_admin = session.get('is_admin') |
|
|
| if not is_admin and current_userid != alumni_id: |
| abort(403) |
|
|
| |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT * FROM alumni WHERE alumni_id = ?", (alumni_id,)) |
| alumni_row = cursor.fetchone() |
| |
| cursor.execute( |
| "SELECT timestamp, action, status FROM activity_log WHERE userid = ? ORDER BY timestamp DESC LIMIT 500", |
| (alumni_id,) |
| ) |
| log_rows = [{"timestamp": r['timestamp'], "action": r['action'], "status": r['status']} for r in cursor.fetchall()] |
| |
| cursor.execute("SELECT survey_id, submitted_at FROM survey_responses WHERE user_id = ?", (alumni_id,)) |
| survey_rows = [{"survey_id": r['survey_id'], "submitted_at": r['submitted_at']} for r in cursor.fetchall()] |
|
|
| profile_data = {} |
| if alumni_row: |
| decrypted = EncryptionManager.decrypt_string(alumni_row['encrypted_data']) |
| profile_data = json.loads(decrypted) if decrypted else {} |
|
|
| export_payload = { |
| "export_generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S UTC"), |
| "alumni_id": alumni_id, |
| "profile": profile_data, |
| "activity_log": log_rows, |
| "survey_participations": survey_rows, |
| "data_controller": "REC Durgapur 1988 Alumni Association", |
| "rights_notice": "Under GDPR Art.15, CCPA, and DPDPA you have the right to access, correct, and request erasure of your personal data. Contact your administrator to exercise these rights." |
| } |
|
|
| export_json = json.dumps(export_payload, indent=2, ensure_ascii=False) |
| buf = BytesIO(export_json.encode('utf-8')) |
| buf.seek(0) |
|
|
| DatabaseManager.log_activity(current_userid, "data_export", "success", |
| f"GDPR data export for alumni_id={alumni_id}") |
| return send_file( |
| buf, |
| as_attachment=True, |
| download_name=f"my_data_{alumni_id}_{datetime.now().strftime('%Y%m%d')}.json", |
| mimetype='application/json' |
| ) |
|
|
|
|
| @app.route('/profile/<alumni_id>/delete-account', methods=['POST']) |
| @require_auth |
| def delete_account(alumni_id): |
| """ |
| GDPR Art.17 / CCPA / DPDPA — Right to Erasure (admin-only). |
| Anonymizes the profile and removes all personal data blobs. |
| Retains an anonymized audit record per legal obligation. |
| """ |
| if not session.get('is_admin'): |
| abort(403) |
|
|
| current_userid = session.get('userid') |
| confirm = request.form.get('confirm_delete', '').strip() |
| if confirm != f'DELETE-{alumni_id}': |
| flash("Deletion confirmation text did not match. No data was changed.", "danger") |
| return redirect(url_for('admin')) |
|
|
| |
| anon_data = { |
| "Name": "[Deleted]", |
| "Email": "", |
| "Phone": "", |
| "WhatsApp": "", |
| "Present Address": "", |
| "Org / Designation": "", |
| "_gdpr_erasure": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), |
| "_erased_by": current_userid |
| } |
| encrypted_anon = EncryptionManager.encrypt_string(json.dumps(anon_data)) |
|
|
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("UPDATE alumni SET encrypted_data = ?, updated_at = ? WHERE alumni_id = ?", |
| (encrypted_anon, datetime.now().strftime("%Y-%m-%d %H:%M:%S"), alumni_id)) |
| |
| cursor.execute("UPDATE users SET password_hash = NULL, totp_secret_encrypted = NULL, active = 0 WHERE username = ?", |
| (alumni_id,)) |
| conn.commit() |
|
|
| |
| with DatabaseManager.get_blobs_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("UPDATE profile_blobs SET profile_writeup = NULL, self_photo = NULL, family_photo = NULL WHERE alumni_id = ?", |
| (alumni_id,)) |
| cursor.execute("DELETE FROM profile_photos WHERE alumni_id = ?", (alumni_id,)) |
| conn.commit() |
|
|
| |
| sync_to_hf_sync(STRUCTURED_DB_FILE, "app/databases/structured.db") |
| sync_to_hf_async(BLOBS_DB_FILE, "app/databases/blobs.db") |
|
|
| DatabaseManager.log_activity(current_userid, "gdpr_erasure", "success", |
| f"Profile anonymized for alumni_id={alumni_id} per right-to-erasure request") |
| flash(f"Account {alumni_id} has been anonymized and all personal data removed (GDPR Right to Erasure).", "success") |
| return redirect(url_for('admin')) |
|
|
|
|
| @app.route('/admin/verify-log-integrity') |
| @require_auth |
| def verify_log_integrity(): |
| """ |
| Phase 7 / SOC 2: Verify tamper-evident audit log hash chain integrity. |
| Admin-only. Returns JSON report. |
| """ |
| if not session.get('is_admin'): |
| abort(403) |
|
|
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT id, timestamp, userid, action, status, details, log_hash FROM activity_log ORDER BY id ASC") |
| rows = cursor.fetchall() |
|
|
| prev_hash = 'GENESIS' |
| violations = [] |
| checked = 0 |
|
|
| for row in rows: |
| if not row['log_hash']: |
| prev_hash = 'GENESIS' |
| continue |
| details_str = str(row['details']) if row['details'] is not None else "" |
| chain_input = f"{row['timestamp']}|{row['userid']}|{row['action']}|{row['status']}|{details_str}|{prev_hash}" |
| expected_hash = hashlib.sha256(chain_input.encode('utf-8')).hexdigest() |
| if row['log_hash'] != expected_hash: |
| violations.append({"id": row['id'], "expected": expected_hash, "stored": row['log_hash']}) |
| prev_hash = row['log_hash'] |
| checked += 1 |
|
|
|
|
| result = { |
| "checked": checked, |
| "violations": len(violations), |
| "integrity": "PASS" if not violations else "FAIL", |
| "violation_ids": [v['id'] for v in violations] |
| } |
| DatabaseManager.log_activity(session.get('userid'), "log_integrity_check", "success" if not violations else "warning", |
| f"Checked {checked} log entries, {len(violations)} violations found") |
| return Response(json.dumps(result, indent=2), mimetype='application/json') |
|
|
|
|
| |
| |
| |
|
|
| def get_network_addresses(): |
| """Retrieve network IP addresses for console banner display.""" |
| ips = [] |
| |
| try: |
| s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) |
| s.connect(("8.8.8.8", 80)) |
| local_ip = s.getsockname()[0] |
| s.close() |
| if local_ip and local_ip != "127.0.0.1": |
| ips.append(local_ip) |
| except Exception: |
| pass |
|
|
| |
| try: |
| host_ip = socket.gethostbyname(socket.gethostname()) |
| if host_ip and host_ip != "127.0.0.1" and host_ip not in ips: |
| ips.append(host_ip) |
| except Exception: |
| pass |
|
|
| if not ips: |
| ips.append("127.0.0.1") |
| return ips |
|
|
|
|
| def print_console_splash_screen(port=None): |
| if port is None: |
| port = int(os.environ.get('PORT', 7860)) |
|
|
| hostname = socket.gethostname() |
| network_ips = get_network_addresses() |
| primary_ip = network_ips[0] |
| mode_str = "Hugging Face Space" if is_hf_mode() else "Local / On-Premise" |
| space_id = os.environ.get("SPACE_ID", os.environ.get("SPACE_REPO_NAME", "N/A")) |
|
|
| banner = f""" |
| ================================================================================ |
| ____ _____ ____ ____ |
| | _ \\| ____/ ___| | _ \\ _ _ _ __ __ _ __ _ _ __ _ _ _ __ |
| | |_) | _|| | | | | | | | | '__/ _` |/ _` | '_ \\| | | | '__| |
| | _ <| |__| |___ | |_| | |_| | | | (_| | (_| | |_) | |_| | | |
| |_| \\_\\_____\\____| |____/ \\__,_|_| \\__, |\\__,_| .__/ \\__,_|_| |
| |___/ |_| |
| ____ _ _ ___ __ ___ ___ |
| | __ ) __ _| |_ ___| |__ / _ \\/ \\( _ )( _ ) |
| | _ \\ / _` | __/ __| '_ \\ | | | | () / _ \\/ _ \\ |
| | |_) | (_| | || (__| | | | | |_| | > _ \\ _ | |
| |____/ \\__,_|\\__\\___|_| |_| \\___/ \\__/\\___/\\___/ |
| |
| 🎓 REC DURGAPUR BATCH OF 1988 - YEARBOOK DIRECTORY SYSTEM 🎓 |
| - Secure End-to-End Encrypted Database & Memory Portal - |
| ================================================================================ |
| [🌐 SERVER NETWORK & ADDRESS INFORMATION] |
| - Hostname : {hostname} |
| - Listening Host : 0.0.0.0 |
| - Running Port : {port} |
| - Primary Server IP : {primary_ip} |
| - Local Access URL : http://localhost:{port} (or http://127.0.0.1:{port}) |
| - Network Access URL : http://{primary_ip}:{port}""" |
|
|
| if len(network_ips) > 1: |
| for extra_ip in network_ips[1:]: |
| banner += f"\n - Additional LAN IP : http://{extra_ip}:{port}" |
|
|
| if is_hf_mode() and space_id != "N/A": |
| banner += f"\n - HF Space URL : https://huggingface.co/spaces/{space_id}" |
|
|
| banner += f""" |
| -------------------------------------------------------------------------------- |
| [⚙️ RUNTIME ENVIRONMENT] |
| - Deployment Mode : {mode_str} |
| - Python Version : {sys.version.split()[0]} |
| - System Platform : {platform.system()} ({platform.machine()}) |
| - Encryption Engine : Fernet AES-256 (2048-bit RSA + PBKDF2 HMAC SHA-256) |
| - Active Databases : structured.db (Metadata) | blobs.db (Media Assets) |
| ================================================================================ |
| """ |
| print(banner) |
| sys.stdout.flush() |
|
|
|
|
| |
| |
| |
|
|
| MAINTENANCE_MODE = False |
|
|
| def check_database_health() -> bool: |
| """ |
| Verifies production database health. |
| Returns True if database files and required user/alumni schemas exist; |
| Returns False if database is uninitialized or missing. |
| """ |
| if not os.path.exists(STRUCTURED_DB_FILE) or not os.path.exists(BLOBS_DB_FILE): |
| return False |
| try: |
| with DatabaseManager.get_structured_connection() as conn: |
| cursor = conn.cursor() |
| cursor.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('alumni', 'users')") |
| table_count = cursor.fetchone()[0] |
| if table_count < 2: |
| return False |
| cursor.execute("SELECT COUNT(*) FROM users") |
| user_count = cursor.fetchone()[0] |
| return user_count > 0 |
| except Exception as e: |
| logger.error(f"Database health check exception: {e}") |
| return False |
|
|
|
|
| @app.before_request |
| def enforce_maintenance_mode(): |
| """Intercepts requests and displays 503 Maintenance Page if database is uninitialized.""" |
| if MAINTENANCE_MODE: |
| |
| if request.path.startswith('/static'): |
| return None |
| return render_template('maintenance.html'), 503 |
|
|
|
|
| @app.after_request |
| def add_security_headers(response): |
| """Enforces OWASP standard security headers on all application HTTP responses.""" |
| response.headers['X-Frame-Options'] = 'SAMEORIGIN' |
| response.headers['X-Content-Type-Options'] = 'nosniff' |
| response.headers['X-XSS-Protection'] = '1; mode=block' |
| response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' |
| return response |
|
|
|
|
| @app.errorhandler(500) |
| def handle_internal_server_error(error): |
| """Sanitizes unhandled exceptions to prevent stack trace leakage.""" |
| logger.error(f"Unhandled Server Error: {error}") |
| return render_template('base.html', error_message="An internal server error occurred. Please try again later."), 500 |
|
|
|
|
| def initialize_application(): |
| global MAINTENANCE_MODE |
| suppress_banner = os.environ.get("RECDGP88_SUPPRESS_BANNER") == "1" |
| port = int(os.environ.get('PORT', 7860)) |
| if not suppress_banner: |
| print_console_splash_screen(port=port) |
| print("[*] Starting REC Durgapur '88 directory web app initialization...") |
| print("[*] 1. Checking Hugging Face Dataset synchronization... ", end="") |
| sys.stdout.flush() |
| sync_all_from_hf() |
| if not suppress_banner: |
| print("[OK]") |
| print("[*] 2. Checking secure encryption manager... ", end="") |
| sys.stdout.flush() |
| real_key = EncryptionManager.reload_key() |
| import hashlib as _hashlib |
| app.secret_key = _hashlib.sha256(real_key).hexdigest() |
| logger.info("Flask app.secret_key re-derived from reloaded encryption key.") |
| if not suppress_banner: |
| print("[OK]") |
| print("[*] 3. Verifying production database health... ", end="") |
| sys.stdout.flush() |
| if not check_database_health(): |
| logger.warning("Database missing or uninitialized. Enabling Maintenance Mode.") |
| MAINTENANCE_MODE = True |
| if not suppress_banner: |
| print("[MAINTENANCE MODE - Run recdgp88_init.py to initialize]") |
| else: |
| MAINTENANCE_MODE = False |
| if not suppress_banner: |
| print("[OK - DATABASE HEALTHY]") |
| |
| if is_hf_mode() and not MAINTENANCE_MODE: |
| logger.info("Triggering post-initialization database sync to Hugging Face...") |
| sync_to_hf_async(STRUCTURED_DB_FILE, "app/databases/structured.db") |
| sync_to_hf_async(BLOBS_DB_FILE, "app/databases/blobs.db") |
| |
| if not suppress_banner: |
| net_ips = get_network_addresses() |
| primary_ip = net_ips[0] |
| print("\n[*] Application initialized and ready. Graceful shutdown enabled (Ctrl+C).") |
| print(f"[*] Access URLs:") |
| print(f" - Local : http://localhost:{port} (or http://127.0.0.1:{port})") |
| print(f" - Network : http://{primary_ip}:{port}") |
| if MAINTENANCE_MODE: |
| print("[!] NOTICE: Site is serving 503 Maintenance Page until database is initialized via recdgp88_init.py.") |
| print("===================================================================\n") |
| sys.stdout.flush() |
| if not MAINTENANCE_MODE: |
| DatabaseManager.cleanup_old_audit_logs(retention_days=365) |
|
|
|
|
| if os.environ.get("RECDGP88_SUPPRESS_BANNER") != "1": |
| initialize_application() |
|
|
| if __name__ == '__main__': |
| initialize_application() |
| port = int(os.environ.get('PORT', 7860)) |
| app.run(host='0.0.0.0', port=port, debug=False) |
|
|
|
|