Compare commits

..

No commits in common. "main" and "v1.0.6" have entirely different histories.
main ... v1.0.6

27 changed files with 732 additions and 1731 deletions

View File

@ -1,8 +0,0 @@
[tool.bumpversion]
current_version = "1.4.10"
commit = true
tag = true
tag_name = "{new_version}"
[[tool.bumpversion.files]]
filename = "VERSION"

View File

@ -1,21 +0,0 @@
# Git
.git
.gitignore
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
.venv/
venv/
env/
# IDE/Editor
.vscode/
.idea/
# Build artifacts
dockerfile
.dockerignore
*.sample

View File

@ -3,7 +3,7 @@ name: Build and Publish Docker Image
on: on:
push: push:
tags: tags:
- "*.*.*" - "v*.*.*"
workflow_dispatch: workflow_dispatch:
jobs: jobs:

24
.vscode/launch.json vendored
View File

@ -1,25 +1,17 @@
{ {
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Python: Flask", "name": "Python Debugger: Current File",
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"module": "flask", "program": "${file}",
"env": { "console": "integratedTerminal",
"FLASK_APP": "app.py", "justMyCode": false,
"FLASK_ENV": "development" "args": ["--host=0.0.0.0"]
},
"args": [
"run",
"--no-debugger",
"--port",
"5005",
"--host",
"0.0.0.0"
],
"jinja": true,
"justMyCode": true
} }
] ]
} }

View File

@ -1 +0,0 @@
1.4.10

566
app.py
View File

