From a8314eefa40d29d4f8bc0e6be632d6f8428755f8 Mon Sep 17 00:00:00 2001
From: ReturnFI <151555003+ReturnFI@users.noreply.github.com>
Date: Wed, 17 Sep 2025 11:14:37 +0000
Subject: [PATCH] Feat: Refactor subscription page templates
---
core/scripts/normalsub/normalsub.py | 35 +-
core/scripts/normalsub/template.html | 519 ---------------------
core/scripts/normalsub/template/index.html | 171 +++++++
core/scripts/normalsub/template/script.js | 119 +++++
core/scripts/normalsub/template/style.css | 118 +++++
5 files changed, 438 insertions(+), 524 deletions(-)
delete mode 100644 core/scripts/normalsub/template.html
create mode 100644 core/scripts/normalsub/template/index.html
create mode 100644 core/scripts/normalsub/template/script.js
create mode 100644 core/scripts/normalsub/template/style.css
diff --git a/core/scripts/normalsub/normalsub.py b/core/scripts/normalsub/normalsub.py
index 425bb87..3284951 100644
--- a/core/scripts/normalsub/normalsub.py
+++ b/core/scripts/normalsub/normalsub.py
@@ -12,7 +12,7 @@ from io import BytesIO
from aiohttp import web
from aiohttp.web_middlewares import middleware
-from urllib.parse import unquote, parse_qs, urlparse, urljoin
+from urllib.parse import unquote, parse_qs, urlparse, urljoin, quote
from dotenv import load_dotenv
import qrcode
from jinja2 import Environment, FileSystemLoader
@@ -127,9 +127,14 @@ class TemplateContext:
expiration_date: str
sublink_qrcode: str
sub_link: str
+ sub_link_encoded: str
blocked: bool = False
local_uris: List[NodeURI] = field(default_factory=list)
node_uris: List[NodeURI] = field(default_factory=list)
+ singbox_qrcode: Optional[str] = None
+ hiddify_qrcode: Optional[str] = None
+ streisand_qrcode: Optional[str] = None
+ nekobox_qrcode: Optional[str] = None
class Utils:
@@ -398,14 +403,14 @@ class SubscriptionManager:
f"total={user_info.max_download_bytes}; "
f"expire={user_info.expiration_timestamp}\n"
)
- profile_lines = f"//profile-title: {username}-Hysteria2 🚀\n//profile-update-interval: 1\n"
+ profile_lines = f"//profile-title: {username}-Blitz ⚡\n//profile-update-interval: 1\n"
return profile_lines + subscription_info + "\n".join(all_processed_uris)
class TemplateRenderer:
def __init__(self, template_dir: str, config: AppConfig):
self.env = Environment(loader=FileSystemLoader(template_dir), autoescape=True)
- self.html_template = self.env.get_template('template.html')
+ self.html_template = self.env.get_template('index.html')
self.config = config
def render(self, context: TemplateContext) -> str:
@@ -430,6 +435,8 @@ class HysteriaServer:
safe_subpath = self.validate_and_escape_subpath(self.config.subpath)
base_path = f'/{safe_subpath}'
+ self.app.router.add_get(f'{base_path}/sub/normal/style.css', self.handle_style)
+ self.app.router.add_get(f'{base_path}/sub/normal/script.js', self.handle_script)
self.app.router.add_get(f'{base_path}/sub/normal/{{password_token}}', self.handle)
self.app.router.add_get(f'{base_path}/robots.txt', self.robots_handler)
self.app.router.add_route('*', f'{base_path}/{{tail:.*}}', self.handle_404_subpath)
@@ -452,7 +459,7 @@ class HysteriaServer:
extra_config_path = '/etc/hysteria/extra.json'
rate_limit = 100
rate_limit_window = 60
- template_dir = os.path.dirname(__file__)
+ template_dir = os.path.join(os.path.dirname(__file__), 'template')
sni = self._load_sni_from_env(sni_file)
return AppConfig(domain=domain, external_port=external_port,
@@ -565,6 +572,7 @@ class HysteriaServer:
expiration_date=user_info.expiration_date,
sublink_qrcode="",
sub_link="#blocked",
+ sub_link_encoded="",
blocked=True,
local_uris=[
NodeURI(
@@ -603,8 +611,14 @@ class HysteriaServer:
print(f"Warning: Constructed base URL '{base_url}' might be invalid. Check domain and port config.")
sub_link = f"{base_url}/{self.config.subpath}/sub/normal/{user_info.password}"
+ sub_link_encoded = quote(sub_link, safe='')
sublink_qrcode = Utils.generate_qrcode_base64(sub_link)
+ singbox_qrcode = Utils.generate_qrcode_base64(f"sing-box://import-remote-profile?url={sub_link_encoded}")
+ hiddify_qrcode = Utils.generate_qrcode_base64(f"hiddify://import/{sub_link_encoded}")
+ streisand_qrcode = Utils.generate_qrcode_base64(f"streisand://import/sub?url={sub_link_encoded}")
+ nekobox_qrcode = Utils.generate_qrcode_base64(f"nekobox://import?url={sub_link_encoded}")
+
local_uris = []
node_uris = []
@@ -626,9 +640,14 @@ class HysteriaServer:
expiration_date=user_info.expiration_date,
sublink_qrcode=sublink_qrcode,
sub_link=sub_link,
+ sub_link_encoded=sub_link_encoded,
blocked=user_info.blocked,
local_uris=local_uris,
- node_uris=node_uris
+ node_uris=node_uris,
+ singbox_qrcode=singbox_qrcode,
+ hiddify_qrcode=hiddify_qrcode,
+ streisand_qrcode=streisand_qrcode,
+ nekobox_qrcode=nekobox_qrcode
)
async def robots_handler(self, request: web.Request) -> web.Response:
@@ -637,6 +656,12 @@ class HysteriaServer:
async def handle_404_subpath(self, request: web.Request) -> web.Response:
print(f"404 Not Found (within subpath, unhandled by specific routes): {request.path}")
return web.Response(status=404, text="Not Found within Subpath")
+
+ async def handle_style(self, request: web.Request) -> web.Response:
+ return web.FileResponse(os.path.join(self.config.template_dir, 'style.css'))
+
+ async def handle_script(self, request: web.Request) -> web.Response:
+ return web.FileResponse(os.path.join(self.config.template_dir, 'script.js'))
def run(self):
print(f"Starting Hysteria Normalsub server on {self.config.aiohttp_listen_address}:{self.config.aiohttp_listen_port}")
diff --git a/core/scripts/normalsub/template.html b/core/scripts/normalsub/template.html
deleted file mode 100644
index 0912c67..0000000
--- a/core/scripts/normalsub/template.html
+++ /dev/null
@@ -1,519 +0,0 @@
-
-
-
-
-
-
-
- Hysteria2 Subscription
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ expiration_date }}
-
-
-
-
-
-
-
-
- {% if blocked %}
-
-
Account Suspended
-
Your subscription link is disabled. Please contact support.
-
- {% else %}
-
-
Universal Subscription Link
-

