Compare commits

..

14 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
16 changed files with 388 additions and 14 deletions

View File

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

2
.vscode/launch.json vendored
View File

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

View File

@ -1 +1 @@
1.3.2 1.3.8

View File

@ -33,6 +33,9 @@ ENV FLASK_APP=ktvmanager.main:create_app
# Copy application code # Copy application code
COPY . . COPY . .
# Debugging step to verify file presence
RUN ls -la /app/ktvmanager/lib
# Handle versioning # Handle versioning
ARG VERSION ARG VERSION
RUN if [ -n "$VERSION" ]; then echo $VERSION > VERSION; fi 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: for account in expiring_accounts:
expiry_date = datetime.fromtimestamp(account['expiaryDate']) 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: if days_to_expiry == 30 or days_to_expiry == 7:
print(f"Found expiring account: {account['username']}")
user_id = account['user_id'] user_id = account['user_id']
subscriptions = get_push_subscriptions(user_id) subscriptions = get_push_subscriptions(user_id)
for sub in subscriptions: for sub in subscriptions:
@ -67,8 +68,11 @@ def send_expiry_notifications(app) -> None:
if last_notified and last_notified.date() == now.date(): if last_notified and last_notified.date() == now.date():
continue continue
message = f"Your account {account['username']} is due to expire in {days_to_expiry} days." message = {
send_notification(sub['subscription_json'], 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 the last notified timestamp
update_last_notified_query = "UPDATE push_subscriptions SET last_notified = %s WHERE id = %s" 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 functools import wraps
from flask import request, jsonify, Blueprint, Response from flask import request, jsonify, Blueprint, Response
from typing import Callable, Any, Tuple, Dict from typing import Callable, Any, Tuple, Dict
from .database import get_user_id_from_username
auth_blueprint = Blueprint("auth", __name__) auth_blueprint = Blueprint("auth", __name__)
@ -57,4 +58,5 @@ def check_login(username: str, password: str) -> Response:
Returns: Returns:
A Flask JSON response indicating success. 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, 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 return jsonify({"message": "Account is valid", "data": result}), 200
else: else:
return jsonify({"message": "Account is invalid"}), 401 return jsonify({"message": "Account is invalid"}), 401

View File

@ -114,6 +114,17 @@ def get_stream_names() -> Response:
return jsonify(stream_names) 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]: def single_check() -> Response | Tuple[Response, int]:
""" """
Performs a check on a single account provided in the request JSON. 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: else:
query = "SELECT * FROM push_subscriptions" query = "SELECT * FROM push_subscriptions"
return _execute_query(query) 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 flask import current_app
from pywebpush import webpush, WebPushException 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: try:
subscription_info = json.loads(subscription_json)
webpush( webpush(
subscription_info=subscription_info, subscription_info=subscription_info,
data=message_body, data=message_body,
@ -11,7 +14,6 @@ def send_notification(subscription_info, message_body):
) )
except WebPushException as ex: except WebPushException as ex:
print(f"Web push error: {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: if ex.response and ex.response.status_code == 410:
print("Subscription is no longer valid, removing from DB.") 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 import os
from flask import Flask, jsonify from flask import Flask, jsonify
from flask_cors import CORS
from dotenv import load_dotenv from dotenv import load_dotenv
from ktvmanager.config import DevelopmentConfig, ProductionConfig from ktvmanager.config import DevelopmentConfig, ProductionConfig
from ktvmanager.routes.api import api_blueprint from routes.api import api_blueprint
from ktvmanager.routes.dns import dns_bp from routes.dns import dns_bp
from routes.extra_urls import extra_urls_bp
from ktvmanager.lib.database import initialize_db_pool from ktvmanager.lib.database import initialize_db_pool
from ktvmanager.account_checker import send_expiry_notifications from ktvmanager.account_checker import send_expiry_notifications
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
def create_app(): def create_app():
app = Flask(__name__) app = Flask(__name__)
CORS(app)
load_dotenv() load_dotenv()
if os.environ.get("FLASK_ENV") == "production": if os.environ.get("FLASK_ENV") == "production":
@ -29,6 +32,7 @@ def create_app():
# Register blueprints # Register blueprints
app.register_blueprint(api_blueprint) app.register_blueprint(api_blueprint)
app.register_blueprint(dns_bp) app.register_blueprint(dns_bp)
app.register_blueprint(extra_urls_bp)
@app.route('/check-expiry', methods=['POST']) @app.route('/check-expiry', methods=['POST'])
def check_expiry(): def check_expiry():

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 python-dotenv
pywebpush==1.13.0 pywebpush==1.13.0
stem==1.8.2 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, get_user_id_from_username,
save_push_subscription, save_push_subscription,
get_push_subscriptions, get_push_subscriptions,
get_all_stream_urls,
) )
from ktvmanager.lib.get_urls import get_latest_urls_from_dns from ktvmanager.lib.get_urls import get_latest_urls_from_dns
from ktvmanager.lib.auth import requires_basic_auth, check_login 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()) 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"]) @api_blueprint.route("/singleCheck", methods=["POST"])
@requires_basic_auth @requires_basic_auth
def single_check_route(username: str, password: str) -> Response: 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: for sub in subscriptions:
try: try:
send_notification(json.loads(sub['subscription_json']), message_body) send_notification(sub['subscription_json'], message_body)
success_count += 1 success_count += 1
except Exception as e: except Exception as e:
print(f"Error sending notification to subscription ID {sub.get('id', 'N/A')}: {e}") print(f"Error sending notification to subscription ID {sub.get('id', 'N/A')}: {e}")

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