@ -1,64 +1,37 @@
# app.py # app.py
from flask import (Flask, render_template, request, redirect, url_for, session, from flask import Flask, render_template, request, redirect, url_for, session, send_file, jsonify
send_file, jsonify, send_from_directory, Response)
from flask_caching import Cache from flask_caching import Cache
import requests.auth import requests.auth
import os import os
import base64
from typing import Dict, Any, Tuple, Union
import sys
import redis
import json
import mysql.connector
import re
import threading
from lib.datetime import filter_accounts_next_30_days, filter_accounts_expired from lib.datetime import filter_accounts_next_30_days, filter_accounts_expired
from lib.reqs import (get_urls, get_user_accounts, add_user_account, from lib.reqs import get_urls, get_user_accounts, add_user_account, delete_user_account, get_user_accounts_count, get_stream_names
delete_user_account, get_stream_names) from flask import send_from_directory
from config import DevelopmentConfig, ProductionConfig import requests
import base64
from flask import Flask
from config import DevelopmentConfig
from paddleocr import PaddleOCR
from PIL import Image
import numpy as np
os.environ["OMP_NUM_THREADS"] = "1" os.environ["OMP_NUM_THREADS"] = "1"
os.environ["MKL_NUM_THREADS"] = "1" os.environ["MKL_NUM_THREADS"] = "1"
app = Flask(__name__) app = Flask(__name__)
app.config.from_object(
DevelopmentConfig
)
cache = Cache(app, config={"CACHE_TYPE": "SimpleCache"})
if os.environ.get("FLASK_ENV") == "production": ocr = PaddleOCR(use_angle_cls=True, lang='en') # Adjust language if needed
app.config.from_object(ProductionConfig)
else:
app.config.from_object(DevelopmentConfig)
# Check for Redis availability and configure cache app.config['SESSION_COOKIE_SECURE'] = True # Only send cookie over HTTPS
redis_url = app.config["REDIS_URL"] app.config['SESSION_COOKIE_HTTPONLY'] = True # Prevent JavaScript access
cache_config = {"CACHE_TYPE": "redis", "CACHE_REDIS_URL": redis_url} app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # Adjust for cross-site requests
try: app.config['PERMANENT_SESSION_LIFETIME'] = 60 * 60 * 24 * 365 # 1 year in seconds
# Use a short timeout to prevent hanging cache.clear() # Clears all cache entries
r = redis.from_url(redis_url, socket_connect_timeout=1)
r.ping()
except redis.exceptions.ConnectionError as e:
print(
f"WARNING: Redis connection failed: {e}. Falling back to SimpleCache. "
"This is not recommended for production with multiple workers.",
file=sys.stderr,
)
cache_config = {"CACHE_TYPE": "SimpleCache"}
cache = Cache(app, config=cache_config) def get_version():
app.config["OCR_ENABLED"] = False
app.config["SESSION_COOKIE_SECURE"] = not app.config["DEBUG"]
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
app.config['PERMANENT_SESSION_LIFETIME'] = 60 * 60 * 24 * 365 # 1 year
def get_version() -> str:
"""Retrieves the application version from the VERSION file.
Returns:
The version string, or 'dev' if the file is not found.
"""
try: try:
with open('VERSION', 'r') as f: with open('VERSION', 'r') as f:
return f.read().strip() return f.read().strip()
@ -66,489 +39,168 @@ def get_version() -> str:
return 'dev' return 'dev'
@app.context_processor @app.context_processor
def inject_version() -> Dict[str, str]: def inject_version():
"""Injects the version into all templates.""" return dict(version=get_version())
return dict(version=get_version(), config=app.config, session=session)
def make_cache_key(*args, **kwargs):
"""Generate a cache key based on the user's session and request path."""
username = session.get('username', 'anonymous')
path = request.path
return f"view/{username}/{path}"
@app.before_request @app.before_request
def make_session_permanent() -> None: def make_session_permanent():
"""Makes the user session permanent."""
session.permanent = True session.permanent = True
@app.route('/site.webmanifest') @app.route('/manifest.json')
def serve_manifest() -> Response: def serve_manifest():
"""Serves the site manifest file.""" return send_file('manifest.json', mimetype='application/manifest+json')
return send_from_directory(
os.path.join(app.root_path, 'static'),
'site.webmanifest',
mimetype='application/manifest+json'
)
@app.route("/favicon.ico") @app.route("/favicon.ico")
def favicon() -> Response: def favicon():
"""Serves the favicon."""
return send_from_directory( return send_from_directory(
os.path.join(app.root_path, "static"), os.path.join(app.root_path, "static"),
"favicon.ico", "favicon.ico",
mimetype="image/vnd.microsoft.icon", mimetype="image/vnd.microsoft.icon",
) )
@app.route("/") @app.route("/")
def index() -> Union[Response, str]: def index():
"""Renders the index page or redirects to home if logged in.""" # If the user is logged in, redirect to a protected page like /accounts
if session.get("logged_in"): if session.get("logged_in"):
return redirect(url_for("home")) return redirect(url_for("home"))
return render_template("index.html") return render_template("index.html")
@app.route('/vapid-public-key', methods=['GET'])
def proxy_vapid_public_key():
"""Proxies the request for the VAPID public key to the backend."""
backend_url = f"{app.config['BACKEND_URL']}/vapid-public-key"
try:
response = requests.get(backend_url)
return Response(response.content, status=response.status_code, mimetype=response.headers['Content-Type'])
except requests.exceptions.RequestException as e:
return jsonify({"error": str(e)}), 502
@app.route('/save-subscription', methods=['POST'])
def proxy_save_subscription():
"""Proxies the request to save a push subscription to the backend."""
if not session.get("logged_in"):
return jsonify({'error': 'Unauthorized'}), 401
backend_url = f"{app.config['BACKEND_URL']}/save-subscription"
credentials = base64.b64decode(session["auth_credentials"]).decode()
username, password = credentials.split(":", 1)
try:
response = requests.post(
backend_url,
auth=requests.auth.HTTPBasicAuth(username, password),
json=request.get_json()
)
return Response(response.content, status=response.status_code, mimetype=response.headers['Content-Type'])
except requests.exceptions.RequestException as e:
return jsonify({"error": str(e)}), 502
@app.route('/send-test-notification', methods=['POST'])
def send_test_notification():
"""Proxies the request to send a test notification to the backend."""
if not session.get("logged_in"):
return jsonify({'error': 'Unauthorized'}), 401
backend_url = f"{app.config['BACKEND_URL']}/send-test-notification"
credentials = base64.b64decode(session["auth_credentials"]).decode()
username, password = credentials.split(":", 1)
try:
response = requests.post(
backend_url,
auth=requests.auth.HTTPBasicAuth(username, password),
json={}
)
return Response(response.content, status=response.status_code, mimetype=response.headers['Content-Type'])
except requests.exceptions.RequestException as e:
return jsonify({"error": str(e)}), 502
@app.route("/home") @app.route("/home")
@cache.cached(timeout=60, key_prefix=make_cache_key) @cache.cached(timeout=60) # cache for 120 seconds
def home() -> str: def home():
"""Renders the home page with account statistics."""
if session.get("logged_in"): if session.get("logged_in"):
base_url = app.config["BACKEND_URL"] base_url = app.config["BASE_URL"] # Access base_url from the config
all_accounts = get_user_accounts(base_url, session["auth_credentials"]) all_accounts = get_user_accounts(base_url, session["auth_credentials"])
count = len(all_accounts)
current_month_accounts = filter_accounts_next_30_days(all_accounts)
expired_accounts = filter_accounts_expired(all_accounts)
return render_template( return render_template(
"home.html", "home.html",
username=session["username"], username=session["username"],
accounts=len(all_accounts), accounts=count,
current_month_accounts=filter_accounts_next_30_days(all_accounts), current_month_accounts=current_month_accounts,
expired_accounts=filter_accounts_expired(all_accounts), expired_accounts=expired_accounts,
) )
return render_template("index.html") return render_template("index.html")
@app.route("/login", methods=["POST"]) @app.route("/login", methods=["POST"])
def login() -> Union[Response, str]: def login():
"""Handles user login."""
username = request.form["username"] username = request.form["username"]
password = request.form["password"] password = request.form["password"]
# Encode the username and password in Base64
credentials = f"{username}:{password}" credentials = f"{username}:{password}"
encoded_credentials = base64.b64encode(credentials.encode()).decode() encoded_credentials = base64.b64encode(credentials.encode()).decode()
base_url = app.config["BACKEND_URL"]
login_url = f"{base_url}/Login"
try: base_url = app.config["BASE_URL"] # Access base_url from the config
response = requests.get( login_url = f"{base_url}/Login" # Construct the full URL
login_url, auth=requests.auth.HTTPBasicAuth(username, password)
) # Send GET request to the external login API with Basic Auth
response.raise_for_status() response = requests.get(
response_data = response.json() login_url, auth=requests.auth.HTTPBasicAuth(username, password)
if response_data.get("auth") == "Success": )
session["logged_in"] = True
session["username"] = response_data.get("username", username) # Check if login was successful
session["user_id"] = response_data.get("user_id") if response.status_code == 200 and response.json().get("auth") == "Success":
session["auth_credentials"] = encoded_credentials # Set session variable to indicate the user is logged in
next_url = request.args.get("next") session["logged_in"] = True
if next_url: session["username"] = username
return redirect(next_url) session["auth_credentials"] = encoded_credentials
return redirect(url_for("home", loggedin=True)) return redirect(url_for("home")) # Redirect to the Accounts page
except requests.exceptions.RequestException: else:
pass # Fall through to error # Show error on the login page
error = "Invalid username or password. Please try again."
return render_template("index.html", error=error)
error = "Invalid username or password. Please try again."
return render_template("index.html", error=error)
@app.route("/urls", methods=["GET"]) @app.route("/urls", methods=["GET"])
@cache.cached(timeout=300, key_prefix=make_cache_key) @cache.cached(timeout=300) # cache for 5 minutes
def urls() -> Union[Response, str]: def urls():
"""Renders the URLs page.""" # Check if the user is logged in
if not session.get("logged_in"): if not session.get("logged_in"):
return redirect(url_for("home")) return redirect(url_for("home"))
base_url = app.config["BACKEND_URL"] # Placeholder content for Accounts page
base_url = app.config["BASE_URL"] # Access base_url from the config
return render_template( return render_template(
"urls.html", urls=get_urls(base_url, session["auth_credentials"]) "urls.html", urls=get_urls(base_url, session["auth_credentials"])
) )
@app.route("/accounts", methods=["GET"]) @app.route("/accounts", methods=["GET"])
@cache.cached(timeout=60, key_prefix=make_cache_key) @cache.cached(timeout=120) # cache for 120 seconds
def user_accounts() -> Union[Response, str]: def user_accounts():
"""Renders the user accounts page.""" # Check if the user is logged in
if not session.get("logged_in"): if not session.get("logged_in"):
return redirect(url_for("home")) return redirect(url_for("home"))
base_url = app.config["BACKEND_URL"] # Placeholder content for Accounts page
user_accounts_data = get_user_accounts(base_url, session["auth_credentials"]) base_url = app.config["BASE_URL"] # Access base_url from the config
return render_template( return render_template(
"user_accounts.html", "user_accounts.html",
username=session["username"], username=session["username"],
user_accounts=user_accounts_data, user_accounts=get_user_accounts(base_url, session["auth_credentials"]),
auth=session["auth_credentials"], auth=session["auth_credentials"],
) )
@app.route("/share", methods=["GET"])
def share() -> Response:
"""Handles shared text from PWA."""
if not session.get("logged_in"):
return redirect(url_for("index", next=request.url))
shared_text = request.args.get("text")
return redirect(url_for("add_account", shared_text=shared_text))
@app.route("/accounts/add", methods=["GET", "POST"]) @app.route("/accounts/add", methods=["GET", "POST"])
def add_account() -> Union[Response, str]: def add_account():
"""Handles adding a new user account.""" base_url = app.config["BASE_URL"] # Access base_url from the config
if not session.get("logged_in"):
return redirect(url_for("index", next=request.url))
base_url = app.config["BACKEND_URL"]
shared_text = request.args.get('shared_text')
if request.method == "POST": if request.method == "POST":
username = request.form["username"] username = request.form["username"]
password = request.form["password"] password = request.form["password"]
stream = request.form["stream"] stream = request.form["stream"]
if add_user_account( if add_user_account(
base_url, session["auth_credentials"], username, password, stream base_url, session["auth_credentials"], username, password, stream
): ):
cache.delete_memoized(user_accounts, key_prefix=make_cache_key) cache.clear() # Clears all cache entries
# Run the NPM config update in a background thread
thread = threading.Thread(target=_update_npm_config_in_background)
thread.start()
return redirect(url_for("user_accounts")) return redirect(url_for("user_accounts"))
return render_template("add_account.html")
return render_template("add_account.html")
return render_template(
"add_account.html",
text_input_enabled=app.config.get("TEXT_INPUT_ENABLED"),
shared_text=shared_text
)
@app.route("/accounts/delete", methods=["POST"]) @app.route("/accounts/delete", methods=["POST"])
def delete_account() -> Response: def delete_account():
"""Handles deleting a user account."""
stream = request.form.get("stream") stream = request.form.get("stream")
username = request.form.get("username") username = request.form.get("username")
base_url = app.config["BACKEND_URL"] base_url = app.config["BASE_URL"]
delete_user_account(base_url, session["auth_credentials"], stream, username)
cache.delete_memoized(user_accounts, key_prefix=make_cache_key) if delete_user_account(base_url, session["auth_credentials"], stream, username):
cache.clear() # Clears all cache entries
return redirect(url_for("user_accounts"))
return redirect(url_for("user_accounts")) return redirect(url_for("user_accounts"))
@app.route("/validateAccount", methods=["POST"])
def validate_account() -> Tuple[Response, int]:
"""Forwards account validation requests to the backend."""
base_url = app.config["BACKEND_URL"]
validate_url = f"{base_url}/validateAccount"
credentials = base64.b64decode(session["auth_credentials"]).decode()
username, password = credentials.split(":", 1)
try:
response = requests.post(
validate_url,
auth=requests.auth.HTTPBasicAuth(username, password),
json=request.get_json()
)
response.raise_for_status()
response_data = response.json()
if response_data.get("message") == "Account is valid and updated":
cache.delete_memoized(user_accounts, key_prefix=make_cache_key)
# Run the NPM config update in a background thread
thread = threading.Thread(target=_update_npm_config_in_background)
thread.start()
return jsonify(response_data), response.status_code
except requests.exceptions.RequestException as e:
return jsonify({"error": str(e)}), 500
@app.route("/get_stream_names", methods=["GET"]) @app.route("/get_stream_names", methods=["GET"])
def stream_names() -> Union[Response, str]: def stream_names():
"""Fetches and returns stream names as JSON."""
if not session.get("logged_in"): if not session.get("logged_in"):
return redirect(url_for("home")) return redirect(url_for("home"))
base_url = app.config["BACKEND_URL"] base_url = app.config["BASE_URL"]
return jsonify(get_stream_names(base_url, session["auth_credentials"])) stream_names = get_stream_names(base_url, session["auth_credentials"])
return jsonify(stream_names)
@app.route('/config') @app.route('/OCRupload', methods=['POST'])
def config(): def OCRupload():
"""Handles access to the configuration page.""" if 'image' not in request.files:
if session.get('user_id') and int(session.get('user_id')) == 1: return jsonify({"error": "No image file found"}), 400
return redirect(url_for('config_dashboard')) # Get the uploaded file
return redirect(url_for('home')) file = request.files['image']
@app.route('/config/dashboard')
def config_dashboard():
"""Renders the configuration dashboard."""
if not session.get('user_id') or int(session.get('user_id')) != 1:
return redirect(url_for('home'))
return render_template('config_dashboard.html')
@app.route('/check-expiring-accounts', methods=['POST'])
def check_expiring_accounts():
"""Proxies the request to check for expiring accounts to the backend."""
if not session.get('user_id') or int(session.get('user_id')) != 1:
return jsonify({'error': 'Unauthorized'}), 401
backend_url = f"{app.config['BACKEND_URL']}/check-expiry"
try: try:
response = requests.post(backend_url) image = Image.open(file.stream)
return Response(response.content, status=response.status_code, mimetype=response.headers['Content-Type']) image_np = np.array(image)
except requests.exceptions.RequestException as e: result = ocr.ocr(image_np)
return jsonify({"error": str(e)}), 502 # Extract text
extracted_text = []
for line in result[0]:
@app.route('/dns', methods=['GET', 'POST', 'DELETE']) extracted_text.append(line[1][0])
def proxy_dns(): return render_template("add_account.html", username=extracted_text[2], password=extracted_text[3])
"""Proxies DNS management requests to the backend.""" except Exception as e:
if not session.get('user_id') or int(session.get('user_id')) != 1: return jsonify({"error": str(e)}), 500
return jsonify({'error': 'Unauthorized'}), 401
backend_url = f"{app.config['BACKEND_URL']}/dns"
credentials = base64.b64decode(session["auth_credentials"]).decode()
username, password = credentials.split(":", 1)
auth = requests.auth.HTTPBasicAuth(username, password)
try:
if request.method == 'GET':
response = requests.get(backend_url, auth=auth)
elif request.method == 'POST':
response = requests.post(backend_url, auth=auth, json=request.get_json())
if response.ok:
cache.clear()
elif request.method == 'DELETE':
response = requests.delete(backend_url, auth=auth, json=request.get_json())
if response.ok:
cache.clear()
return Response(response.content, status=response.status_code, mimetype=response.headers['Content-Type'])
except requests.exceptions.RequestException as e:
return jsonify({"error": str(e)}), 502
@app.route('/extra_urls', methods=['GET', 'POST', 'DELETE'])
def proxy_extra_urls():
"""Proxies extra URL management requests to the backend."""
if not session.get('user_id') or int(session.get('user_id')) != 1:
return jsonify({'error': 'Unauthorized'}), 401
backend_url = f"{app.config['BACKEND_URL']}/extra_urls"
credentials = base64.b64decode(session["auth_credentials"]).decode()
username, password = credentials.split(":", 1)
auth = requests.auth.HTTPBasicAuth(username, password)
try:
if request.method == 'GET':
response = requests.get(backend_url, auth=auth)
elif request.method == 'POST':
response = requests.post(backend_url, auth=auth, json=request.get_json())
if response.ok:
cache.clear()
elif request.method == 'DELETE':
response = requests.delete(backend_url, auth=auth, json=request.get_json())
if response.ok:
cache.clear()
return Response(response.content, status=response.status_code, mimetype=response.headers['Content-Type'])
except requests.exceptions.RequestException as e:
return jsonify({"error": str(e)}), 502
class NginxProxyManager:
def __init__(self, host, email, password):
self.host = host
self.email = email
self.password = password
self.token = None
def login(self):
url = f"{self.host}/api/tokens"
payload = {
"identity": self.email,
"secret": self.password
}
headers = {
"Content-Type": "application/json"
}
response = requests.post(url, headers=headers, data=json.dumps(payload))
if response.status_code == 200:
self.token = response.json()["token"]
print("Login successful.")
else:
print(f"Failed to login: {response.text}")
exit(1)
def get_proxy_host(self, host_id):
if not self.token:
self.login()
url = f"{self.host}/api/nginx/proxy-hosts/{host_id}"
headers = {
"Authorization": f"Bearer {self.token}"
}
response = requests.get(url, headers=headers)
if response.status_code == 200:
return response.json()
else:
print(f"Failed to get proxy host {host_id}: {response.text}")
return None
def update_proxy_host_config(self, host_id, config):
if not self.token:
self.login()
url = f"{self.host}/api/nginx/proxy-hosts/{host_id}"
original_host_data = self.get_proxy_host(host_id)
if not original_host_data:
return
# Construct a new payload with only the allowed fields for an update
update_payload = {
"domain_names": original_host_data.get("domain_names", []),
"forward_scheme": original_host_data.get("forward_scheme", "http"),
"forward_host": original_host_data.get("forward_host"),
"forward_port": original_host_data.get("forward_port"),
"access_list_id": original_host_data.get("access_list_id", 0),
"certificate_id": original_host_data.get("certificate_id", 0),
"ssl_forced": original_host_data.get("ssl_forced", False),
"hsts_enabled": original_host_data.get("hsts_enabled", False),
"hsts_subdomains": original_host_data.get("hsts_subdomains", False),
"http2_support": original_host_data.get("http2_support", False),
"block_exploits": original_host_data.get("block_exploits", False),
"caching_enabled": original_host_data.get("caching_enabled", False),
"allow_websocket_upgrade": original_host_data.get("allow_websocket_upgrade", False),
"advanced_config": config, # The updated advanced config
"meta": original_host_data.get("meta", {}),
"locations": original_host_data.get("locations", []),
}
headers = {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json"
}
response = requests.put(url, headers=headers, data=json.dumps(update_payload))
if response.status_code == 200:
print(f"Successfully updated proxy host {host_id}")
else:
print(f"Failed to update proxy host {host_id}: {response.text}")
def update_config_with_streams(config, streams):
# Get all stream names from the database
db_stream_names = {stream['streamName'] for stream in streams}
# Find all location blocks in the config
location_blocks = re.findall(r'location ~ \^/(\w+)\(\.\*\)\$ \{[^}]+\}', config)
# Remove location blocks that are not in the database
for stream_name in location_blocks:
if stream_name not in db_stream_names:
print(f"Removing location block for stream: {stream_name}")
pattern = re.compile(f'location ~ \\^/{re.escape(stream_name)}\\(\\.\\*\\)\\$ {{[^}}]+}}\\s*', re.DOTALL)
config = pattern.sub('', config)
# Update existing stream URLs
for stream in streams:
stream_name = stream['streamName']
stream_url = stream['streamURL']
if stream_url: # Ensure there is a URL to update to
# Use a more specific regex to avoid replacing parts of other URLs
pattern = re.compile(f'(location ~ \\^/{re.escape(stream_name)}\\(\\.\\*\\)\\$ {{\\s*return 302 )([^;]+)(;\\s*}})')
config = pattern.sub(f'\\1{stream_url}/$1$is_args$args\\3', config)
return config
def _update_npm_config():
"""Helper function to update the NPM config."""
if not session.get('user_id') or int(session.get('user_id')) != 1:
print("Unauthorized attempt to update NPM config.")
return
npm = NginxProxyManager(app.config['NPM_HOST'], app.config['NPM_EMAIL'], app.config['NPM_PASSWORD'])
npm.login()
host = npm.get_proxy_host(9)
if host:
current_config = host.get('advanced_config', '')
backend_url = f"{app.config['BACKEND_URL']}/get_all_stream_urls"
credentials = base64.b64decode(session["auth_credentials"]).decode()
username, password = credentials.split(":", 1)
auth = requests.auth.HTTPBasicAuth(username, password)
try:
response = requests.get(backend_url, auth=auth)
response.raise_for_status()
streams = response.json()
except requests.exceptions.RequestException as e:
print(f"Failed to fetch streams from backend: {e}")
return
if streams:
new_config = update_config_with_streams(current_config, streams)
npm.update_proxy_host_config(9, new_config)
print("NPM config updated successfully.")
else:
print("Failed to update NPM config.")
def _update_npm_config_in_background():
with app.app_context():
_update_npm_config()
@app.route('/update_host_9_config', methods=['POST'])
def update_host_9_config():
if not session.get('user_id') or int(session.get('user_id')) != 1:
return jsonify({'error': 'Unauthorized'}), 401
thread = threading.Thread(target=_update_npm_config_in_background)
thread.start()
return jsonify({'message': 'NPM config update started in the background.'}), 202
if __name__ == "__main__": if __name__ == "__main__":
app.run( app.run(debug=app.config["DEBUG"], host=app.config["HOST"], port=app.config["PORT"])
debug=app.config["DEBUG"],
host=app.config["HOST"],
port=app.config["PORT"]
)