-
-
-
-
- {% endif %}
-
-
-
-
-
-
-
-
- {% if blocked %}
- {% for item in local_uris %}
-
-
{{ item.label }}
-
{{ usage_raw }}
-
- {% endfor %}
- {% else %}
- {% for item in local_uris %}
-
-
{{ item.label }} URI
- {% if item.qrcode %}
-

-
-
-
- {% else %}
-
{{ item.label }} URI not available
- {% endif %}
-
- {% endfor %}
- {% endif %}
-
-
-
-
- {% if node_uris and not blocked %}
-
-
-
-
- {% for item in node_uris %}
-
-
{{ item.label }}
- {% if item.qrcode %}
-

-
-
-
- {% else %}
-
{{ item.label }} URI not available
- {% endif %}
-
- {% endfor %}
-
-
-
- {% endif %}
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/core/scripts/normalsub/template/index.html b/core/scripts/normalsub/template/index.html
new file mode 100644
index 0000000..ab171fe
--- /dev/null
+++ b/core/scripts/normalsub/template/index.html
@@ -0,0 +1,171 @@
+
+
+
+
+
+
+ {{ username }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Username
+
{{ username }}
+
+
+
+
+
+
+
Data Usage
+
{{ usage }}
+
+
+
+
+
+
+
Expires On
+
{{ expiration_date }}
+
+
+
+
+
+
+
+ {% if blocked %}
+
+
+
Account Suspended
+
Your subscription is currently disabled.
+
+ {% else %}
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+
+
+
+
+ {% endif %}
+
+
+
+
+
+
+ {% if blocked %}
+
+
+
Connections Disabled
+
{{ usage_raw }}
+
+ {% else %}
+
+ {% for item in local_uris %}
+
+
{{ item.label }}
+ {% if item.qrcode %}
+

+
+ {% else %}
+
URI not available
+ {% endif %}
+
+ {% endfor %}
+
+ {% endif %}
+
+
+
+ {% if node_uris and not blocked %}
+
+
+
+
+ {% for item in node_uris %}
+
+
{{ item.label }}
+ {% if item.qrcode %}
+

+
+ {% else %}
+
URI not available
+ {% endif %}
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+
+
+
+
\ No newline at end of file
diff --git a/core/scripts/normalsub/template/script.js b/core/scripts/normalsub/template/script.js
new file mode 100644
index 0000000..b3c5767
--- /dev/null
+++ b/core/scripts/normalsub/template/script.js
@@ -0,0 +1,119 @@
+document.addEventListener('DOMContentLoaded', () => {
+ const themeToggle = document.getElementById('dark-mode-toggle');
+ const themeIcon = document.getElementById('theme-icon');
+ let isDarkMode = localStorage.getItem('darkMode') === 'enabled';
+
+ const enableDarkMode = () => {
+ document.body.classList.add('dark-mode');
+ themeIcon.classList.replace('fa-sun', 'fa-moon');
+ localStorage.setItem('darkMode', 'enabled');
+ isDarkMode = true;
+ };
+
+ const disableDarkMode = () => {
+ document.body.classList.remove('dark-mode');
+ themeIcon.classList.replace('fa-moon', 'fa-sun');
+ localStorage.setItem('darkMode', 'disabled');
+ isDarkMode = false;
+ };
+
+ if (isDarkMode) {
+ enableDarkMode();
+ } else {
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
+ if (prefersDark && localStorage.getItem('darkMode') === null) {
+ enableDarkMode();
+ }
+ }
+
+ themeToggle.addEventListener('click', () => {
+ isDarkMode ? disableDarkMode() : enableDarkMode();
+ });
+
+ // --- Loading Indicator ---
+ const loadingIndicator = document.getElementById('loading-indicator');
+ window.addEventListener('load', () => {
+ loadingIndicator.style.opacity = '0';
+ setTimeout(() => {
+ loadingIndicator.style.display = 'none';
+ }, 300);
+ });
+
+ // --- App Import Tabs ---
+ const tabsContainer = document.querySelector('.app-tabs');
+ if (tabsContainer) {
+ const tabButtons = tabsContainer.querySelectorAll('.app-tab-btn');
+ const tabPanes = tabsContainer.querySelectorAll('.app-tab-pane');
+
+ tabButtons.forEach(button => {
+ button.addEventListener('click', () => {
+ const targetId = button.getAttribute('data-target');
+
+ tabButtons.forEach(btn => btn.classList.remove('active'));
+ button.classList.add('active');
+
+ tabPanes.forEach(pane => {
+ if ('#' + pane.id === targetId) {
+ pane.classList.add('active');
+ } else {
+ pane.classList.remove('active');
+ }
+ });
+ });
+ });
+ }
+});
+
+function copyToClipboard(text) {
+ if (!navigator.clipboard) {
+ alert('Clipboard API not available.');
+ return;
+ }
+ navigator.clipboard.writeText(text).then(() => {
+ showToast('Copied to clipboard!');
+ }).catch(err => {
+ console.error('Failed to copy text: ', err);
+ alert('Failed to copy.');
+ });
+}
+
+function showToast(message) {
+ let toast = document.querySelector('.toast');
+ if (toast) {
+ toast.remove();
+ }
+
+ toast = document.createElement('div');
+ toast.className = 'toast';
+ toast.textContent = message;
+
+ document.body.appendChild(toast);
+
+ const toastStyles = `
+ position: fixed;
+ bottom: 2rem;
+ left: 50%;
+ transform: translateX(-50%) translateY(20px);
+ background-color: var(--text-light-primary);
+ color: var(--bg-light);
+ padding: 0.75rem 1.5rem;
+ border-radius: 0.75rem;
+ font-weight: 500;
+ z-index: 1001;
+ opacity: 0;
+ transition: opacity 0.3s ease, transform 0.3s ease;
+ box-shadow: var(--shadow-lg);
+ `;
+ toast.style.cssText = toastStyles;
+
+ setTimeout(() => {
+ toast.style.opacity = '1';
+ toast.style.transform = 'translateX(-50%) translateY(0)';
+ }, 10);
+
+ setTimeout(() => {
+ toast.style.opacity = '0';
+ toast.style.transform = 'translateX(-50%) translateY(20px)';
+ toast.addEventListener('transitionend', () => toast.remove());
+ }, 2000);
+}
\ No newline at end of file
diff --git a/core/scripts/normalsub/template/style.css b/core/scripts/normalsub/template/style.css
new file mode 100644
index 0000000..0fd5b08
--- /dev/null
+++ b/core/scripts/normalsub/template/style.css
@@ -0,0 +1,118 @@
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
+
+:root {
+ /* Color Palette */
+ --bg-light: #f9fafb;
+ --bg-dark: #111827;
+ --card-bg-light: #ffffff;
+ --card-bg-dark: #1f2937;
+ --text-light-primary: #1f2937;
+ --text-light-secondary: #6b7280;
+ --text-dark-primary: #f9fafb;
+ --text-dark-secondary: #9ca3af;
+ --accent-primary: #4f46e5;
+ --accent-secondary: #3b82f6;
+ --accent-danger: #e11d48;
+ --border-light: #e5e7eb;
+ --border-dark: #374151;
+ --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
+ --gradient: linear-gradient(135deg, var(--accent-secondary) 0%, var(--accent-primary) 100%);
+
+ /* Layout & Design */
+ --radius: 0.75rem;
+ --transition-speed: 0.3s;
+}
+
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+body {
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ background-color: var(--bg-light);
+ color: var(--text-light-primary);
+ transition: background-color var(--transition-speed) ease, color var(--transition-speed) ease;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+body.dark-mode {
+ --bg-light: var(--bg-dark);
+ --card-bg-light: var(--card-bg-dark);
+ --text-light-primary: var(--text-dark-primary);
+ --text-light-secondary: var(--text-dark-secondary);
+ --border-light: var(--border-dark);
+ --shadow-sm: none; --shadow-md: none; --shadow-lg: none;
+}
+
+.background-animation { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: -1; overflow: hidden; background: var(--bg-light); transition: background var(--transition-speed) ease; }
+.background-animation::before, .background-animation::after { content: ''; position: absolute; width: 40vw; height: 40vw; max-width: 500px; max-height: 500px; border-radius: 50%; background: var(--gradient); opacity: 0.1; filter: blur(120px); animation: float 30s infinite ease-in-out; }
+.background-animation::before { top: 5%; left: 5%; }
+.background-animation::after { bottom: 5%; right: 5%; animation-delay: -15s; }
+@keyframes float { 50% { transform: translate(20%, -20%) scale(1.1); } }
+
+.container { max-width: 900px; margin: 0 auto; padding: 2rem; }
+.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 3.5rem; }
+.header-title { font-size: 1.75rem; font-weight: 600; }
+.header-title strong { font-weight: 700; background: var(--gradient); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
+.theme-toggle { background-color: transparent; border: 1px solid var(--border-light); color: var(--text-light-secondary); width: 40px; height: 40px; border-radius: 50%; cursor: pointer; display: grid; place-items: center; font-size: 1.125rem; transition: all var(--transition-speed) ease; }
+.theme-toggle:hover { color: var(--accent-primary); border-color: var(--accent-primary); transform: rotate(15deg); }
+
+.card { background: var(--card-bg-light); border: 1px solid var(--border-light); border-radius: var(--radius); margin-bottom: 1rem; margin-top: 1rem; box-shadow: var(--shadow-md); transition: transform var(--transition-speed) ease, box-shadow var(--transition-speed) ease; }
+.dark-mode .card { background: var(--card-bg-dark); }
+.card:hover { transform: translateY(-5px); box-shadow: var(--shadow-lg); }
+.card:last-child { margin-bottom: 0; }
+.card-header { display: flex; align-items: center; gap: 0.75rem; padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-light); font-weight: 600; font-size: 1.125rem; }
+.card-header i { color: var(--accent-primary); }
+.card-body { padding: 1.5rem; }
+
+.info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1.5rem; }
+.info-card { display: flex; align-items: center; gap: 1rem; padding: 1.5rem; margin-bottom: 0; }
+.card-icon { background: var(--gradient); color: white; width: 48px; height: 48px; border-radius: 50%; display: grid; place-items: center; font-size: 1.25rem; flex-shrink: 0; }
+.card-title { font-size: 0.875rem; font-weight: 500; color: var(--text-light-secondary); margin-bottom: 0.25rem; }
+.card-value { font-size: 1.25rem; font-weight: 600; }
+
+[data-tooltip] { position: relative; cursor: help; }
+[data-tooltip]::after { content: attr(data-tooltip); position: absolute; bottom: 110%; left: 50%; transform: translateX(-50%); background: var(--text-light-primary); color: var(--bg-light); padding: 0.5rem 0.75rem; border-radius: 0.5rem; font-size: 0.875rem; font-weight: 500; white-space: nowrap; opacity: 0; visibility: hidden; transition: opacity var(--transition-speed) ease; box-shadow: var(--shadow-lg); }
+[data-tooltip]:hover::after { opacity: 1; visibility: visible; }
+
+.app-import-section.blocked, .uri-section.blocked { background-color: var(--accent-danger); color: white; }
+.app-import-section.blocked .card-header, .uri-section.blocked .card-header { border-color: transparent; color: white; }
+.app-import-section.blocked .card-header i, .uri-section.blocked .card-header i { color: white; }
+.blocked-message { text-align: center; }
+.blocked-message i { font-size: 2.5rem; margin-bottom: 1rem; }
+.blocked-message h3 { font-size: 1.25rem; }
+.blocked-message p { opacity: 0.9; }
+
+.app-tabs-nav { display: flex; overflow-x: auto; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border-light); gap: 0.5rem; }
+.app-tab-btn { font-family: inherit; font-size: 0.9rem; font-weight: 600; color: var(--text-light-secondary); background: transparent; border: none; padding: 0.75rem 1rem; cursor: pointer; transition: all var(--transition-speed) ease; border-bottom: 2px solid transparent; }
+.app-tab-btn:hover { color: var(--text-light-primary); }
+.app-tab-btn.active { color: var(--accent-primary); border-bottom-color: var(--accent-primary); }
+.app-tabs-content { padding-top: 1rem; }
+.app-tab-pane { display: none; flex-direction: column; align-items: center; gap: 1.25rem; }
+.app-tab-pane.active { display: flex; }
+
+.qr-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 2.5rem; place-items: center; }
+.qr-item { display: flex; flex-direction: column; align-items: center; gap: 1.25rem; text-align: center; }
+.qr-title { font-weight: 600; font-size: 1rem; }
+.qrcode { max-width: 200px; width: 100%; padding: 0.75rem; background: white; border-radius: 0.75rem; box-shadow: var(--shadow-md); }
+
+.btn { font-family: inherit; text-decoration: none; font-size: 1rem; font-weight: 600; padding: 0.75rem 1.5rem; border: none; border-radius: 0.5rem; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; transition: all var(--transition-speed) ease; }
+.btn-primary { background: var(--gradient); color: white; box-shadow: var(--shadow-md); }
+.btn-primary:hover { transform: translateY(-3px); box-shadow: var(--shadow-lg); }
+.btn-secondary { background: var(--card-bg-light); color: var(--accent-primary); border: 1px solid var(--border-light); }
+.btn-secondary:hover { background-color: var(--accent-primary); color: white; border-color: var(--accent-primary); }
+
+.uri-unavailable { color: var(--text-light-secondary); }
+
+.loading-indicator { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: var(--bg-light); display: grid; place-items: center; z-index: 1000; transition: opacity var(--transition-speed) ease; }
+.spinner { width: 3rem; height: 3rem; border: 4px solid var(--accent-primary); border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; }
+@keyframes spin { to { transform: rotate(360deg); } }
+
+@media (max-width: 768px) {
+ .container { padding: 1rem; }
+ .header { flex-direction: column; gap: 1.5rem; align-items: flex-start; margin-bottom: 2.5rem; }
+ .header-title { font-size: 1.5rem; }
+ .card-body { padding: 1rem; }
+ .info-grid { grid-template-columns: 1fr; }
+}
\ No newline at end of file