Compare commits
186 Commits
Author | SHA1 | Date | |
---|---|---|---|
8b9c100e87 | |||
6462fc6009 | |||
dbf0161133 | |||
0d56235863 | |||
5c80751484 | |||
1d2b1db9df | |||
1dd83f4230 | |||
3888e6d536 | |||
d063d7fb7f | |||
70e7782918 | |||
785fdb6dbb | |||
5106686a12 | |||
21c48d0b6a | |||
7f42202383 | |||
824bdd080f | |||
ef38edfcd4 | |||
7622716b92 | |||
08ebb7a265 | |||
644ba005aa | |||
eea6708f42 | |||
8b9880ce44 | |||
21234fccc6 | |||
c62441a2d1 | |||
d519a268a0 | |||
e76a54854b | |||
6cf291f857 | |||
2cdb601706 | |||
e84441e7f1 | |||
7ad67a80f5 | |||
64f0da662b | |||
472098f9f1 | |||
ec928cf631 | |||
9e3348d9b6 | |||
75f210df5f | |||
b5fcc31cf4 | |||
898d737324 | |||
33c8af61ca | |||
b274bf12d3 | |||
868571f1a8 | |||
15a09789a0 | |||
44fa24f2b7 | |||
c494a90227 | |||
fc16f5f4f1 | |||
fc359a844f | |||
a45f2ba3cb | |||
7329313399 | |||
37f888fa2d | |||
22be992500 | |||
aec1549698 | |||
e30d85065d | |||
a6cddef761 | |||
70c900fc67 | |||
fcd35f166e | |||
a9ece95d07 | |||
85f1ddb877 | |||
f77f395de6 | |||
e16fb42e37 | |||
7468b1b391 | |||
3191fabf39 | |||
6736680131 | |||
16dfcab6ba | |||
a041ec2abb | |||
8c982666d6 | |||
9e63cbc2f0 | |||
e4f92a1db2 | |||
b81d0ae81c | |||
0f1e97bba2 | |||
a9276f63dc | |||
cf46844a4e | |||
07dc830160 | |||
6fa693ecd0 | |||
2e63cd951a | |||
b082500c01 | |||
ab90dd2679 | |||
978b9b2c71 | |||
d048360c11 | |||
be49a31e54 | |||
6b30769d96 | |||
051e5fb290 | |||
c9329a33ea | |||
b95f00486b | |||
b446b00fc6 | |||
4caf1add26 | |||
d40f98f5e4 | |||
b5f1375a87 | |||
fbaf79c9ae | |||
2f68a82c33 | |||
1e36958279 | |||
4c33235322 | |||
b994f22725 | |||
4677ed76af | |||
c2080357f2 | |||
63b5db6fd0 | |||
3917b41968 | |||
bc01f4d980 | |||
3e1e7a5ce2 | |||
1fc2fc46e0 | |||
603d85b529 | |||
c1a3bb6ba4 | |||
a81a096cfb | |||
2a49ca6bf2 | |||
4305ea61ba | |||
26ef5b082a | |||
4f9371ae0f | |||
630c5aa537 | |||
31480c10a4 | |||
8784e141eb | |||
d623725289 | |||
0d056fa5de | |||
424d58c4cb | |||
2da6c6584a | |||
e04bdea613 | |||
d3fe4ad380 | |||
72d238b05d | |||
ef9804da72 | |||
37cefe3422 | |||
31ef25ead3 | |||
6689e3fad5 | |||
47d4c24518 | |||
32ca9b5dfb | |||
cb7c994e11 | |||
f03f56f76b | |||
7d98dd82c9 | |||
c389ffb496 | |||
5e76631c75 | |||
65356eef6f | |||
988ecd11d2 | |||
260e330f9a | |||
cf2c004958 | |||
d3913d786c | |||
0e9d8f2a9f | |||
ee0d216045 | |||
8b9c6da2b7 | |||
259b128af0 | |||
2e2ee7c693 | |||
a77bc95ce3 | |||
0eb79c3775 | |||
9425fde4b8 | |||
0705011384 | |||
8f23b9d3b1 | |||
e559156232 | |||
96d0fc8da7 | |||
9748b664aa | |||
9dc9b7e1b5 | |||
8df6af5edf | |||
e3cce698a0 | |||
318468fe53 | |||
41fef3cb15 | |||
cc576e3c91 | |||
1455e45554 | |||
a442460338 | |||
f3b37d6ac2 | |||
c174f1dbc9 | |||
13368a0437 | |||
8ce43b9328 | |||
891be7d502 | |||
b339a00d4b | |||
b24ecee2ba | |||
9e1e442c74 | |||
64574c624f | |||
96c9ec9d57 | |||
b2fc0a22a1 | |||
8adf086818 | |||
7015561136 | |||
d67c4dd94b | |||
629ef4e975 | |||
950183542f | |||
bdc96504bc | |||
56f0396b20 | |||
b1ada983ec | |||
4fa0cb083f | |||
f345241ab8 | |||
e46a8862ad | |||
40bead1e6a | |||
4b50711cd3 | |||
3f1ebfbac0 | |||
ce6572b81b | |||
bcab963b99 | |||
5a5692d8cc | |||
603d178d03 | |||
30918daeb4 | |||
09293b5cf2 | |||
dbb1eb7b17 | |||
57c6099a43 | |||
aaf7f58af3 | |||
deeea48b20 |
8
.bumpversion.toml
Normal file
8
.bumpversion.toml
Normal file
@ -0,0 +1,8 @@
|
||||
[tool.bumpversion]
|
||||
current_version = "1.4.10"
|
||||
commit = true
|
||||
tag = true
|
||||
tag_name = "{new_version}"
|
||||
|
||||
[[tool.bumpversion.files]]
|
||||
filename = "VERSION"
|
21
.dockerignore
Normal file
21
.dockerignore
Normal file
@ -0,0 +1,21 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# IDE/Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Build artifacts
|
||||
dockerfile
|
||||
.dockerignore
|
||||
*.sample
|
@ -2,10 +2,8 @@ name: Build and Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
- "*.*.*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@ -21,10 +19,7 @@ jobs:
|
||||
with:
|
||||
images: ${{ secrets.REGISTRY }}/${{ secrets.USERNAME }}/ktv-ui
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@ -42,4 +37,9 @@ jobs:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
labels: |
|
||||
org.opencontainers.image.version=${{ steps.meta.outputs.version }}
|
||||
org.opencontainers.image.title=KTVManager_UI
|
||||
org.opencontainers.image.description=UI for KTVManager
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
|
24
.vscode/launch.json
vendored
24
.vscode/launch.json
vendored
@ -1,17 +1,25 @@
|
||||
{
|
||||
// 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",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Python Debugger: Current File",
|
||||
"name": "Python: Flask",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "${file}",
|
||||
"console": "integratedTerminal",
|
||||
"justMyCode": false,
|
||||
"args": ["--host=0.0.0.0"]
|
||||
"module": "flask",
|
||||
"env": {
|
||||
"FLASK_APP": "app.py",
|
||||
"FLASK_ENV": "development"
|
||||
},
|
||||
"args": [
|
||||
"run",
|
||||
"--no-debugger",
|
||||
"--port",
|
||||
"5005",
|
||||
"--host",
|
||||
"0.0.0.0"
|
||||
],
|
||||
"jinja": true,
|
||||
"justMyCode": true
|
||||
}
|
||||
]
|
||||
}
|
565
app.py
565
app.py
@ -1,195 +1,554 @@
|
||||
# app.py
|
||||
from flask import Flask, render_template, request, redirect, url_for, session, send_file, jsonify
|
||||
from flask import (Flask, render_template, request, redirect, url_for, session,
|
||||
send_file, jsonify, send_from_directory, Response)
|
||||
from flask_caching import Cache
|
||||
import requests.auth
|
||||
import os
|
||||
from lib.datetime import filter_accounts_next_30_days, filter_accounts_expired
|
||||
from lib.reqs import get_urls, get_user_accounts, add_user_account, delete_user_account, get_user_accounts_count, get_stream_names
|
||||
from flask import send_from_directory
|
||||
import requests
|
||||
import base64
|
||||
from flask import Flask
|
||||
from config import DevelopmentConfig
|
||||
from paddleocr import PaddleOCR
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
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.reqs import (get_urls, get_user_accounts, add_user_account,
|
||||
delete_user_account, get_stream_names)
|
||||
from config import DevelopmentConfig, ProductionConfig
|
||||
|
||||
|
||||
os.environ["OMP_NUM_THREADS"] = "1"
|
||||
os.environ["MKL_NUM_THREADS"] = "1"
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(
|
||||
DevelopmentConfig
|
||||
|
||||
if os.environ.get("FLASK_ENV") == "production":
|
||||
app.config.from_object(ProductionConfig)
|
||||
else:
|
||||
app.config.from_object(DevelopmentConfig)
|
||||
|
||||
# Check for Redis availability and configure cache
|
||||
redis_url = app.config["REDIS_URL"]
|
||||
cache_config = {"CACHE_TYPE": "redis", "CACHE_REDIS_URL": redis_url}
|
||||
try:
|
||||
# Use a short timeout to prevent hanging
|
||||
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 = Cache(app, config={"CACHE_TYPE": "SimpleCache"})
|
||||
cache_config = {"CACHE_TYPE": "SimpleCache"}
|
||||
|
||||
ocr = PaddleOCR(use_angle_cls=True, lang='en') # Adjust language if needed
|
||||
cache = Cache(app, config=cache_config)
|
||||
|
||||
app.config['SESSION_COOKIE_SECURE'] = True # Only send cookie over HTTPS
|
||||
app.config['SESSION_COOKIE_HTTPONLY'] = True # Prevent JavaScript access
|
||||
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # Adjust for cross-site requests
|
||||
app.config['PERMANENT_SESSION_LIFETIME'] = 60 * 60 * 24 * 365 # 1 year in seconds
|
||||
cache.clear() # Clears all cache entries
|
||||
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:
|
||||
with open('VERSION', 'r') as f:
|
||||
return f.read().strip()
|
||||
except FileNotFoundError:
|
||||
return 'dev'
|
||||
|
||||
@app.context_processor
|
||||
def inject_version() -> Dict[str, str]:
|
||||
"""Injects the version into all templates."""
|
||||
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
|
||||
def make_session_permanent():
|
||||
def make_session_permanent() -> None:
|
||||
"""Makes the user session permanent."""
|
||||
session.permanent = True
|
||||
|
||||
@app.route('/manifest.json')
|
||||
def serve_manifest():
|
||||
return send_file('manifest.json', mimetype='application/manifest+json')
|
||||
@app.route('/site.webmanifest')
|
||||
def serve_manifest() -> Response:
|
||||
"""Serves the site manifest file."""
|
||||
return send_from_directory(
|
||||
os.path.join(app.root_path, 'static'),
|
||||
'site.webmanifest',
|
||||
mimetype='application/manifest+json'
|
||||
)
|
||||
|
||||
@app.route("/favicon.ico")
|
||||
def favicon():
|
||||
def favicon() -> Response:
|
||||
"""Serves the favicon."""
|
||||
return send_from_directory(
|
||||
os.path.join(app.root_path, "static"),
|
||||
"favicon.ico",
|
||||
mimetype="image/vnd.microsoft.icon",
|
||||
)
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
# If the user is logged in, redirect to a protected page like /accounts
|
||||
def index() -> Union[Response, str]:
|
||||
"""Renders the index page or redirects to home if logged in."""
|
||||
if session.get("logged_in"):
|
||||
return redirect(url_for("home"))
|
||||
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")
|
||||
@cache.cached(timeout=60) # cache for 120 seconds
|
||||
def home():
|
||||
@cache.cached(timeout=60, key_prefix=make_cache_key)
|
||||
def home() -> str:
|
||||
"""Renders the home page with account statistics."""
|
||||
if session.get("logged_in"):
|
||||
base_url = app.config["BASE_URL"] # Access base_url from the config
|
||||
base_url = app.config["BACKEND_URL"]
|
||||
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(
|
||||
"home.html",
|
||||
username=session["username"],
|
||||
accounts=count,
|
||||
current_month_accounts=current_month_accounts,
|
||||
expired_accounts=expired_accounts,
|
||||
accounts=len(all_accounts),
|
||||
current_month_accounts=filter_accounts_next_30_days(all_accounts),
|
||||
expired_accounts=filter_accounts_expired(all_accounts),
|
||||
)
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
@app.route("/login", methods=["POST"])
|
||||
def login():
|
||||
def login() -> Union[Response, str]:
|
||||
"""Handles user login."""
|
||||
username = request.form["username"]
|
||||
password = request.form["password"]
|
||||
|
||||
# Encode the username and password in Base64
|
||||
credentials = f"{username}:{password}"
|
||||
encoded_credentials = base64.b64encode(credentials.encode()).decode()
|
||||
base_url = app.config["BACKEND_URL"]
|
||||
login_url = f"{base_url}/Login"
|
||||
|
||||
base_url = app.config["BASE_URL"] # Access base_url from the config
|
||||
login_url = f"{base_url}/Login" # Construct the full URL
|
||||
|
||||
# Send GET request to the external login API with Basic Auth
|
||||
try:
|
||||
response = requests.get(
|
||||
login_url, auth=requests.auth.HTTPBasicAuth(username, password)
|
||||
)
|
||||
|
||||
# Check if login was successful
|
||||
if response.status_code == 200 and response.json().get("auth") == "Success":
|
||||
# Set session variable to indicate the user is logged in
|
||||
response.raise_for_status()
|
||||
response_data = response.json()
|
||||
if response_data.get("auth") == "Success":
|
||||
session["logged_in"] = True
|
||||
session["username"] = username
|
||||
session["username"] = response_data.get("username", username)
|
||||
session["user_id"] = response_data.get("user_id")
|
||||
session["auth_credentials"] = encoded_credentials
|
||||
return redirect(url_for("home")) # Redirect to the Accounts page
|
||||
else:
|
||||
# Show error on the login page
|
||||
next_url = request.args.get("next")
|
||||
if next_url:
|
||||
return redirect(next_url)
|
||||
return redirect(url_for("home", loggedin=True))
|
||||
except requests.exceptions.RequestException:
|
||||
pass # Fall through to error
|
||||
|
||||
error = "Invalid username or password. Please try again."
|
||||
return render_template("index.html", error=error)
|
||||
|
||||
|
||||
@app.route("/urls", methods=["GET"])
|
||||
@cache.cached(timeout=300) # cache for 5 minutes
|
||||
def urls():
|
||||
# Check if the user is logged in
|
||||
@cache.cached(timeout=300, key_prefix=make_cache_key)
|
||||
def urls() -> Union[Response, str]:
|
||||
"""Renders the URLs page."""
|
||||
if not session.get("logged_in"):
|
||||
return redirect(url_for("home"))
|
||||
# Placeholder content for Accounts page
|
||||
base_url = app.config["BASE_URL"] # Access base_url from the config
|
||||
base_url = app.config["BACKEND_URL"]
|
||||
return render_template(
|
||||
"urls.html", urls=get_urls(base_url, session["auth_credentials"])
|
||||
)
|
||||
|
||||
|
||||
@app.route("/accounts", methods=["GET"])
|
||||
@cache.cached(timeout=120) # cache for 120 seconds
|
||||
def user_accounts():
|
||||
# Check if the user is logged in
|
||||
@cache.cached(timeout=60, key_prefix=make_cache_key)
|
||||
def user_accounts() -> Union[Response, str]:
|
||||
"""Renders the user accounts page."""
|
||||
if not session.get("logged_in"):
|
||||
return redirect(url_for("home"))
|
||||
# Placeholder content for Accounts page
|
||||
base_url = app.config["BASE_URL"] # Access base_url from the config
|
||||
base_url = app.config["BACKEND_URL"]
|
||||
user_accounts_data = get_user_accounts(base_url, session["auth_credentials"])
|
||||
return render_template(
|
||||
"user_accounts.html",
|
||||
username=session["username"],
|
||||
user_accounts=get_user_accounts(base_url, session["auth_credentials"]),
|
||||
user_accounts=user_accounts_data,
|
||||
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"])
|
||||
def add_account():
|
||||
base_url = app.config["BASE_URL"] # Access base_url from the config
|
||||
def add_account() -> Union[Response, str]:
|
||||
"""Handles adding a new user account."""
|
||||
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":
|
||||
username = request.form["username"]
|
||||
password = request.form["password"]
|
||||
stream = request.form["stream"]
|
||||
|
||||
if add_user_account(
|
||||
base_url, session["auth_credentials"], username, password, stream
|
||||
):
|
||||
cache.clear() # Clears all cache entries
|
||||
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 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"])
|
||||
def delete_account():
|
||||
def delete_account() -> Response:
|
||||
"""Handles deleting a user account."""
|
||||
stream = request.form.get("stream")
|
||||
username = request.form.get("username")
|
||||
base_url = app.config["BASE_URL"]
|
||||
|
||||
if delete_user_account(base_url, session["auth_credentials"], stream, username):
|
||||
cache.clear() # Clears all cache entries
|
||||
return redirect(url_for("user_accounts"))
|
||||
base_url = app.config["BACKEND_URL"]
|
||||
delete_user_account(base_url, session["auth_credentials"], stream, username)
|
||||
cache.delete_memoized(user_accounts, key_prefix=make_cache_key)
|
||||
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)
|
||||
|
||||
@app.route("/get_stream_names", methods=["GET"])
|
||||
def stream_names():
|
||||
if not session.get("logged_in"):
|
||||
return redirect(url_for("home"))
|
||||
base_url = app.config["BASE_URL"]
|
||||
stream_names = get_stream_names(base_url, session["auth_credentials"])
|
||||
return jsonify(stream_names)
|
||||
|
||||
|
||||
@app.route('/OCRupload', methods=['POST'])
|
||||
def OCRupload():
|
||||
if 'image' not in request.files:
|
||||
return jsonify({"error": "No image file found"}), 400
|
||||
# Get the uploaded file
|
||||
file = request.files['image']
|
||||
try:
|
||||
image = Image.open(file.stream)
|
||||
image_np = np.array(image)
|
||||
result = ocr.ocr(image_np)
|
||||
# Extract text
|
||||
extracted_text = []
|
||||
for line in result[0]:
|
||||
extracted_text.append(line[1][0])
|
||||
return render_template("add_account.html", username=extracted_text[2], password=extracted_text[3])
|
||||
except Exception as e:
|
||||
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"])
|
||||
def stream_names() -> Union[Response, str]:
|
||||
"""Fetches and returns stream names as JSON."""
|
||||
if not session.get("logged_in"):
|
||||
return redirect(url_for("home"))
|
||||
base_url = app.config["BACKEND_URL"]
|
||||
return jsonify(get_stream_names(base_url, session["auth_credentials"]))
|
||||
|
||||
|
||||
@app.route('/config')
|
||||
def config():
|
||||
"""Handles access to the configuration page."""
|
||||
if session.get('user_id') and int(session.get('user_id')) == 1:
|
||||
return redirect(url_for('config_dashboard'))
|
||||
return redirect(url_for('home'))
|
||||
|
||||
@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:
|
||||
response = requests.post(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('/dns', methods=['GET', 'POST', 'DELETE'])
|
||||
def proxy_dns():
|
||||
"""Proxies DNS 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']}/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__":
|
||||
app.run(debug=app.config["DEBUG"], host=app.config["HOST"], port=app.config["PORT"])
|
||||
app.run(
|
||||
debug=app.config["DEBUG"],
|
||||
host=app.config["HOST"],
|
||||
port=app.config["PORT"]
|
||||
)
|
||||
|
@ -1,19 +0,0 @@
|
||||
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"])
|
@ -1,37 +0,0 @@
|
||||
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
|
18
bump_and_push.sh
Normal file
18
bump_and_push.sh
Normal file
@ -0,0 +1,18 @@
|
||||
#!/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
|
@ -1,11 +0,0 @@
|
||||
# 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
|
47
dockerfile
47
dockerfile
@ -1,22 +1,39 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Install system dependencies required by PaddleOCR and OpenCV
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libglib2.0-0 \
|
||||
libsm6 \
|
||||
libxrender1 \
|
||||
libxext6 \
|
||||
libgomp1 \
|
||||
libgl1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Builder stage
|
||||
FROM python:3.11-slim-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
# Final stage
|
||||
FROM python:3.11-slim-bookworm AS final
|
||||
|
||||
EXPOSE 8089
|
||||
|
||||
CMD ["python", "app.py"]
|
||||
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
|
||||
RUN echo $VERSION > VERSION
|
||||
|
||||
# Create a non-root user
|
||||
RUN useradd --create-home appuser && \
|
||||
mkdir -p /app/tmp && \
|
||||
chown -R appuser:appuser /app/tmp
|
||||
USER appuser
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
ENV FLASK_ENV=production
|
||||
CMD ["./run.sh"]
|
||||
|
11
gunicorn.conf.py
Normal file
11
gunicorn.conf.py
Normal file
@ -0,0 +1,11 @@
|
||||
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"
|
@ -1,46 +1,51 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict
|
||||
from typing import List, Dict, Any
|
||||
|
||||
def filter_accounts_next_30_days(accounts: List[Dict[str, int]]) -> List[Dict[str, int]]:
|
||||
"""Filter accounts whose expiry date falls within the next 30 days.
|
||||
def filter_accounts_next_30_days(accounts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Filters accounts expiring within the next 30 days.
|
||||
|
||||
Args:
|
||||
accounts (List[Dict[str, int]]): A list of account dictionaries, each containing
|
||||
an 'expiaryDate' key with an epoch timestamp as its value.
|
||||
accounts: A list of account dictionaries, each with an 'expiaryDate'
|
||||
(epoch timestamp).
|
||||
|
||||
Returns:
|
||||
List[Dict[str, int]]: A list of accounts expiring within the next 30 days.
|
||||
A list of accounts expiring within the next 30 days, with added
|
||||
'expiaryDate_rendered' and 'days_to_expiry' keys.
|
||||
"""
|
||||
now = datetime.now()
|
||||
thirty_days_later = now + timedelta(days=30)
|
||||
|
||||
# Convert current time and 30 days later to epoch timestamps
|
||||
now_timestamp = int(now.timestamp())
|
||||
thirty_days_later_timestamp = int(thirty_days_later.timestamp())
|
||||
|
||||
# Filter accounts with expiryDate within the next 30 days
|
||||
return [
|
||||
account for account in accounts
|
||||
if now_timestamp <= account['expiaryDate'] < thirty_days_later_timestamp
|
||||
]
|
||||
result = []
|
||||
today = now.date()
|
||||
|
||||
def filter_accounts_expired(accounts: List[Dict[str, int]]) -> List[Dict[str, int]]:
|
||||
"""Filter accounts whose expiry date has passed.
|
||||
for account in accounts:
|
||||
if now_timestamp <= account['expiaryDate'] < thirty_days_later_timestamp:
|
||||
expiry_date = datetime.fromtimestamp(account['expiaryDate'])
|
||||
account['expiaryDate_rendered'] = expiry_date.strftime('%d-%m-%Y')
|
||||
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]]:
|
||||
"""Filters accounts that have already expired.
|
||||
|
||||
Args:
|
||||
accounts (List[Dict[str, int]]): A list of account dictionaries, each containing
|
||||
an 'expiaryDate' key with an epoch timestamp as its value.
|
||||
accounts: A list of account dictionaries, each with an 'expiaryDate'
|
||||
(epoch timestamp).
|
||||
|
||||
Returns:
|
||||
List[Dict[str, int]]: A list of accounts that have expired.
|
||||
A list of expired accounts with an added 'expiaryDate_rendered' key.
|
||||
"""
|
||||
# Get the current epoch timestamp
|
||||
current_timestamp = int(datetime.now().timestamp())
|
||||
|
||||
# Filter accounts where the current date is greater than the expiryDate
|
||||
expired_accounts = [
|
||||
account for account in accounts
|
||||
if account['expiaryDate'] < current_timestamp
|
||||
]
|
||||
expired_accounts = []
|
||||
for account in accounts:
|
||||
if account['expiaryDate'] < current_timestamp:
|
||||
expiry_date = datetime.fromtimestamp(account['expiaryDate'])
|
||||
account['expiaryDate_rendered'] = expiry_date.strftime('%d-%m-%Y')
|
||||
expired_accounts.append(account)
|
||||
|
||||
return expired_accounts
|
||||
|
161
lib/reqs.py
161
lib/reqs.py
@ -1,123 +1,84 @@
|
||||
import requests
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
# 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]]:
|
||||
"""Retrieve user account streams 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:
|
||||
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)
|
||||
"""Retrieves user account streams from the API."""
|
||||
response = _make_api_request("GET", base_url, auth, "getUserAccounts/streams")
|
||||
return response.json() if response else []
|
||||
|
||||
|
||||
def get_user_accounts(base_url: str, auth: str) -> List[Dict[str, Any]]:
|
||||
"""Retrieve user accounts from the specified base URL.
|
||||
"""Retrieves user accounts from the API."""
|
||||
response = _make_api_request("GET", base_url, auth, "getUserAccounts")
|
||||
if not response:
|
||||
return []
|
||||
|
||||
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 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:
|
||||
accounts = response.json()
|
||||
for account in accounts:
|
||||
account["expiaryDate_rendered"] = datetime.utcfromtimestamp(
|
||||
account["expiaryDate"]
|
||||
).strftime("%d/%m/%Y")
|
||||
|
||||
return res_json
|
||||
return accounts
|
||||
|
||||
|
||||
def delete_user_account(base_url: str, auth: str, stream: str, username: str) -> bool:
|
||||
"""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"
|
||||
"""Deletes a user account via the API."""
|
||||
payload = {"stream": stream, "user": username}
|
||||
headers = {"Authorization": f"Basic {auth}"}
|
||||
|
||||
response = requests.request("POST", url, headers=headers, data=payload)
|
||||
return "Deleted" in response.text
|
||||
response = _make_api_request(
|
||||
"POST", base_url, auth, "deleteAccount", payload=payload
|
||||
)
|
||||
return response and "Deleted" in response.text
|
||||
|
||||
|
||||
def add_user_account(base_url: str, auth: str, username: str, password: str, stream: str) -> bool:
|
||||
"""Add a user account to the specified base URL.
|
||||
|
||||
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"
|
||||
def add_user_account(
|
||||
base_url: str, auth: str, username: str, password: str, stream: str
|
||||
) -> bool:
|
||||
"""Adds a user account via the API."""
|
||||
payload = {"username": username, "password": password, "stream": stream}
|
||||
headers = {"Authorization": f"Basic {auth}"}
|
||||
|
||||
response = requests.request("POST", url, headers=headers, data=payload)
|
||||
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']
|
||||
response = _make_api_request(
|
||||
"POST", base_url, auth, "addAccount", payload=payload
|
||||
)
|
||||
return response and response.status_code == 200
|
||||
|
||||
|
||||
def get_stream_names(base_url: str, auth: str) -> List[str]:
|
||||
"""Get a list of stream names from the API.
|
||||
|
||||
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)
|
||||
"""Retrieves a list of stream names from the API."""
|
||||
response = _make_api_request("GET", base_url, auth, "getStreamNames")
|
||||
return response.json() if response else []
|
||||
|
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
12
run.sh
Normal file
12
run.sh
Normal file
@ -0,0 +1,12 @@
|
||||
#!/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
|
@ -1,15 +1,51 @@
|
||||
self.addEventListener('install', e => {
|
||||
// console.log('[Service Worker] Installed');
|
||||
const CACHE_NAME = 'ktvmanager-cache-v1';
|
||||
const urlsToCache = [
|
||||
'/',
|
||||
'/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('activate', e => {
|
||||
// console.log('[Service Worker] Activated');
|
||||
self.addEventListener('push', function(event) {
|
||||
console.log('[Service Worker] Push Received.');
|
||||
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', e => {
|
||||
// e.respondWith(
|
||||
// caches.match(e.request).then(res => {
|
||||
// return res || fetch(e.request);
|
||||
// })
|
||||
// );
|
||||
self.addEventListener('fetch', function(event) {
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then(function(response) {
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
return fetch(event.request);
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
@ -1,28 +1,29 @@
|
||||
{
|
||||
"name": "kTvManager",
|
||||
"short_name": "kTv",
|
||||
"start_url": "/",
|
||||
"name": "kTvManager",
|
||||
"description": "KTVManager PWA",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon-96x96.png",
|
||||
"sizes": "144x144",
|
||||
"src": "/static/web-app-manifest-192x192.png",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"src": "/static/web-app-manifest-512x512.png",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"start_url": "/",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
"display": "standalone",
|
||||
"theme_color": "#ffffff",
|
||||
"share_target": {
|
||||
"action": "/share",
|
||||
"method": "GET",
|
||||
"enctype": "application/x-www-form-urlencoded",
|
||||
"params": {
|
||||
"text": "text"
|
||||
}
|
||||
}
|
||||
}
|
@ -28,3 +28,45 @@ footer {
|
||||
text-align: center;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -1,43 +1,18 @@
|
||||
<!-- templates/add_account.html -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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') }}" />
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Add Account - KTVManager{% endblock %}
|
||||
|
||||
{% block head_content %}
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/awesomplete/1.1.5/awesomplete.min.css" />
|
||||
<style>
|
||||
/* Hide the spinner by default */
|
||||
#loadingSpinner,
|
||||
#ocrLoadingSpinner {
|
||||
#loadingSpinner {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</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>
|
||||
{% endblock %}
|
||||
|
||||
{% block sub_nav %}
|
||||
<!-- Sub-navigation for Accounts -->
|
||||
<div class="bg-light py-2">
|
||||
<div class="container">
|
||||
@ -51,9 +26,9 @@
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container mt-5">
|
||||
{% block content %}
|
||||
<h1>Add Account</h1>
|
||||
<div>
|
||||
<form action="/accounts/add" method="POST" onsubmit="showLoading()">
|
||||
@ -67,32 +42,24 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="stream">Stream Name</label>
|
||||
<div class="awesomplete">
|
||||
<input type="text" class="form-control" id="stream" name="stream" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" id="submitButton">
|
||||
<span class="spinner-border spinner-border-sm" id="loadingSpinner" role="status" aria-hidden="true"></span>
|
||||
<span id="buttonText">Add Account</span>
|
||||
</button>
|
||||
</form>
|
||||
{% if text_input_enabled %}
|
||||
<hr>
|
||||
<h4>Load Details Via OCR</h2>
|
||||
<form action="/OCRupload" method="POST" enctype="multipart/form-data" onsubmit="showLoadingOCR()">
|
||||
<h2>Load Details Via Text</h2>
|
||||
<div class="form-group">
|
||||
<label for="image">Select Image</label>
|
||||
<input type="file" class="form-control-file" id="image" name="image" accept="image/*" required>
|
||||
<label for="accountDetails">Paste Account Details</label>
|
||||
<textarea class="form-control" id="accountDetails" rows="4" data-shared-text="{{ shared_text }}"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success" id="ocrButton">
|
||||
<span class="spinner-border spinner-border-sm" id="ocrLoadingSpinner" role="status" aria-hidden="true"></span>
|
||||
<span id="ocrButtonText">Load Details</span>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
</main>
|
||||
<footer class="bg-dark text-white text-center py-3 mt-5">
|
||||
<p></p>
|
||||
</footer>
|
||||
{% block scripts %}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/awesomplete/1.1.5/awesomplete.min.js"></script>
|
||||
<script>
|
||||
function showLoading() {
|
||||
@ -100,22 +67,60 @@
|
||||
document.getElementById("loadingSpinner").style.display = "inline-block";
|
||||
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() {
|
||||
var streamInput = document.getElementById("stream");
|
||||
var awesomplete;
|
||||
fetch('/get_stream_names')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
new Awesomplete(streamInput, {
|
||||
list: data.map(item => item.streamName)
|
||||
awesomplete = new Awesomplete(streamInput, {
|
||||
list: data
|
||||
});
|
||||
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
{% endblock %}
|
||||
|
192
templates/base.html
Normal file
192
templates/base.html
Normal file
@ -0,0 +1,192 @@
|
||||
<!-- 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>
|
301
templates/config_dashboard.html
Normal file
301
templates/config_dashboard.html
Normal file
@ -0,0 +1,301 @@
|
||||
{% 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 %}
|
@ -1,39 +1,10 @@
|
||||
<!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>
|
||||
{% extends "base.html" %}
|
||||
|
||||
<!-- 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 title %}KTVManager{% endblock %}
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container mt-5">
|
||||
{% block content %}
|
||||
<h1>Welcome {{ username }}!</h1>
|
||||
<br>
|
||||
<button id="enable-notifications-btn" class="btn btn-primary my-3">Enable Notifications</button>
|
||||
<h2>You have {{ accounts }} active accounts</h2>
|
||||
<br>
|
||||
|
||||
@ -52,7 +23,7 @@
|
||||
<tr>
|
||||
<td>{{ account.stream }}</td>
|
||||
<td>{{ account.username }}</td>
|
||||
<td>{{ account.expiaryDate_rendered }}</td>
|
||||
<td>{{ account.expiaryDate_rendered }} {% if account.days_to_expiry is defined %}<span style="color: red;">({{ account.days_to_expiry }} days)</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@ -79,15 +50,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
<!-- Footer -->
|
||||
<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>
|
||||
{% block scripts %}
|
||||
{% endblock %}
|
||||
|
@ -1,44 +1,8 @@
|
||||
<!-- 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>
|
||||
{% extends "base.html" %}
|
||||
|
||||
<!-- 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 title %}KTVManager{% endblock %}
|
||||
|
||||
<!-- Main Content -->
|
||||
<main div class="container mt-5">
|
||||
{% block content %}
|
||||
<h1>Welcome to KTV Manager</h1>
|
||||
|
||||
<!-- Login Form -->
|
||||
@ -56,16 +20,9 @@
|
||||
<div class="alert alert-danger mt-3">{{ error }}</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
<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>
|
||||
{% block scripts %}
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('{{ url_for("static", filename="service-worker.js") }}')
|
||||
@ -76,5 +33,4 @@
|
||||
// });
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{% endblock %}
|
||||
|
@ -1,39 +1,8 @@
|
||||
<!-- 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>
|
||||
{% extends "base.html" %}
|
||||
|
||||
<!-- 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 title %}URLs - KTVManager{% endblock %}
|
||||
|
||||
|
||||
<!-- Main Content -->
|
||||
<main div class="container mt-5">
|
||||
{% block content %}
|
||||
<h2>URLs</h2>
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
@ -51,15 +20,4 @@
|
||||
{% endfor %}
|
||||
</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>
|
||||
{% endblock %}
|
||||
|
@ -1,30 +1,13 @@
|
||||
<!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">
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Accounts - KTVManager{% endblock %}
|
||||
|
||||
{% block head_content %}
|
||||
<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="{{ 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>
|
||||
{% endblock %}
|
||||
|
||||
{% block sub_nav %}
|
||||
<!-- Sub-navigation for Accounts -->
|
||||
<div class="bg-light py-2">
|
||||
<div class="container">
|
||||
@ -38,12 +21,12 @@
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
<!-- Main Content -->
|
||||
<main div class="container mt-5">
|
||||
{% block content %}
|
||||
<h2>{{ username }}'s Accounts</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped" id="accountsTable">
|
||||
<div>
|
||||
<table class="table table-striped dt-responsive nowrap" id="accountsTable" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<!-- <th>#</th> -->
|
||||
@ -61,10 +44,14 @@
|
||||
<!-- <td>{{ loop.index }}</td> -->
|
||||
<td>{{ account.username }}</td>
|
||||
<td>{{ account.stream }}</td>
|
||||
<td><a href="{{ account.streamURL }}" target="_blank">{{ account.streamURL }}</a></td>
|
||||
<td><a href="{{ account.streamURL }}" target="_blank">{{ account.streamURL }}</a> ({{ account.maxConnections }})</td>
|
||||
<td>{{ account.expiaryDate_rendered }}</td>
|
||||
<td>{{ account.password }}</td>
|
||||
<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;">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span>
|
||||
<span class="button-text">Validate</span>
|
||||
</button>
|
||||
<form action="/accounts/delete" method="POST" style="display:inline;">
|
||||
<input type="hidden" name="stream" value="{{ account.stream }}">
|
||||
<input type="hidden" name="username" value="{{ account.username }}">
|
||||
@ -78,16 +65,9 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
<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>
|
||||
{% block scripts %}
|
||||
<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>
|
||||
@ -113,8 +93,67 @@
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
$('#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>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{% endblock %}
|
||||
|
164
test_npm_update.py
Normal file
164
test_npm_update.py
Normal file
@ -0,0 +1,164 @@
|
||||
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()
|
Loading…
x
Reference in New Issue
Block a user