19
backend/app.py Normal file
View File

@ -0,0 +1,19 @@
from flask import Flask, jsonify
from config import DevelopmentConfig
from lib.mysql import execute_query
app = Flask(__name__)
app.config.from_object(DevelopmentConfig)
@app.route('/getUserAccounts', methods=['GET'])
def get_user_accounts():
# Use the execute_query function to get user accounts
data = execute_query("SELECT COUNT(*) AS account_count FROM userAccounts WHERE userID = %s;", (1,))
if data is None:
return jsonify({"error": "Database query failed"}), 500
return jsonify(data), 200
# Run the app
if __name__ == '__main__':
app.run(debug=app.config["DEBUG"], port=app.config["PORT"])

37
backend/lib/mysql.py Normal file
View File

@ -0,0 +1,37 @@
import mysql.connector
from flask import current_app
def execute_query(query, params=None, fetch_one=False):
"""Execute a SQL query and optionally fetch results."""
try:
# Get database configuration from the current app context
db_config = {
"host": current_app.config['DBHOST'],
"user": current_app.config['DBUSER'],
"password": current_app.config['DBPASS'],
"database": current_app.config['DATABASE'],
}
# Establish database connection
connection = mysql.connector.connect(**db_config)
cursor = connection.cursor(dictionary=True)
# Execute the query with optional parameters
cursor.execute(query, params)
# Fetch results if it's a SELECT query
if query.strip().upper().startswith("SELECT"):
result = cursor.fetchone() if fetch_one else cursor.fetchall()
else:
# Commit changes for INSERT, UPDATE, DELETE
connection.commit()
result = cursor.rowcount # Number of affected rows
# Close the database connection
cursor.close()
connection.close()
return result
except mysql.connector.Error as err:
print("Error: ", err)
return None

View File

@ -1,18 +0,0 @@
#!/bin/bash
set -ex
# Check if an argument is provided
if [ -z "$1" ]; then
echo "Usage: $0 <part>"
echo "Example: $0 patch"
exit 1
fi
PART=$1
# Bump the version
bump-my-version bump $PART
# Push the changes
git push
git push origin --tags

11
config.py.sample Normal file
View File

@ -0,0 +1,11 @@
# config.py
class Config:
DEBUG = False
BASE_URL = '' # Set your base URL here
class DevelopmentConfig(Config):
DEBUG = True
class ProductionConfig(Config):
BASE_URL = '' # Production base URL

View File

@ -1,39 +1,25 @@
# Builder stage FROM python:3.11-slim-bookworm
FROM python:3.11-slim-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
libglib2.0-0 \
libsm6 \
libxrender1 \
libxext6 \
libgomp1 \
libgl1 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Final stage COPY . .
FROM python:3.11-slim-bookworm AS final
WORKDIR /app
COPY --from=builder /install /usr/local
COPY app.py .
COPY gunicorn.conf.py .
COPY run.sh .
COPY VERSION .
COPY lib/ lib/
COPY static/ static/
COPY templates/ templates/
RUN chmod +x run.sh
ARG VERSION ARG VERSION
RUN echo $VERSION > VERSION RUN echo $VERSION > VERSION
# Create a non-root user EXPOSE 8089
RUN useradd --create-home appuser && \
mkdir -p /app/tmp && \
chown -R appuser:appuser /app/tmp
USER appuser
EXPOSE 5000 CMD ["python", "app.py"]
ENV FLASK_ENV=production
CMD ["./run.sh"]

View File

@ -1,11 +0,0 @@
import os
# Set the environment to production
os.environ['FLASK_ENV'] = 'production'
# Gunicorn config variables
loglevel = "info"
workers = 2
bind = "0.0.0.0:5000"
timeout = 120
worker_tmp_dir = "/app/tmp"

View File

