diff --git a/main_app.py b/main_app.py index fc0c73d..10e337b 100644 --- a/main_app.py +++ b/main_app.py @@ -1,2431 +1,1430 @@ # ---------------------------------------- - -# UrNetwork Stats Dashboard - Refactored - +# UrNetwork Stats Dashboard - Refactored & Enhanced # ---------------------------------------- - # Original Author: https://github.com/techroy23/UrNetwork-Stats-Dashboard - # Refactored with Public/Private views, UI, Graphs, Installer, Auth, Dark Theme, and Provider Map. - +# Enhanced with categorized, configurable features from the API and separate dashboard pages. # - # --- main_app.py --- - # ---------------------------------------- - import os - import time - import datetime - import requests - import logging - import json - from functools import wraps - from flask import ( - Flask, request, render_template_string, - redirect, url_for, flash, session, g, jsonify - ) - from flask_sqlalchemy import SQLAlchemy - from flask_apscheduler import APScheduler - from dateutil.parser import isoparse - +import secrets # --- Application Setup & Configuration --- - # Configure logging - logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') - # Environment file path - ENV_FILE = ".env" - def load_env(): - """Load environment variables from .env file.""" - if os.path.exists(ENV_FILE): - with open(ENV_FILE, 'r') as f: - for line in f: - - if '=' in line: - + if '=' in line and not line.strip().startswith('#'): key, value = line.strip().split('=', 1) - os.environ[key] = value - load_env() - class Config: - """Flask configuration.""" - SCHEDULER_API_ENABLED = True - SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", "sqlite:///transfer_stats.db") - SQLALCHEMY_TRACK_MODIFICATIONS = False - SECRET_KEY = os.getenv("SECRET_KEY", "default-secret-key-for-initial-setup") - UR_API_BASE = "https://api.bringyour.com" - + # --- New Feature Flags (Defaults) --- + # These will be used to initialize the settings in the database on first run. + ENABLE_ACCOUNT_STATS = os.getenv("ENABLE_ACCOUNT_STATS", "True").lower() in ('true', '1', 't') + ENABLE_LEADERBOARD = os.getenv("ENABLE_LEADERBOARD", "True").lower() in ('true', '1', 't') + ENABLE_DEVICE_STATS = os.getenv("ENABLE_DEVICE_STATS", "True").lower() in ('true', '1', 't') # --- Flask App Initialization --- - app = Flask(__name__) - app.config.from_object(Config) - db = SQLAlchemy(app) - scheduler = APScheduler() - scheduler.init_app(app) - # --- Database Models --- - class Stats(db.Model): - - """ - - Represents a snapshot of paid vs unpaid bytes at a given timestamp. - - """ - + """Represents a snapshot of paid vs unpaid bytes at a given timestamp.""" __tablename__ = 'stats' - id = db.Column(db.Integer, primary_key=True) - timestamp = db.Column(db.DateTime, server_default=db.func.now()) - paid_bytes = db.Column(db.BigInteger, nullable=False) - paid_gb = db.Column(db.Float, nullable=False) - unpaid_bytes = db.Column(db.BigInteger, nullable=False) - unpaid_gb = db.Column(db.Float, nullable=False) - class Webhook(db.Model): - """Represents a webhook URL.""" - __tablename__ = 'webhook' - id = db.Column(db.Integer, primary_key=True) - url = db.Column(db.String, unique=True, nullable=False) +class Setting(db.Model): + """Represents a key-value setting for the application, like feature flags.""" + __tablename__ = 'settings' + key = db.Column(db.String(50), primary_key=True) + value = db.Column(db.String(100), nullable=False) # --- Helper Functions --- +def get_setting(key, default=None): + """Gets a setting value from the database.""" + setting = Setting.query.get(key) + return setting.value if setting else default + +def get_boolean_setting(key): + """Gets a boolean setting from the database.""" + value = get_setting(key, 'False') + return value.lower() in ('true', '1', 't') def is_installed(): - """Check if the application has been configured.""" - return all(os.getenv(var) for var in ["UR_USER", "UR_PASS", "SECRET_KEY"]) - def save_env_file(config_data): - """Save configuration data to the .env file.""" - try: - with open(ENV_FILE, "w") as f: - for key, value in config_data.items(): - f.write(f"{key.upper()}={value}\n") - + # Also write the initial feature flag settings + f.write("\n# Feature Flags (can be managed in the UI after install)\n") + f.write(f"ENABLE_ACCOUNT_STATS=True\n") + f.write(f"ENABLE_LEADERBOARD=True\n") + f.write(f"ENABLE_DEVICE_STATS=True\n") load_env() # Reload env after writing - return True - except IOError as e: - logging.error(f"Failed to write to .env file: {e}") - return False - def request_with_retry(method, url, retries=3, backoff=5, timeout=30, **kwargs): - """Issue an HTTP request with retries.""" - last_exc = None - for attempt in range(1, retries + 1): - try: - resp = requests.request(method, url, timeout=timeout, **kwargs) - resp.raise_for_status() - return resp - except requests.exceptions.RequestException as e: - last_exc = e - logging.warning( - f"[{method.upper()} {url}] attempt {attempt}/{retries} failed: {e}" - ) - if attempt < retries: - time.sleep(backoff) - - raise RuntimeError(f"All {retries} attempts to {method.upper()} {url} failed: {last_exc}") - + # Instead of raising a fatal RuntimeError, return None to handle gracefully + logging.error(f"All {retries} attempts to {method.upper()} {url} failed: {last_exc}") + return None def get_jwt_from_credentials(user, password): - """Fetch a new JWT token using username and password.""" - try: - resp = request_with_retry( - "post", - f"{app.config['UR_API_BASE']}/auth/login-with-password", - headers={"Content-Type": "application/json"}, - json={"user_auth": user, "password": password}, - ) - + if not resp: + raise RuntimeError("API request failed after multiple retries.") data = resp.json() - token = data.get("network", {}).get("by_jwt") - if not token: - err = data.get("message") or data.get("error") or str(data) - raise RuntimeError(f"Login failed: {err}") - return token - except Exception as e: - logging.error(f"Could not get JWT from credentials: {e}") - return None - def get_valid_jwt(): - """Gets a valid JWT for API calls.""" - user = os.getenv("UR_USER") - password = os.getenv("UR_PASS") - - if not user or not password: - logging.error("Cannot fetch JWT; user credentials not found in environment.") - return None - - return get_jwt_from_credentials(user, password) - +# --- API Fetch Functions --- def fetch_transfer_stats(jwt_token): - """Retrieve transfer statistics using the provided JWT.""" - - headers = {"Authorization": f"Bearer {jwt_token}", "Accept": "*/*"} - - resp = request_with_retry("get", f"{app.config['UR_API_BASE']}/transfer/stats", headers=headers) - + if not jwt_token: return None + resp = request_with_retry("get", f"{app.config['UR_API_BASE']}/transfer/stats", headers={"Authorization": f"Bearer {jwt_token}"}) + if not resp: return None data = resp.json() - paid = data.get("paid_bytes_provided", 0) - unpaid = data.get("unpaid_bytes_provided", 0) - return { - "paid_bytes": paid, - "paid_gb": paid / 1e9, - "unpaid_bytes": unpaid, - "unpaid_gb": unpaid / 1e9 - } - def fetch_payment_stats(jwt_token): - """Retrieve account payment statistics using the provided JWT.""" + if not jwt_token: return [] + resp = request_with_retry("get", f"{app.config['UR_API_BASE']}/account/payments", headers={"Authorization": f"Bearer {jwt_token}"}) + if not resp: return [] + return resp.json().get("account_payments", []) - if not jwt_token: +def fetch_account_details(jwt_token): + """Fetches various account details like points and referrals.""" + if not jwt_token: return {} + headers = {"Authorization": f"Bearer {jwt_token}"} + details = {} + + points_resp = request_with_retry("get", f"{app.config['UR_API_BASE']}/account/points", headers=headers) + if points_resp: + points_data = points_resp.json().get("network_points", []) + details['points'] = sum(p.get('point_value', 0) for p in points_data) + + referral_resp = request_with_retry("get", f"{app.config['UR_API_BASE']}/account/referral-code", headers=headers) + if referral_resp: + details['referrals'] = referral_resp.json() - return None + ranking_resp = request_with_retry("get", f"{app.config['UR_API_BASE']}/network/ranking", headers=headers) + if ranking_resp: + details['ranking'] = ranking_resp.json().get('network_ranking', {}) - try: + return details - headers = {"Authorization": f"Bearer {jwt_token}", "Accept": "*/*"} +def fetch_leaderboard(jwt_token): + """Fetches the global leaderboard.""" + if not jwt_token: return [] + headers = {"Authorization": f"Bearer {jwt_token}"} + resp = request_with_retry("post", f"{app.config['UR_API_BASE']}/stats/leaderboard", headers=headers, json={}) + if not resp: return [] + return resp.json().get("earners", []) - resp = request_with_retry("get", f"{app.config['UR_API_BASE']}/account/payments", headers=headers) - - return resp.json().get("account_payments", []) - - except Exception as e: - - logging.error(f"Failed to fetch payment stats: {e}") - - return [] +def fetch_devices(jwt_token): + """Fetches the status of all network clients/devices.""" + if not jwt_token: return [] + headers = {"Authorization": f"Bearer {jwt_token}"} + resp = request_with_retry("get", f"{app.config['UR_API_BASE']}/network/clients", headers=headers) + if not resp: return [] + return resp.json().get("clients", []) +def remove_device(jwt_token, client_id): + """Removes a device from the network.""" + if not jwt_token: return False, "Authentication token not available." + headers = {"Authorization": f"Bearer {jwt_token}"} + payload = {"client_id": client_id} + resp = request_with_retry("post", f"{app.config['UR_API_BASE']}/network/remove-client", headers=headers, json=payload) + if resp and resp.status_code == 200: + data = resp.json() + if data.get("error"): + return False, data["error"].get("message", "An unknown error occurred.") + return True, "Device removed successfully." + elif resp: + return False, f"API returned status {resp.status_code}." + else: + return False, "Failed to communicate with API." def calculate_earnings(payments): - """Calculate total and monthly earnings from a list of payments.""" - total_earnings = 0 - monthly_earnings = 0 - now = datetime.datetime.now(datetime.timezone.utc) - one_month_ago = now - datetime.timedelta(days=30) - if not payments: - return 0, 0 - for payment in payments: - if payment.get("completed"): - amount = payment.get("token_amount", 0) - total_earnings += amount - payment_time_str = payment.get("payment_time") - if payment_time_str: - try: - payment_time = isoparse(payment_time_str) - if payment_time > one_month_ago: - monthly_earnings += amount - - except ValueError: - + except (ValueError, TypeError): logging.warning(f"Could not parse payment_time: {payment_time_str}") - return total_earnings, monthly_earnings - - def fetch_provider_locations(): - """Retrieve provider locations from the public API.""" - - try: - - resp = request_with_retry("get", f"{app.config['UR_API_BASE']}/network/provider-locations") - - return resp.json() - - except Exception as e: - - logging.error(f"Failed to fetch provider locations: {e}") - - return None - + resp = request_with_retry("get", f"{app.config['UR_API_BASE']}/network/provider-locations") + return resp.json() if resp else None def send_webhook_notification(stats_data): - """Sends a notification to all configured webhooks.""" - webhooks = Webhook.query.all() - - if not webhooks: - - return - - - # A more generic payload that might work for Discord, Slack, etc. + if not webhooks: return payload = { - "embeds": [{ - "title": "UrNetwork Stats Update", - "description": "New data has been synced from the UrNetwork API.", - - "color": 5814783, # A nice blue color - + "color": 5814783, "fields": [ - {"name": "Total Paid Data", "value": f"{stats_data['paid_gb']:.3f} GB", "inline": True}, - {"name": "Total Unpaid Data", "value": f"{stats_data['unpaid_gb']:.3f} GB", "inline": True}, - {"name": "Total Data Provided", "value": f"{(stats_data['paid_gb'] + stats_data['unpaid_gb']):.3f} GB", "inline": True}, - ], - "footer": {"text": f"Update Time: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"} - }] - } - - for webhook in webhooks: - try: - requests.post(webhook.url, json=payload, timeout=10) - logging.info(f"Sent webhook notification to {webhook.url}") - except requests.exceptions.RequestException as e: - logging.error(f"Failed to send webhook to {webhook.url}: {e}") - - # --- Authentication Decorator --- - def login_required(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if not session.get('logged_in'): - flash("You must be logged in to view this page.", "error") - return redirect(url_for('login', next=request.url)) - return f(*args, **kwargs) - return decorated_function - # --- Scheduled Job --- - @scheduler.task(id="log_stats_job", trigger="cron", minute="0,15,30,45") - def log_stats_job(): - """Scheduled job to fetch and store stats every 15 minutes.""" - with app.app_context(): - if not is_installed(): - logging.warning("log_stats_job skipped: Application is not installed.") - return - - logging.info("Running scheduled stats fetch...") - try: - - jwt = get_valid_jwt() - - if not jwt: - - raise RuntimeError("Scheduled job could not authenticate with API.") - - + jwt = get_valid_jwt() + if not jwt: raise RuntimeError("Scheduled job could not authenticate with API.") stats_data = fetch_transfer_stats(jwt) + if not stats_data: raise RuntimeError("Scheduled job could not fetch transfer stats.") entry = Stats( - - paid_bytes=stats_data["paid_bytes"], - - paid_gb=stats_data["paid_gb"], - - unpaid_bytes=stats_data["unpaid_bytes"], - - unpaid_gb=stats_data["unpaid_gb"] - + paid_bytes=stats_data["paid_bytes"], paid_gb=stats_data["paid_gb"], + unpaid_bytes=stats_data["unpaid_bytes"], unpaid_gb=stats_data["unpaid_gb"] ) - db.session.add(entry) - db.session.commit() - logging.info(f"Logged new stats entry at {entry.timestamp}") - - - send_webhook_notification(stats_data) - - except Exception as e: - logging.error(f"Scheduled job 'log_stats_job' failed: {e}") - - # --- HTML Templates --- - LAYOUT_TEMPLATE = """ - - - - - - {{ title }} - UrNetwork Stats - - - - + - - - - - - - -
- -
-
-

{{ title }}

-
-
- -
-
- {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} - - {% endfor %} - {% endif %} - {% endwith %} - {{ content | safe }} -
-
-
- -