Compare commits

...

16 Commits

Author SHA1 Message Date
e3fce36c89 Bump version: 1.3.7 → 1.3.8
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m29s
2025-08-10 17:27:14 +01:00
6741fb6eb1 expiary check 2025-08-10 17:26:15 +01:00
0105b9539a Bump version: 1.3.6 → 1.3.7
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m46s
2025-07-23 09:28:28 +01:00
0f1080b196 rework config login and add update NPM function 2025-07-23 09:26:11 +01:00
5e8f1ee46d Bump version: 1.3.5 → 1.3.6
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m31s
2025-07-19 23:19:34 +01:00
f619644d59 cleanup 2025-07-19 23:19:31 +01:00
0e56ca16bb Bump version: 1.3.4 → 1.3.5
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m31s
2025-07-19 23:14:09 +01:00
b673a232b1 looking for extra urls 2025-07-19 23:13:48 +01:00
64b54e959e lets deploy and see 2025-07-19 11:56:30 +01:00
aeaac5fd4e extra url modifications 2025-07-19 11:31:16 +01:00
a78ca2d0b2 Bump version: 1.3.3 → 1.3.4
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m34s
2025-07-19 10:39:16 +01:00
317066703b cors 2025-07-19 10:39:08 +01:00
c82ac9350f Bump version: 1.3.2 → 1.3.3
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m36s
2025-07-19 09:40:33 +01:00
c8a9cae55e fix the imports 2025-07-19 09:40:31 +01:00
07d32958cb Bump version: 1.3.1 → 1.3.2
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m35s
2025-07-19 09:32:53 +01:00
984b3a62f9 dns on config page 2025-07-19 09:32:45 +01:00
18 changed files with 446 additions and 59 deletions

View File

@ -1,5 +1,5 @@
[tool.bumpversion]
current_version = "1.3.1"
current_version = "1.3.8"
commit = true
tag = true
tag_name = "{new_version}"

2
.vscode/launch.json vendored
View File

@ -17,7 +17,7 @@
"FLASK_APP": "ktvmanager.main:create_app",
"FLASK_ENV": "development",
"PYTHONPATH": "${workspaceFolder}",
"FLASK_RUN_PORT": "5001"
"FLASK_RUN_PORT": "5002"
},
"args": [
"run",

View File

@ -1 +1 @@
1.3.1
1.3.8

View File

@ -33,6 +33,9 @@ ENV FLASK_APP=ktvmanager.main:create_app
# Copy application code
COPY . .
# Debugging step to verify file presence
RUN ls -la /app/ktvmanager/lib
# Handle versioning
ARG VERSION
RUN if [ -n "$VERSION" ]; then echo $VERSION > VERSION; fi

21
generate_vapid_keys.py Normal file
View File

@ -0,0 +1,21 @@
from py_vapid import Vapid
import os
vapid = Vapid()
vapid.generate_keys()
from cryptography.hazmat.primitives import serialization
private_key = vapid.private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
).decode('utf-8')
public_key = vapid.public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
).decode('utf-8')
print(f"VAPID_PRIVATE_KEY='{private_key}'")
print(f"VAPID_PUBLIC_KEY='{public_key}'")

72
host_9_config.txt Normal file
View File