@ -1,51 +1,46 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import List, Dict, Any from typing import List, Dict
def filter_accounts_next_30_days(accounts: List[Dict[str, Any]]) -> List[Dict[str, Any]]: def filter_accounts_next_30_days(accounts: List[Dict[str, int]]) -> List[Dict[str, int]]:
"""Filters accounts expiring within the next 30 days. """Filter accounts whose expiry date falls within the next 30 days.
Args: Args:
accounts: A list of account dictionaries, each with an 'expiaryDate' accounts (List[Dict[str, int]]): A list of account dictionaries, each containing
(epoch timestamp). an 'expiaryDate' key with an epoch timestamp as its value.
Returns: Returns:
A list of accounts expiring within the next 30 days, with added List[Dict[str, int]]: A list of accounts expiring within the next 30 days.
'expiaryDate_rendered' and 'days_to_expiry' keys.
""" """
now = datetime.now() now = datetime.now()
thirty_days_later = now + timedelta(days=30) thirty_days_later = now + timedelta(days=30)
# Convert current time and 30 days later to epoch timestamps
now_timestamp = int(now.timestamp()) now_timestamp = int(now.timestamp())
thirty_days_later_timestamp = int(thirty_days_later.timestamp()) thirty_days_later_timestamp = int(thirty_days_later.timestamp())
result = []
today = now.date()
for account in accounts: # Filter accounts with expiryDate within the next 30 days
if now_timestamp <= account['expiaryDate'] < thirty_days_later_timestamp: return [
expiry_date = datetime.fromtimestamp(account['expiaryDate']) account for account in accounts
account['expiaryDate_rendered'] = expiry_date.strftime('%d-%m-%Y') if now_timestamp <= account['expiaryDate'] < thirty_days_later_timestamp
expiry_date_date = expiry_date.date() ]
account['days_to_expiry'] = (expiry_date_date - today).days
result.append(account)
return result
def filter_accounts_expired(accounts: List[Dict[str, Any]]) -> List[Dict[str, Any]]: def filter_accounts_expired(accounts: List[Dict[str, int]]) -> List[Dict[str, int]]:
"""Filters accounts that have already expired. """Filter accounts whose expiry date has passed.
Args: Args:
accounts: A list of account dictionaries, each with an 'expiaryDate' accounts (List[Dict[str, int]]): A list of account dictionaries, each containing
(epoch timestamp). an 'expiaryDate' key with an epoch timestamp as its value.
Returns: Returns:
A list of expired accounts with an added 'expiaryDate_rendered' key. List[Dict[str, int]]: A list of accounts that have expired.
""" """
# Get the current epoch timestamp
current_timestamp = int(datetime.now().timestamp()) current_timestamp = int(datetime.now().timestamp())
expired_accounts = [] # Filter accounts where the current date is greater than the expiryDate
for account in accounts: expired_accounts = [
if account['expiaryDate'] < current_timestamp: account for account in accounts
expiry_date = datetime.fromtimestamp(account['expiaryDate']) if account['expiaryDate'] < current_timestamp
account['expiaryDate_rendered'] = expiry_date.strftime('%d-%m-%Y') ]
expired_accounts.append(account)
return expired_accounts return expired_accounts

View File

@ -1,84 +1,123 @@
import requests import requests
import json import json
from datetime import datetime from datetime import datetime
from typing import List, Dict, Any, Optional from typing import List, Dict, Any
# Create a session object to reuse TCP connections
session = requests.Session()
def _make_api_request(
method: str,
base_url: str,
auth: str,
endpoint: str,
payload: Optional[Dict[str, Any]] = None,
) -> Any:
"""
A helper function to make API requests.
Args:
method: The HTTP method to use (e.g., 'GET', 'POST').
base_url: The base URL of the API.
auth: The authorization token.
endpoint: The API endpoint to call.
payload: The data to send with the request.
Returns:
The JSON response from the API.
"""
url = f"{base_url}/{endpoint}"
headers = {"Authorization": f"Basic {auth}"}
try:
response = session.request(method, url, headers=headers, data=payload)
response.raise_for_status()
return response
except (requests.exceptions.RequestException, json.JSONDecodeError) as e:
# Log the error for debugging purposes
print(f"API request failed: {e}")
return None
def get_urls(base_url: str, auth: str) -> List[Dict[str, Any]]: def get_urls(base_url: str, auth: str) -> List[Dict[str, Any]]:
"""Retrieves user account streams from the API.""" """Retrieve user account streams from the specified base URL.
response = _make_api_request("GET", base_url, auth, "getUserAccounts/streams")
return response.json() if response else [] Args:
base_url (str): The base URL of the API.
auth (str): The authorization token for accessing the API.
Returns:
List[Dict[str, Any]]: A list of user account streams.
"""
url = f"{base_url}/getUserAccounts/streams"
payload = {}
headers = {"Authorization": f"Basic {auth}"}
response = requests.request("GET", url, headers=headers, data=payload)
return json.loads(response.text)
def get_user_accounts(base_url: str, auth: str) -> List[Dict[str, Any]]: def get_user_accounts(base_url: str, auth: str) -> List[Dict[str, Any]]:
"""Retrieves user accounts from the API.""" """Retrieve user accounts from the specified base URL.
response = _make_api_request("GET", base_url, auth, "getUserAccounts")
if not response:
return []
accounts = response.json() Args:
for account in accounts: base_url (str): The base URL of the API.
auth (str): The authorization token for accessing the API.
Returns:
List[Dict[str, Any]]: A list of user accounts with their expiration dates rendered.
"""
url = f"{base_url}/getUserAccounts"
payload = {}
headers = {"Authorization": f"Basic {auth}"}
response = requests.request("GET", url, headers=headers, data=payload)
res_json = json.loads(response.text)
for account in res_json:
account["expiaryDate_rendered"] = datetime.utcfromtimestamp( account["expiaryDate_rendered"] = datetime.utcfromtimestamp(
account["expiaryDate"] account["expiaryDate"]
).strftime("%d/%m/%Y") ).strftime("%d/%m/%Y")
return accounts
return res_json
def delete_user_account(base_url: str, auth: str, stream: str, username: str) -> bool: def delete_user_account(base_url: str, auth: str, stream: str, username: str) -> bool:
"""Deletes a user account via the API.""" """Delete a user account from the specified base URL.
Args:
base_url (str): The base URL of the API.
auth (str): The authorization token for accessing the API.
stream (str): The name of the stream associated with the user account.
username (str): The username of the account to delete.
Returns:
bool: True if the account was deleted successfully, False otherwise.
"""
url = f"{base_url}/deleteAccount"
payload = {"stream": stream, "user": username} payload = {"stream": stream, "user": username}
response = _make_api_request( headers = {"Authorization": f"Basic {auth}"}
"POST", base_url, auth, "deleteAccount", payload=payload
) response = requests.request("POST", url, headers=headers, data=payload)
return response and "Deleted" in response.text return "Deleted" in response.text
def add_user_account( def add_user_account(base_url: str, auth: str, username: str, password: str, stream: str) -> bool:
base_url: str, auth: str, username: str, password: str, stream: str """Add a user account to the specified base URL.
) -> bool:
"""Adds a user account via the API.""" Args:
base_url (str): The base URL of the API.
auth (str): The authorization token for accessing the API.
username (str): The username of the account to add.
password (str): The password of the account to add.
stream (str): The name of the stream associated with the user account.
Returns:
bool: True if the account was added successfully, False otherwise.
"""
url = f"{base_url}/addAccount"
payload = {"username": username, "password": password, "stream": stream} payload = {"username": username, "password": password, "stream": stream}
response = _make_api_request( headers = {"Authorization": f"Basic {auth}"}
"POST", base_url, auth, "addAccount", payload=payload
) response = requests.request("POST", url, headers=headers, data=payload)
return response and response.status_code == 200 return "Added successfully" in response.text
def get_user_accounts_count(base_url: str, auth: str) -> int:
"""Get the count of user accounts from the specified base URL.
Args:
base_url (str): The base URL of the API.
auth (str): The authorization token for accessing the API.
Returns:
int: The count of user accounts.
"""
url = f"{base_url}/getUserAccounts/count"
payload = {}
headers = {"Authorization": f"Basic {auth}"}
response = requests.request("GET", url, headers=headers, data=payload)
res_json = json.loads(response.text)
return res_json['count']
def get_stream_names(base_url: str, auth: str) -> List[str]: def get_stream_names(base_url: str, auth: str) -> List[str]:
"""Retrieves a list of stream names from the API.""" """Get a list of stream names from the API.
response = _make_api_request("GET", base_url, auth, "getStreamNames")
return response.json() if response else [] Args:
base_url (str): The base URL of the API.
auth (str): The authorization token.
Returns:
List[str]: A list of stream names.
"""
url = f"{base_url}/getStreamNames"
headers = {"Authorization": f"Basic {auth}"}
response = requests.get(url, headers=headers)
return json.loads(response.text)

Binary file not shown.

12
run.sh
View File

@ -1,12 +0,0 @@
#!/bin/bash
# Default to development environment
FLASK_ENV=${FLASK_ENV:-development}
if [ "$FLASK_ENV" = "production" ]; then
echo "Starting in production mode..."
python3 -m gunicorn --config gunicorn.conf.py app:app
else
echo "Starting in development mode..."
python3 app.py
fi

View File

@ -1,51 +1,15 @@
const CACHE_NAME = 'ktvmanager-cache-v1'; self.addEventListener('install', e => {
const urlsToCache = [ // console.log('[Service Worker] Installed');
'/',
'/static/styles.css',
'/static/favicon.ico',
'/static/favicon-96x96.png',
'/static/favicon.svg',
'/static/apple-touch-icon.png',
'/static/web-app-manifest-192x192.png',
'/static/web-app-manifest-512x512.png',
'/static/site.webmanifest'
];
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open(CACHE_NAME)
.then(function(cache) {
console.log('Opened cache');
return cache.addAll(urlsToCache);
})
);
}); });
self.addEventListener('push', function(event) { self.addEventListener('activate', e => {
console.log('[Service Worker] Push Received.'); // console.log('[Service Worker] Activated');
console.log(`[Service Worker] Push data: "${event.data.text()}"`);
const data = event.data.json();
const options = {
body: data.body,
icon: '/static/web-app-manifest-192x192.png',
badge: '/static/favicon-96x96.png'
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
}); });
self.addEventListener('fetch', function(event) { self.addEventListener('fetch', e => {
event.respondWith( // e.respondWith(
caches.match(event.request) // caches.match(e.request).then(res => {
.then(function(response) { // return res || fetch(e.request);
if (response) { // })
return response; // );
}
return fetch(event.request);
}
)
);
}); });

View File

@ -1,29 +1,28 @@
{ {
"short_name": "kTv",
"name": "kTvManager", "name": "kTvManager",
"description": "KTVManager PWA", "short_name": "kTv",
"start_url": "/",
"icons": [ "icons": [
{ {
"src": "/static/web-app-manifest-192x192.png", "src": "/favicon-96x96.png",
"sizes": "144x144",
"type": "image/png", "type": "image/png",
"sizes": "192x192" "purpose": "maskable"
}, },
{ {
"src": "/static/web-app-manifest-512x512.png", "src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png", "type": "image/png",
"sizes": "512x512" "purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
} }
], ],
"start_url": "/",
"background_color": "#ffffff",
"display": "standalone",
"theme_color": "#ffffff", "theme_color": "#ffffff",
"share_target": { "background_color": "#ffffff",
"action": "/share", "display": "standalone"
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": {
"text": "text"
}
}
} }

View File

@ -28,45 +28,3 @@ footer {
text-align: center; text-align: center;
padding: 1em; padding: 1em;
} }
/* Awesomplete and Bootstrap integration fix */
div.awesomplete {
display: block;
width: 100%;
}
/* Responsive table styles */
table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control:before, table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control:before {
background-color: #337ab7;
border: 2px solid white;
border-radius: 50%;
box-shadow: 0 0 3px #444;
box-sizing: content-box;
content: '+';
color: white;
display: block;
height: 16px;
left: 4px;
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 16px;
font-size: 14px;
line-height: 16px;
}
table.dataTable.dtr-inline.collapsed>tbody>tr.parent>td.dtr-control:before, table.dataTable.dtr-inline.collapsed>tbody>tr.parent>th.dtr-control:before {
content: '-';
background-color: #d33333;
}
/* Media query for mobile devices */
@media (max-width: 768px) {
.container {
margin-top: 1rem !important;
}
main {
padding: 0.5em;
}
}

View File

@ -1,18 +1,43 @@
{% extends "base.html" %} <!-- templates/add_account.html -->
<!DOCTYPE html>
{% block title %}Add Account - KTVManager{% endblock %} <html lang="en">
<head>
{% block head_content %} <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Add Account - KTVManager</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/awesomplete/1.1.5/awesomplete.min.css" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/awesomplete/1.1.5/awesomplete.min.css" />
<style> <style>
/* Hide the spinner by default */ /* Hide the spinner by default */
#loadingSpinner { #loadingSpinner,
#ocrLoadingSpinner {
display: none; display: none;
} }
</style> </style>
{% endblock %} </head>
<body>
<!-- Navbar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<a class="navbar-brand" href="/">KTVManager</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/accounts">Accounts</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/urls">URLs</a>
</li>
</ul>
</div>
</nav>
{% block sub_nav %}
<!-- Sub-navigation for Accounts --> <!-- Sub-navigation for Accounts -->
<div class="bg-light py-2"> <div class="bg-light py-2">
<div class="container"> <div class="container">
@ -26,40 +51,46 @@
</ul> </ul>
</div> </div>
</div> </div>
{% endblock %}
{% block content %} <!-- Main Content -->
<h1>Add Account</h1> <main class="container mt-5">
<div> <h1>Add Account</h1>
<form action="/accounts/add" method="POST" onsubmit="showLoading()"> <div>
<div class="form-group"> <form action="/accounts/add" method="POST" onsubmit="showLoading()">
<label for="username">Username</label> <div class="form-group">
<input type="text" class="form-control" id="username" name="username" value="{{ username }}" required> <label for="username">Username</label>
</div> <input type="text" class="form-control" id="username" name="username" value="{{ username }}" required>
<div class="form-group"> </div>
<label for="password">Password</label> <div class="form-group">
<input type="text" class="form-control" id="password" name="password" value="{{ password }}" required> <label for="password">Password</label>
</div> <input type="text" class="form-control" id="password" name="password" value="{{ password }}" required>
<div class="form-group"> </div>
<label for="stream">Stream Name</label> <div class="form-group">
<input type="text" class="form-control" id="stream" name="stream" required> <label for="stream">Stream Name</label>
</div> <input type="text" class="form-control" id="stream" name="stream" required>
<button type="submit" class="btn btn-primary" id="submitButton"> </div>
<span class="spinner-border spinner-border-sm" id="loadingSpinner" role="status" aria-hidden="true"></span> <button type="submit" class="btn btn-primary" id="submitButton">
<span id="buttonText">Add Account</span> <span class="spinner-border spinner-border-sm" id="loadingSpinner" role="status" aria-hidden="true"></span>
</button> <span id="buttonText">Add Account</span>
</form> </button>
{% if text_input_enabled %} </form>
<hr> <hr>
<h2>Load Details Via Text</h2> <h4>Load Details Via OCR</h2>
<div class="form-group"> <form action="/OCRupload" method="POST" enctype="multipart/form-data" onsubmit="showLoadingOCR()">
<label for="accountDetails">Paste Account Details</label> <div class="form-group">
<textarea class="form-control" id="accountDetails" rows="4" data-shared-text="{{ shared_text }}"></textarea> <label for="image">Select Image</label>
</div> <input type="file" class="form-control-file" id="image" name="image" accept="image/*" required>
{% endif %} </div>
{% endblock %} <button type="submit" class="btn btn-success" id="ocrButton">
<span class="spinner-border spinner-border-sm" id="ocrLoadingSpinner" role="status" aria-hidden="true"></span>
{% block scripts %} <span id="ocrButtonText">Load Details</span>
</button>
</form>
</main>
<footer class="bg-dark text-white text-center py-3 mt-5">
<p></p>
</footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/awesomplete/1.1.5/awesomplete.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/awesomplete/1.1.5/awesomplete.min.js"></script>
<script> <script>
function showLoading() { function showLoading() {
@ -67,60 +98,22 @@
document.getElementById("loadingSpinner").style.display = "inline-block"; document.getElementById("loadingSpinner").style.display = "inline-block";
document.getElementById("buttonText").textContent = "Working..."; document.getElementById("buttonText").textContent = "Working...";
} }
function showLoadingOCR() {
document.getElementById("ocrButton").disabled = true;
document.getElementById("ocrLoadingSpinner").style.display = "inline-block";
document.getElementById("ocrButtonText").textContent = "Processing...";
}
document.addEventListener("DOMContentLoaded", function() { document.addEventListener("DOMContentLoaded", function() {
var streamInput = document.getElementById("stream"); var streamInput = document.getElementById("stream");
var awesomplete;
fetch('/get_stream_names') fetch('/get_stream_names')
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
awesomplete = new Awesomplete(streamInput, { new Awesomplete(streamInput, {
list: data list: data.map(item => item.streamName)
}); });
streamInput.addEventListener('awesomplete-selectcomplete', function(event) {
this.value = event.text.value;
});
const accountDetailsTextarea = document.getElementById('accountDetails');
if (accountDetailsTextarea) {
accountDetailsTextarea.addEventListener('input', function() {
const text = this.value;
const lines = text.split('\n');
const streamName = lines[0] ? lines[0].trim() : '';
const usernameLine = lines.find(line => line.toUpperCase().startsWith('USER:'));
const passwordLine = lines.find(line => line.toUpperCase().startsWith('PASS:'));
if (usernameLine) {
document.getElementById('username').value = usernameLine.substring(5).trim();
}
if (passwordLine) {
document.getElementById('password').value = passwordLine.substring(5).trim();
}
if (streamName) {
streamInput.value = streamName;
awesomplete.evaluate();
if (awesomplete.ul.children.length > 0) {
awesomplete.goto(0);
awesomplete.select();
}
}
});
const sharedText = accountDetailsTextarea.dataset.sharedText;
if (sharedText && sharedText !== 'None') {
accountDetailsTextarea.value = sharedText;
const event = new Event('input', {
bubbles: true,
cancelable: true,
});
accountDetailsTextarea.dispatchEvent(event);
}
}
}); });
}); });
</script> </script>
{% endblock %} </body>
</html>

View File