@ -0,0 +1,72 @@
location ~ ^/Mongoose(.*)$ {
return 302 http://m3u.sstv.one:81/$1$is_args$args;
}
location ~ ^/Blazin(.*)$ {
return 302 http://blazin.dns-cloud.net:8080/$1$is_args$args;
}
location ~ ^/Insanity(.*)$ {
return 302 https://biglicks.win:443/$1$is_args$args;
}
location ~ ^/Badger(.*)$ {
return 302 http://hurricanetv.kiev.ua:80/$1$is_args$args;
}
location ~ ^/Gunslinger(.*)$ {
return 302 http://jabawalkies.club:8080/$1$is_args$args;
}
location ~ ^/KDB(.*)$ {
return 302 http://finger-ya-bum-hole.site/$1$is_args$args;
}
location ~ ^/Graphite(.*)$ {
return 302 http://sarahgraphite.liveme.vip:80/$1$is_args$args;
}
location ~ ^/old-Premium(.*)$ {
return 302 https://kwikfitfitter.life:443/$1$is_args$args;
}
location ~ ^/Gold(.*)$ {
return 302 http://server1.elitehosting.gq:8090/$1$is_args$args;
}
location ~ ^/Bravado(.*)$ {
return 302 http://le.thund.re/$1$is_args$args;
}
location ~ ^/Titan(.*)$ {
return 302 http://maximumorg.xyz:80/$1$is_args$args;
}
location ~ ^/Wolfie(.*)$ {
return 302 http://deviltv.fun:8080/$1$is_args$args;
}
location ~ ^/DiamondBack(.*)$ {
return 302 http://pro-media.live:2052/$1$is_args$args;
}
location ~ ^/Halo(.*)$ {
return 302 http://i-like-turtles.org:8080/$1$is_args$args;
}
location ~ ^/Nitro(.*)$ {
return 302 http://mr-beans-streams.xyz$1$is_args$args;
}
location ~ ^/Insanity(.*)$ {
return 302 https://biglicks.win:443/$1$is_args$args;
}
location ~ ^/Bonsai(.*)$ {
return 302 http://crazyservertimes.pro/$1$is_args$args;
}
location ~ ^/New-Prem(.*)$ {
return 302 http://hello.exodus-2.xyz:8080/$1$is_args$args;
}
location ~ ^/Crystal(.*)$ {
return 302 https://line.ottcst.com/$1$is_args$args;
}
location ~ ^/VIP(.*)$ {
return 302 https://1visions.co.uk:443/$1$is_args$args;
}
location ~ ^/WILD(.*)$ {
return 302 http://wildversion.com:8080/$1$is_args$args;
}
location ~ ^/STEST(.*)$ {
return 302 http://notwhatyourlookingfor.ru/$1$is_args$args;
}
location ~ ^/SPARE(.*)$ {
return 302 http://moontv.co.uk/$1$is_args$args;
}
location ~ ^/QUARTZ(.*)$ {
return 302 http://anyholeisagoal.ru/$1$is_args$args;
}

View File

@ -53,9 +53,10 @@ def send_expiry_notifications(app) -> None:
for account in expiring_accounts:
expiry_date = datetime.fromtimestamp(account['expiaryDate'])
days_to_expiry = (expiry_date - now).days
days_to_expiry = (expiry_date.date() - now.date()).days
if days_to_expiry == 30 or days_to_expiry == 7:
print(f"Found expiring account: {account['username']}")
user_id = account['user_id']
subscriptions = get_push_subscriptions(user_id)
for sub in subscriptions:
@ -67,8 +68,11 @@ def send_expiry_notifications(app) -> None:
if last_notified and last_notified.date() == now.date():
continue
message = f"Your account {account['username']} is due to expire in {days_to_expiry} days."
send_notification(sub['subscription_json'], message)
message = {
"title": "Account Expiry Warning",
"body": f"Your account {account['username']} is due to expire in {days_to_expiry} days."
}
send_notification(sub['subscription_json'], json.dumps(message))
# Update the last notified timestamp
update_last_notified_query = "UPDATE push_subscriptions SET last_notified = %s WHERE id = %s"

View File