@ -1,192 +0,0 @@
<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}KTVManager{% endblock %}</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon-96x96.png') }}" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}" />
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}" />
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='apple-touch-icon.png') }}" />
<meta name="apple-mobile-web-app-title" content="kTvManager" />
<link rel="manifest" href="{{ url_for('static', filename='site.webmanifest') }}?v={{ version }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}?v={{ version }}" />
{% block head_content %}{% endblock %}
</head>
<body>
<!-- Navbar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<a class="navbar-brand" href="/">KTVManager</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/accounts">Accounts</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/urls">URLs</a>
</li>
</ul>
</div>
</nav>
{% block sub_nav %}{% endblock %}
<!-- Main Content -->
<main class="container mt-5">
{% block content %}{% endblock %}
</main>
<footer class="bg-dark text-white text-center py-3 mt-5">
<p>Version: {% if session.user_id and session.user_id|int == 1 %}<a href="{{ url_for('config') }}" style="color: inherit; text-decoration: none;">{{ version }}</a>{% else %}{{ version }}{% endif %}</p>
</footer>
<input type="hidden" id="is-logged-in" value="{{ 'true' if session.get('logged_in') else 'false' }}">
<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.1/dist/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
{% block scripts %}{% endblock %}
<script>
if ('serviceWorker' in navigator && 'PushManager' in window) {
const isLoggedIn = document.getElementById('is-logged-in').value === 'true';
navigator.serviceWorker.register('{{ url_for("static", filename="service-worker.js") }}').then(function(registration) {
console.log('ServiceWorker registration successful with scope: ', registration.scope);
const enableNotificationsBtn = document.getElementById('enable-notifications-btn');
function setupNotificationButton() {
registration.pushManager.getSubscription().then(function(subscription) {
if (enableNotificationsBtn) {
if (subscription) {
enableNotificationsBtn.style.display = 'none';
} else {
enableNotificationsBtn.style.display = 'block';
enableNotificationsBtn.addEventListener('click', function() {
askPermission(registration);
});
}
}
});
}
if (enableNotificationsBtn) {
setupNotificationButton();
}
}, function(err) {
console.log('ServiceWorker registration failed: ', err);
});
const forceResubscribeBtn = document.getElementById('force-resubscribe-btn');
if (forceResubscribeBtn) {
forceResubscribeBtn.addEventListener('click', function() {
navigator.serviceWorker.ready.then(function(registration) {
registration.pushManager.getSubscription().then(function(subscription) {
if (subscription) {
subscription.unsubscribe().then(function(successful) {
if (successful) {
console.log('Unsubscribed successfully.');
askPermission(registration);
} else {
console.log('Unsubscribe failed.');
}
});
} else {
askPermission(registration);
}
});
});
});
}
}
function askPermission(registration) {
Notification.requestPermission().then(function(result) {
if (result === 'granted') {
subscribeUser(registration);
}
});
}
function subscribeUser(registration) {
fetch('/vapid-public-key')
.then(response => {
if (!response.ok) {
return response.text().then(text => {
console.error('Failed to fetch VAPID public key. Server response:', text);
throw new Error('Failed to fetch VAPID public key. See server response in logs.');
});
}
return response.json();
})
.then(data => {
console.log('Received VAPID public key:', data.public_key);
const applicationServerKey = urlB64ToUint8Array(data.public_key);
registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey
}).then(function(subscription) {
console.log('User is subscribed.');
saveSubscription(subscription);
}).catch(function(err) {
console.log('Failed to subscribe the user: ', err);
});
}).catch(function(err) {
console.error('Error during subscription process:', err);
});
}
function saveSubscription(subscription) {
console.log('Attempting to save subscription...');
fetch('/save-subscription', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(subscription)
})
.then(response => {
if (response.ok) {
console.log('Subscription saved successfully.');
const enableNotificationsBtn = document.getElementById('enable-notifications-btn');
if (enableNotificationsBtn) {
enableNotificationsBtn.style.display = 'none';
}
return response.json();
} else {
console.error('Failed to save subscription. Status:', response.status);
response.text().then(text => console.error('Server response:', text));
throw new Error('Failed to save subscription.');
}
})
.then(data => {
console.log('Server response on save:', data);
})
.catch(err => {
console.error('Error during saveSubscription fetch:', err);
});
}
function urlB64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
</script>
</body>
</html>

View File

@ -1,301 +0,0 @@
{% extends "base.html" %}
{% block title %}Config Dashboard{% endblock %}
{% block content %}
<div class="container mt-4">
<h2 class="mb-4">Configuration Dashboard</h2>
<div class="row">
<div class="col-md-12">
<div class="card">
<div class="card-header">
Actions
</div>
<div class="card-body">
<button id="send-test-notification-btn" class="btn btn-primary">Send Test Notification</button>
<button id="check-expiring-accounts-btn" class="btn btn-info">Check Expiring Accounts</button>
<button id="force-resubscribe-btn" class="btn btn-warning">Force Re-subscribe</button>
<button id="update-host-9-btn" class="btn btn-success">Update Redirect URLS</button>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-md-12">
<div class="card">
<div class="card-header">
DNS Manager
</div>
<div class="card-body">
<div class="input-group mb-3">
<input type="text" class="form-control" id="dns-entry-input" placeholder="Enter DNS entry">
<div class="input-group-append">
<button class="btn btn-primary" id="add-dns-btn">Add</button>
</div>
</div>
<table class="table table-striped">
<thead>
<tr>
<th>DNS Entry</th>
<th style="width: 10%;">Actions</th>
</tr>
</thead>
<tbody id="dns-list-table-body">
<!-- DNS entries will be loaded here -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-md-12">
<div class="card">
<div class="card-header">
Extra URLs Manager
</div>
<div class="card-body">
<div class="input-group mb-3">
<input type="text" class="form-control" id="extra-url-input" placeholder="Enter Extra URL">
<div class="input-group-append">
<button class="btn btn-primary" id="add-extra-url-btn">Add</button>
</div>
</div>
<table class="table table-striped">
<thead>
<tr>
<th>Extra URL</th>
<th style="width: 10%;">Actions</th>
</tr>
</thead>
<tbody id="extra-urls-table-body">
<!-- Extra URLs will be loaded here -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
<script>
document.addEventListener('DOMContentLoaded', function() {
// DNS Manager
const dnsListTableBody = document.getElementById('dns-list-table-body');
const addDnsBtn = document.getElementById('add-dns-btn');
const dnsEntryInput = document.getElementById('dns-entry-input');
function fetchDnsList() {
fetch("{{ url_for('proxy_dns') }}")
.then(response => {
if (!response.ok) {
// Log the error response text for debugging
response.text().then(text => console.error('Error response from proxy:', text));
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
})
.then(data => {
dnsListTableBody.innerHTML = '';
if (!Array.isArray(data)) {
console.error("Received data is not an array:", data);
throw new Error("Invalid data format received from server.");
}
if (data.length === 0) {
const row = dnsListTableBody.insertRow();
const cell = row.insertCell();
cell.colSpan = 2;
cell.textContent = 'No DNS entries found.';
cell.classList.add('text-center');
} else {
data.forEach(entry => {
const row = dnsListTableBody.insertRow();
const entryCell = row.insertCell();
entryCell.textContent = entry;
const actionCell = row.insertCell();
const removeBtn = document.createElement('button');
removeBtn.className = 'btn btn-danger btn-sm';
removeBtn.textContent = 'Delete';
removeBtn.addEventListener('click', () => removeDnsEntry(entry));
actionCell.appendChild(removeBtn);
});
}
})
.catch(e => {
console.error('Error during fetchDnsList:', e);
dnsListTableBody.innerHTML = '';
const row = dnsListTableBody.insertRow();
const cell = row.insertCell();
cell.colSpan = 2;
cell.textContent = 'Error loading DNS entries. See browser console for details.';
cell.classList.add('text-center', 'text-danger');
});
}
function addDnsEntry() {
const dnsEntry = dnsEntryInput.value.trim();
if (dnsEntry) {
fetch("{{ url_for('proxy_dns') }}", {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ dns_entry: dnsEntry })
}).then(() => {
dnsEntryInput.value = '';
fetchDnsList();
});
}
}
function removeDnsEntry(dnsEntry) {
fetch("{{ url_for('proxy_dns') }}", {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ dns_entry: dnsEntry })
}).then(() => {
fetchDnsList();
});
}
addDnsBtn.addEventListener('click', addDnsEntry);
fetchDnsList();
// Extra URLs Manager
const extraUrlsTableBody = document.getElementById('extra-urls-table-body');
const addExtraUrlBtn = document.getElementById('add-extra-url-btn');
const extraUrlInput = document.getElementById('extra-url-input');
function fetchExtraUrlsList() {
fetch("{{ url_for('proxy_extra_urls') }}")
.then(response => {
if (!response.ok) {
// Log the error response text for debugging
response.text().then(text => console.error('Error response from proxy:', text));
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
})
.then(data => {
extraUrlsTableBody.innerHTML = '';
if (!Array.isArray(data)) {
console.error("Received data is not an array:", data);
throw new Error("Invalid data format received from server.");
}
if (data.length === 0) {
const row = extraUrlsTableBody.insertRow();
const cell = row.insertCell();
cell.colSpan = 2;
cell.textContent = 'No extra URLs found.';
cell.classList.add('text-center');
} else {
data.forEach(entry => {
const row = extraUrlsTableBody.insertRow();
const entryCell = row.insertCell();
entryCell.textContent = entry;
const actionCell = row.insertCell();
const removeBtn = document.createElement('button');
removeBtn.className = 'btn btn-danger btn-sm';
removeBtn.textContent = 'Delete';
removeBtn.addEventListener('click', () => removeExtraUrl(entry));
actionCell.appendChild(removeBtn);
});
}
})
.catch(e => {
console.error('Error during fetchExtraUrlsList:', e);
extraUrlsTableBody.innerHTML = '';
const row = extraUrlsTableBody.insertRow();
const cell = row.insertCell();
cell.colSpan = 2;
cell.textContent = 'Error loading extra URLs. See browser console for details.';
cell.classList.add('text-center', 'text-danger');
});
}
function addExtraUrl() {
const extraUrl = extraUrlInput.value.trim();
if (extraUrl) {
fetch("{{ url_for('proxy_extra_urls') }}", {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ extra_url: extraUrl })
}).then(() => {
extraUrlInput.value = '';
fetchExtraUrlsList();
});
}
}
function removeExtraUrl(extraUrl) {
fetch("{{ url_for('proxy_extra_urls') }}", {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ extra_url: extraUrl })
}).then(() => {
fetchExtraUrlsList();
});
}
addExtraUrlBtn.addEventListener('click', addExtraUrl);
fetchExtraUrlsList();
// Other buttons
document.getElementById('send-test-notification-btn').addEventListener('click', function() {
fetch('{{ url_for("send_test_notification") }}', {
method: 'POST'
}).then(response => {
if (response.ok) {
alert('Test notification sent successfully!');
} else {
alert('Failed to send test notification.');
}
}).catch(err => {
console.error('Error sending test notification:', err);
alert('An error occurred while sending the test notification.');
});
});
document.getElementById('check-expiring-accounts-btn').addEventListener('click', function() {
fetch('{{ url_for("check_expiring_accounts") }}', {
method: 'POST'
}).then(response => {
if (response.ok) {
alert('Expiring accounts check triggered successfully!');
} else {
alert('Failed to trigger expiring accounts check.');
}
}).catch(err => {
console.error('Error triggering expiring accounts check:', err);
alert('An error occurred while triggering the expiring accounts check.');
});
});
document.getElementById('update-host-9-btn').addEventListener('click', function() {
fetch('{{ url_for("update_host_9_config") }}', {
method: 'POST'
}).then(response => {
if (response.ok) {
alert('Host 9 config updated successfully!');
} else {
alert('Failed to update Host 9 config.');
}
}).catch(err => {
console.error('Error updating Host 9 config:', err);
alert('An error occurred while updating the Host 9 config.');
});
});
});
</script>
{% endblock %}