@ -1,6 +1,7 @@
from functools import wraps
from flask import request, jsonify, Blueprint, Response
from typing import Callable, Any, Tuple, Dict
from .database import get_user_id_from_username
auth_blueprint = Blueprint("auth", __name__)
@ -57,4 +58,5 @@ def check_login(username: str, password: str) -> Response:
Returns:
A Flask JSON response indicating success.
"""
return jsonify({"auth": "Success"})
user_id = get_user_id_from_username(username)
return jsonify({"auth": "Success", "user_id": user_id, "username": username})

View File

@ -138,6 +138,16 @@ def validate_account() -> Tuple[Response, int]:
200,
)
# Check if account is expired
exp_date_str = result["data"]["user_info"]["exp_date"]
if exp_date_str:
from datetime import datetime, timezone
exp_date = datetime.fromtimestamp(int(exp_date_str), tz=timezone.utc)
current_date = datetime.now(timezone.utc)
if current_date > exp_date:
return jsonify({"message": "Account is expired", "data": result}), 401
return jsonify({"message": "Account is valid", "data": result}), 200
else:
return jsonify({"message": "Account is invalid"}), 401

View File

@ -114,6 +114,17 @@ def get_stream_names() -> Response:
return jsonify(stream_names)
def get_all_stream_urls() -> Response:
"""Retrieves all stream names and URLs from the database.
Returns:
A Flask JSON response containing a list of stream names and URLs.
"""
query = "SELECT DISTINCT SUBSTRING_INDEX(stream, ' ', 1) AS streamName, streamURL FROM userAccounts"
results = _execute_query(query)
return jsonify(results)
def single_check() -> Response | Tuple[Response, int]:
"""
Performs a check on a single account provided in the request JSON.
@ -237,3 +248,14 @@ def get_push_subscriptions(user_id: Optional[int] = None) -> List[Dict[str, Any]
else:
query = "SELECT * FROM push_subscriptions"
return _execute_query(query)
def delete_push_subscription(subscription_json: str) -> None:
"""Deletes a push subscription from the database.
Args:
subscription_json: The push subscription information as a JSON string.
"""
query = "DELETE FROM push_subscriptions WHERE subscription_json = %s"
params = (subscription_json,)
_execute_query(query, params)

View File

@ -1,8 +1,11 @@
import json
from flask import current_app
from pywebpush import webpush, WebPushException
from ktvmanager.lib.database import delete_push_subscription
def send_notification(subscription_info, message_body):
def send_notification(subscription_json, message_body):
try:
subscription_info = json.loads(subscription_json)
webpush(
subscription_info=subscription_info,
data=message_body,
@ -11,7 +14,6 @@ def send_notification(subscription_info, message_body):
)
except WebPushException as ex:
print(f"Web push error: {ex}")
# You might want to remove the subscription if it's invalid
if ex.response and ex.response.status_code == 410:
print("Subscription is no longer valid, removing from DB.")
# Add logic to remove the subscription from your database
delete_push_subscription(subscription_json)

View File

@ -1,15 +1,18 @@
import os
from flask import Flask, jsonify
from flask_cors import CORS
from dotenv import load_dotenv
from ktvmanager.config import DevelopmentConfig, ProductionConfig
from routes.api import api_blueprint
from routes.dns import dns_bp
from routes.extra_urls import extra_urls_bp
from ktvmanager.lib.database import initialize_db_pool
from ktvmanager.account_checker import send_expiry_notifications
from apscheduler.schedulers.background import BackgroundScheduler
def create_app():
app = Flask(__name__)
CORS(app)
load_dotenv()
if os.environ.get("FLASK_ENV") == "production":
@ -29,6 +32,7 @@ def create_app():
# Register blueprints
app.register_blueprint(api_blueprint)
app.register_blueprint(dns_bp)
app.register_blueprint(extra_urls_bp)
@app.route('/check-expiry', methods=['POST'])
def check_expiry():

View File

@ -1,47 +0,0 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash
import os
dns_bp = Blueprint('dns', __name__)
DNS_FILE = os.path.join(os.path.dirname(__file__), '..', 'lib', 'DNS_list.txt')
def read_dns_list():
if not os.path.exists(DNS_FILE):
return []
with open(DNS_FILE, 'r') as f:
return [line.strip() for line in f.readlines()]
def write_dns_list(dns_list):
with open(DNS_FILE, 'w') as f:
for item in dns_list:
f.write(f"{item}\n")
@dns_bp.route('/add_dns', methods=['POST'])
def add_dns():
dns_entry = request.form.get('dns_entry')
if dns_entry:
dns_list = read_dns_list()
if dns_entry not in dns_list:
dns_list.append(dns_entry)
write_dns_list(dns_list)
flash('DNS entry added successfully.', 'success')
else:
flash('DNS entry already exists.', 'info')
else:
flash('DNS entry cannot be empty.', 'danger')
return redirect(url_for('config_dashboard'))
@dns_bp.route('/remove_dns', methods=['POST'])
def remove_dns():
dns_entry = request.form.get('dns_entry')
if dns_entry:
dns_list = read_dns_list()
if dns_entry in dns_list:
dns_list.remove(dns_entry)
write_dns_list(dns_list)
flash('DNS entry removed successfully.', 'success')
else:
flash('DNS entry not found.', 'info')
else:
flash('DNS entry cannot be empty.', 'danger')
return redirect(url_for('config_dashboard'))

157
npm_config_modifier.py Normal file
View File

@ -0,0 +1,157 @@
import requests
import json
import argparse
import mysql.connector
import re
import os
from dotenv import load_dotenv
load_dotenv()
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_hosts(self):
if not self.token:
self.login()
url = f"{self.host}/api/nginx/proxy-hosts"
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 hosts: {response.text}")
return []
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}"
payload = {
"advanced_config": config
}
headers = {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json"
}
response = requests.put(url, headers=headers, data=json.dumps(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 streamName, streamURL FROM streams")
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):
for stream in streams:
stream_name = stream['streamName']
stream_url = stream['streamURL']
# 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
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Modify Nginx Proxy Manager custom configuration.")
parser.add_argument("--list-hosts", action="store_true", help="List all proxy hosts")
parser.add_argument("--host-id", type=int, help="The ID of the proxy host to modify")
parser.add_argument("--config-file", type=str, help="Path to the file containing the new advanced configuration")
parser.add_argument("--download-config", type=str, help="Path to save the current advanced configuration")
parser.add_argument("--update-from-db", action="store_true", help="Update the configuration from the database")
args = parser.parse_args()
npm_host = os.getenv("NPM_HOST")
npm_email = os.getenv("NPM_EMAIL")
npm_password = os.getenv("NPM_PASSWORD")
db_host = os.getenv("DBHOST")
db_user = os.getenv("DBUSER")
db_pass = os.getenv("DBPASS")
db_name = os.getenv("DATABASE")
db_port = os.getenv("DBPORT")
npm = NginxProxyManager(npm_host, npm_email, npm_password)
npm.login()
if args.list_hosts:
hosts = npm.get_proxy_hosts()
for host in hosts:
print(f"ID: {host['id']}, Domains: {', '.join(host['domain_names'])}")
if args.host_id and args.download_config:
host = npm.get_proxy_host(args.host_id)
if host:
with open(args.download_config, 'w') as f:
f.write(host.get('advanced_config', ''))
print(f"Configuration for host {args.host_id} downloaded to {args.download_config}")
if args.host_id and args.config_file:
with open(args.config_file, 'r') as f:
config = f.read()
npm.update_proxy_host_config(args.host_id, config)
if args.host_id and args.update_from_db:
host = npm.get_proxy_host(args.host_id)
if host:
current_config = host.get('advanced_config', '')
streams = get_streams_from_db(db_host, db_user, db_pass, db_name, db_port)
if streams:
new_config = update_config_with_streams(current_config, streams)
npm.update_proxy_host_config(args.host_id, new_config)

View File

@ -39,4 +39,5 @@ python-dotenv
python-dotenv
pywebpush==1.13.0
stem==1.8.2
APScheduler==3.10.4
APScheduler==3.10.4
Flask-Cors==4.0.1

View File

@ -8,6 +8,7 @@ from ktvmanager.lib.database import (
get_user_id_from_username,
save_push_subscription,
get_push_subscriptions,
get_all_stream_urls,
)
from ktvmanager.lib.get_urls import get_latest_urls_from_dns
from ktvmanager.lib.auth import requires_basic_auth, check_login
@ -71,6 +72,21 @@ def get_user_accounts_streams_route(username: str, password: str) -> Response:
return jsonify(get_latest_urls_from_dns())
@api_blueprint.route("/get_all_stream_urls")
@requires_basic_auth
def get_all_stream_urls_route(username: str, password: str) -> Response:
"""Retrieves all stream names and URLs.
Args:
username: The username of the user.
password: The password of the user (used for authentication).
Returns:
A Flask JSON response containing the list of stream names and URLs.
"""
return get_all_stream_urls()
@api_blueprint.route("/singleCheck", methods=["POST"])
@requires_basic_auth
def single_check_route(username: str, password: str) -> Response:
@ -237,7 +253,7 @@ def send_test_notification_route(username: str, password: str) -> Response:
for sub in subscriptions:
try:
send_notification(json.loads(sub['subscription_json']), message_body)
send_notification(sub['subscription_json'], message_body)
success_count += 1
except Exception as e:
print(f"Error sending notification to subscription ID {sub.get('id', 'N/A')}: {e}")

60
routes/dns.py Normal file
View File

@ -0,0 +1,60 @@
from flask import Blueprint, request, jsonify
import os
dns_bp = Blueprint('dns', __name__)
DNS_FILE = os.path.join(os.path.dirname(__file__), '..', 'ktvmanager', 'lib', 'DNS_list.txt')
def read_dns_list():
if not os.path.exists(DNS_FILE):
return []
with open(DNS_FILE, 'r') as f:
return [line.strip() for line in f.readlines() if line.strip()]
def write_dns_list(dns_list):
with open(DNS_FILE, 'w') as f:
for item in dns_list:
f.write(f"{item}\n")
@dns_bp.route('/dns', methods=['GET'])
def get_dns_list():
"""Gets the list of DNS entries."""
return jsonify(read_dns_list())
@dns_bp.route('/dns', methods=['POST'])
def add_dns():
"""Adds a new DNS entry."""
data = request.get_json()
if not data or 'dns_entry' not in data:
return jsonify({'error': 'Missing dns_entry in request body'}), 400
dns_entry = data.get('dns_entry')
if not dns_entry:
return jsonify({'error': 'DNS entry cannot be empty.'}), 400
dns_list = read_dns_list()
if dns_entry in dns_list:
return jsonify({'message': 'DNS entry already exists.'}), 200
dns_list.append(dns_entry)
write_dns_list(dns_list)
return jsonify({'message': 'DNS entry added successfully.'}), 201
@dns_bp.route('/dns', methods=['DELETE'])
def remove_dns():
"""Removes a DNS entry."""
data = request.get_json()
if not data or 'dns_entry' not in data:
return jsonify({'error': 'Missing dns_entry in request body'}), 400
dns_entry = data.get('dns_entry')
if not dns_entry:
return jsonify({'error': 'DNS entry cannot be empty.'}), 400
dns_list = read_dns_list()
if dns_entry not in dns_list:
return jsonify({'error': 'DNS entry not found.'}), 404
dns_list.remove(dns_entry)
write_dns_list(dns_list)
return jsonify({'message': 'DNS entry removed successfully.'}), 200

60
routes/extra_urls.py Normal file
View File

@ -0,0 +1,60 @@
from flask import Blueprint, request, jsonify
import os
extra_urls_bp = Blueprint('extra_urls', __name__)
EXTRA_URLS_FILE = os.path.join(os.path.dirname(__file__), '..', 'ktvmanager', 'lib', 'extra_urls.txt')
def read_extra_urls_list():
if not os.path.exists(EXTRA_URLS_FILE):
return []
with open(EXTRA_URLS_FILE, 'r') as f:
return [line.strip() for line in f.readlines() if line.strip()]
def write_extra_urls_list(extra_urls_list):
with open(EXTRA_URLS_FILE, 'w') as f:
for item in extra_urls_list:
f.write(f"{item}\n")
@extra_urls_bp.route('/extra_urls', methods=['GET'])
def get_extra_urls_list():
"""Gets the list of extra URLs."""
return jsonify(read_extra_urls_list())
@extra_urls_bp.route('/extra_urls', methods=['POST'])
def add_extra_url():
"""Adds a new extra URL."""
data = request.get_json()
if not data or 'extra_url' not in data:
return jsonify({'error': 'Missing extra_url in request body'}), 400
extra_url = data.get('extra_url')
if not extra_url:
return jsonify({'error': 'Extra URL cannot be empty.'}), 400
extra_urls_list = read_extra_urls_list()
if extra_url in extra_urls_list:
return jsonify({'message': 'Extra URL already exists.'}), 200
extra_urls_list.append(extra_url)
write_extra_urls_list(extra_urls_list)
return jsonify({'message': 'Extra URL added successfully.'}), 201
@extra_urls_bp.route('/extra_urls', methods=['DELETE'])
def remove_extra_url():
"""Removes an extra URL."""
data = request.get_json()
if not data or 'extra_url' not in data:
return jsonify({'error': 'Missing extra_url in request body'}), 400
extra_url = data.get('extra_url')
if not extra_url:
return jsonify({'error': 'Extra URL cannot be empty.'}), 400
extra_urls_list = read_extra_urls_list()
if extra_url not in extra_urls_list:
return jsonify({'error': 'Extra URL not found.'}), 404
extra_urls_list.remove(extra_url)
write_extra_urls_list(extra_urls_list)
return jsonify({'message': 'Extra URL removed successfully.'}), 200