View File

@ -1,56 +1,93 @@
{% extends "base.html" %} <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KTVManager</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
</head>
<body>
{% block title %}KTVManager{% endblock %} <!-- Navbar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<a class="navbar-brand" href="/">KTVManager</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/accounts">Accounts</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/urls">URLs</a>
</li>
</ul>
</div>
</nav>
{% block content %} <!-- Main Content -->
<h1>Welcome {{ username }}!</h1> <main class="container mt-5">
<button id="enable-notifications-btn" class="btn btn-primary my-3">Enable Notifications</button> <h1>Welcome {{ username }}!</h1>
<h2>You have {{ accounts }} active accounts</h2> <br>
<br> <h2>You have {{ accounts }} active accounts</h2>
<br>
{% if current_month_accounts %}
<h3>Accounts Expiring Within 30 Days</h3> {% if current_month_accounts %}
<table class="table table-bordered table-striped"> <h3>Accounts Expiring Within 30 Days</h3>
<thead class="thead-dark"> <table class="table table-bordered table-striped">
<tr> <thead class="thead-dark">
<th>Stream Name</th>
<th>Username</th>
<th>Expiry Date</th>
</tr>
</thead>
<tbody>
{% for account in current_month_accounts %}
<tr> <tr>
<td>{{ account.stream }}</td> <th>Stream Name</th>
<td>{{ account.username }}</td> <th>Username</th>
<td>{{ account.expiaryDate_rendered }} {% if account.days_to_expiry is defined %}<span style="color: red;">({{ account.days_to_expiry }} days)</span>{% endif %}</td> <th>Expiry Date</th>
</tr> </tr>
{% endfor %} </thead>
</tbody> <tbody>
</table> {% for account in current_month_accounts %}
{% endif %} <tr>
{% if expired_accounts %} <td>{{ account.stream }}</td>
<h3>Expired Accounts</h3> <td>{{ account.username }}</td>
<table class="table table-bordered table-striped"> <td>{{ account.expiaryDate_rendered }}</td>
<thead class="thead-dark"> </tr>
<tr> {% endfor %}
<th>Stream Name</th> </tbody>
<th>Username</th> </table>
<th>Expiry Date</th> {% endif %}
</tr> {% if expired_accounts %}
</thead> <h3>Expired Accounts</h3>
<tbody> <table class="table table-bordered table-striped">
{% for account in expired_accounts %} <thead class="thead-dark">
<tr> <tr>
<td>{{ account.stream }}</td> <th>Stream Name</th>
<td>{{ account.username }}</td> <th>Username</th>
<td>{{ account.expiaryDate_rendered }}</td> <th>Expiry Date</th>
</tr> </tr>
{% endfor %} </thead>
</tbody> <tbody>
</table> {% for account in expired_accounts %}
{% endif %} <tr>
{% endblock %} <td>{{ account.stream }}</td>
<td>{{ account.username }}</td>
<td>{{ account.expiaryDate_rendered }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</main>
{% block scripts %} <!-- Footer -->
{% endblock %} <footer class="bg-dark text-white text-center py-3 mt-5">
<p>Version: {{ version }}</p>
</footer>
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.0.7/dist/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
</body>
</html>

View File

@ -1,36 +1,80 @@
{% extends "base.html" %} <!-- templates/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KTVManager</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon-96x96.png') }}" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}" />
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}" />
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='apple-touch-icon.png') }}" />
<meta name="apple-mobile-web-app-title" content="kTvManager" />
<link rel="manifest" href="{{ url_for('static', filename='site.webmanifest') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
</head>
<body>
{% block title %}KTVManager{% endblock %} <!-- Navbar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
{% block content %} <a class="navbar-brand" href="/">KTVManager</a>
<h1>Welcome to KTV Manager</h1> <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
<!-- Login Form --> </button>
<form action="/login" method="post" class="mt-3"> <div class="collapse navbar-collapse" id="navbarNav">
<div class="form-group"> <ul class="navbar-nav ml-auto">
<label for="username">Username:</label> <li class="nav-item">
<input type="text" class="form-control" id="username" name="username" required> <a class="nav-link" href="/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/accounts">Accounts</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/urls">URLs</a>
</li>
</ul>
</div> </div>
<div class="form-group"> </nav>
<label for="password">Password:</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary">Login</button>
{% if error %}
<div class="alert alert-danger mt-3">{{ error }}</div>
{% endif %}
</form>
{% endblock %}
{% block scripts %} <!-- Main Content -->
<script> <main div class="container mt-5">
if ('serviceWorker' in navigator) { <h1>Welcome to KTV Manager</h1>
navigator.serviceWorker.register('{{ url_for("static", filename="service-worker.js") }}')
// .then(reg => { <!-- Login Form -->
// console.log('Service worker:', reg); <form action="/login" method="post" class="mt-3">
// .catch(err => { <div class="form-group">
// console.log('Service worker:', err); <label for="username">Username:</label>
// }); <input type="text" class="form-control" id="username" name="username" required>
} </div>
</script> <div class="form-group">
{% endblock %} <label for="password">Password:</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary">Login</button>
{% if error %}
<div class="alert alert-danger mt-3">{{ error }}</div>
{% endif %}
</form>
</div>
</main>
<footer class="bg-dark text-white text-center py-3 mt-5">
<p></p>
</footer>
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.0.7/dist/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('{{ url_for("static", filename="service-worker.js") }}')
// .then(reg => {
// console.log('Service worker:', reg);
// .catch(err => {
// console.log('Service worker:', err);
// });
}
</script>
</body>
</html>

View File

@ -1,23 +1,65 @@
{% extends "base.html" %} <!-- templates/urls.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KTVManager</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
</head>
<body>
{% block title %}URLs - KTVManager{% endblock %} <!-- Navbar (same as index.html) -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<a class="navbar-brand" href="/">KTVManager</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/accounts">Accounts</a> <!-- Link to the URLs page -->
</li>
<li class="nav-item">
<a class="nav-link" href="/urls">URLs</a> <!-- Link to the URLs page -->
</li>
</ul>
</div>
</nav>
{% block content %}
<h2>URLs</h2> <!-- Main Content -->
<table class="table table-striped"> <main div class="container mt-5">
<thead> <h2>URLs</h2>
<tr> <table class="table table-striped">
<th>#</th> <thead>
<th>URL</th> <tr>
</tr> <th>#</th>
</thead> <th>URL</th>
<tbody> </tr>
{% for url in urls %} </thead>
<tr> <tbody>
<td>{{ loop.index }}</td> {% for url in urls %}
<td><a href="{{ url }}" target="_blank">{{ url }}</a></td> <tr>
</tr> <td>{{ loop.index }}</td>
{% endfor %} <td><a href="{{ url }}" target="_blank">{{ url }}</a></td>
</tbody> </tr>
</table> {% endfor %}
{% endblock %} </tbody>
</table>
</div>
</main>
<footer class="bg-dark text-white text-center py-3 mt-5">
<p></p>
</footer>
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.0.7/dist/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
</body>
</html>

View File

@ -1,13 +1,30 @@
{% extends "base.html" %} <!DOCTYPE html>
<html lang="en">
{% block title %}Accounts - KTVManager{% endblock %} <head>
<meta charset="UTF-8">
{% block head_content %} <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KTVManager</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/1.10.24/css/jquery.dataTables.min.css"> <link rel="stylesheet" href="https://cdn.datatables.net/1.10.24/css/jquery.dataTables.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/responsive/2.2.9/css/responsive.dataTables.min.css"> <link rel="stylesheet" href="https://cdn.datatables.net/responsive/2.2.9/css/responsive.dataTables.min.css">
{% endblock %} <link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
</head>
<body>
<!-- Navbar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<a class="navbar-brand" href="/">KTVManager</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ml-auto">
<li class="nav-item"><a class="nav-link" href="/">Home</a></li>
<li class="nav-item"><a class="nav-link" href="/accounts">Accounts</a></li>
<li class="nav-item"><a class="nav-link" href="/urls">URLs</a></li>
</ul>
</div>
</nav>
{% block sub_nav %}
<!-- Sub-navigation for Accounts --> <!-- Sub-navigation for Accounts -->
<div class="bg-light py-2"> <div class="bg-light py-2">
<div class="container"> <div class="container">
@ -21,53 +38,56 @@
</ul> </ul>
</div> </div>
</div> </div>
{% endblock %}
{% block content %} <!-- Main Content -->
<h2>{{ username }}'s Accounts</h2> <main div class="container mt-5">
<div> <h2>{{ username }}'s Accounts</h2>
<table class="table table-striped dt-responsive nowrap" id="accountsTable" style="width:100%"> <div class="table-responsive">
<thead> <table class="table table-striped" id="accountsTable">
<tr> <thead>
<!-- <th>#</th> --> <tr>
<th>Username</th> <!-- <th>#</th> -->
<th>Stream</th> <th>Username</th>
<th>Stream URL</th> <th>Stream</th>
<th>Expiry Date</th> <th>Stream URL</th>
<th>Password</th> <th>Expiry Date</th>
<th>Actions</th> <th>Password</th>
</tr> <th>Actions</th>
</thead> </tr>
<tbody> </thead>
{% for account in user_accounts %} <tbody>
<tr> {% for account in user_accounts %}
<!-- <td>{{ loop.index }}</td> --> <tr>
<td>{{ account.username }}</td> <!-- <td>{{ loop.index }}</td> -->
<td>{{ account.stream }}</td> <td>{{ account.username }}</td>
<td><a href="{{ account.streamURL }}" target="_blank">{{ account.streamURL }}</a> ({{ account.maxConnections }})</td> <td>{{ account.stream }}</td>
<td>{{ account.expiaryDate_rendered }}</td> <td><a href="{{ account.streamURL }}" target="_blank">{{ account.streamURL }}</a></td>
<td>{{ account.password }}</td> <td>{{ account.expiaryDate_rendered }}</td>
<td> <td>{{ account.password }}</td>
<button type="button" class="btn btn-info btn-validate" data-username="{{ account.username }}" data-password="{{ account.password }}" data-stream="{{ account.stream }}" data-stream-url="{{ account.streamURL }}" data-expiry-date="{{ account.expiaryDate }}" data-max-connections="{{ account.maxConnections }}" style="margin-right: 5px;"> <td>
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span> <form action="/accounts/delete" method="POST" style="display:inline;">
<span class="button-text">Validate</span> <input type="hidden" name="stream" value="{{ account.stream }}">
</button> <input type="hidden" name="username" value="{{ account.username }}">
<form action="/accounts/delete" method="POST" style="display:inline;"> <button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this account?');">
<input type="hidden" name="stream" value="{{ account.stream }}"> Delete
<input type="hidden" name="username" value="{{ account.username }}"> </button>
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this account?');"> </form>
Delete </td>
</button> </tr>
</form> {% endfor %}
</td> </tbody>
</tr> </table>
{% endfor %} </div>
</tbody>
</table>
</div> </div>
{% endblock %} </main>
{% block scripts %} <footer class="bg-dark text-white text-center py-3 mt-5">
<p></p>
</footer>
<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.0.7/dist/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
<script src="https://cdn.datatables.net/1.10.24/js/jquery.dataTables.min.js"></script> <script src="https://cdn.datatables.net/1.10.24/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/responsive/2.2.9/js/dataTables.responsive.min.js"></script> <script src="https://cdn.datatables.net/responsive/2.2.9/js/dataTables.responsive.min.js"></script>
<script> <script>
@ -93,67 +113,8 @@
] ]
}); });
}); });
$('#accountsTable tbody').on('click', '.btn-validate', function() {
var button = $(this);
var spinner = button.find('.spinner-border');
var buttonText = button.find('.button-text');
var username = button.data('username');
var password = button.data('password');
var stream = button.data('stream');
var streamURL = button.data('stream-url');
var expiryDate = button.data('expiry-date');
var maxConnections = button.data('max-connections');
spinner.show();
buttonText.hide();
button.prop('disabled', true);
$.ajax({
url: '/validateAccount',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
username: username,
password: password,
stream: stream,
streamURL: streamURL,
expiry_date: expiryDate,
max_connections: maxConnections
}),
success: function(response) {
spinner.hide();
buttonText.show();
if (response.message === 'Account is valid and updated') {
button.prop('disabled', false);
button.removeClass('btn-info').addClass('btn-success');
buttonText.text('Valid & Updated');
setTimeout(function() {
button.removeClass('btn-success').addClass('btn-info');
buttonText.text('Validate');
}, 3000);
} else {
button.prop('disabled', false);
button.removeClass('btn-info').addClass('btn-success');
buttonText.text('Valid');
setTimeout(function() {
button.removeClass('btn-success').addClass('btn-info');
buttonText.text('Validate');
}, 3000);
}
},
error: function(xhr, status, error) {
spinner.hide();
buttonText.show();
button.prop('disabled', false);
button.removeClass('btn-info').addClass('btn-danger');
buttonText.text('Invalid');
setTimeout(function() {
button.removeClass('btn-danger').addClass('btn-info');
buttonText.text('Validate');
}, 3000);
}
});
});
</script> </script>
{% endblock %}
</body>
</html>

View File

@ -1,164 +0,0 @@
import requests
import json
import mysql.connector
import re
import os
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
# Assuming config.py is in the same directory or accessible via PYTHONPATH
from config import DevelopmentConfig as app_config
class NginxProxyManager:
def __init__(self, host, email, password):
self.host = host
self.email = email
self.password = password
self.token = None
def login(self):
url = f"{self.host}/api/tokens"
payload = {
"identity": self.email,
"secret": self.password
}
headers = {
"Content-Type": "application/json"
}
response = requests.post(url, headers=headers, data=json.dumps(payload))
if response.status_code == 200:
self.token = response.json()["token"]
print("Login successful.")
else:
print(f"Failed to login: {response.text}")
exit(1)
def get_proxy_host(self, host_id):
if not self.token:
self.login()
url = f"{self.host}/api/nginx/proxy-hosts/{host_id}"
headers = {
"Authorization": f"Bearer {self.token}"
}
response = requests.get(url, headers=headers)
if response.status_code == 200:
return response.json()
else:
print(f"Failed to get proxy host {host_id}: {response.text}")
return None
def update_proxy_host_config(self, host_id, config):
if not self.token:
self.login()
url = f"{self.host}/api/nginx/proxy-hosts/{host_id}"
original_host_data = self.get_proxy_host(host_id)
if not original_host_data:
return
# Construct a new payload with only the allowed fields for an update
update_payload = {
"domain_names": original_host_data.get("domain_names", []),
"forward_scheme": original_host_data.get("forward_scheme", "http"),
"forward_host": original_host_data.get("forward_host"),
"forward_port": original_host_data.get("forward_port"),
"access_list_id": original_host_data.get("access_list_id", 0),
"certificate_id": original_host_data.get("certificate_id", 0),
"ssl_forced": original_host_data.get("ssl_forced", False),
"hsts_enabled": original_host_data.get("hsts_enabled", False),
"hsts_subdomains": original_host_data.get("hsts_subdomains", False),
"http2_support": original_host_data.get("http2_support", False),
"block_exploits": original_host_data.get("block_exploits", False),
"caching_enabled": original_host_data.get("caching_enabled", False),
"allow_websocket_upgrade": original_host_data.get("allow_websocket_upgrade", False),
"advanced_config": config, # The updated advanced config
"meta": original_host_data.get("meta", {}),
"locations": original_host_data.get("locations", []),
}
headers = {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json"
}
response = requests.put(url, headers=headers, data=json.dumps(update_payload))
if response.status_code == 200:
print(f"Successfully updated proxy host {host_id}")
else:
print(f"Failed to update proxy host {host_id}: {response.text}")
def get_streams_from_db(db_host, db_user, db_pass, db_name, db_port):
try:
conn = mysql.connector.connect(
host=db_host,
user=db_user,
password=db_pass,
database=db_name,
port=db_port
)
cursor = conn.cursor(dictionary=True)
cursor.execute("""
SELECT DISTINCT
SUBSTRING_INDEX(stream, ' ', 1) AS streamName,
streamURL
FROM userAccounts
""")
streams = cursor.fetchall()
cursor.close()
conn.close()
return streams
except mysql.connector.Error as err:
print(f"Error connecting to database: {err}")
return []
def update_config_with_streams(config, streams):
# Get all stream names from the database
db_stream_names = {stream['streamName'] for stream in streams}
# Find all location blocks in the config
location_blocks = re.findall(r'location ~ \^/(\w+)\(\.\*\)\$ \{[^}]+\}', config)
# Remove location blocks that are not in the database
for stream_name in location_blocks:
if stream_name not in db_stream_names:
print(f"Removing location block for stream: {stream_name}")
pattern = re.compile(f'location ~ \\^/{re.escape(stream_name)}\\(\\.\\*\\)\\$ {{[^}}]+}}\\s*', re.DOTALL)
config = pattern.sub('', config)
# Update existing stream URLs
for stream in streams:
stream_name = stream['streamName']
stream_url = stream['streamURL']
if stream_url: # Ensure there is a URL to update to
# Use a more specific regex to avoid replacing parts of other URLs
pattern = re.compile(f'(location ~ \\^/{re.escape(stream_name)}\\(\\.\\*\\)\\$ {{\\s*return 302 )([^;]+)(;\\s*}})')
config = pattern.sub(f'\\1{stream_url}/$1$is_args$args\\3', config)
return config
def main():
npm = NginxProxyManager(app_config.NPM_HOST, app_config.NPM_EMAIL, app_config.NPM_PASSWORD)
npm.login()
host = npm.get_proxy_host(9)
if host:
current_config = host.get('advanced_config', '')
print("Current Config:")
print(current_config)
streams = get_streams_from_db(app_config.DBHOST, app_config.DBUSER, app_config.DBPASS, app_config.DATABASE, app_config.DBPORT)
if streams:
new_config = update_config_with_streams(current_config, streams)
print("\nNew Config:")
print(new_config)
# Uncomment the following line to apply the changes
npm.update_proxy_host_config(9, new_config)
print("\nTo apply the changes, uncomment the last line in the main function.")
if __name__ == "__main__":
main()