feat(normalsub): Add external node URIs to subscriptions
This commit is contained in:
@ -6,7 +6,7 @@ import time
|
|||||||
import shlex
|
import shlex
|
||||||
import base64
|
import base64
|
||||||
from typing import Dict, List, Optional, Tuple, Any, Union
|
from typing import Dict, List, Optional, Tuple, Any, Union
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
@ -29,6 +29,7 @@ class AppConfig:
|
|||||||
singbox_template_path: str
|
singbox_template_path: str
|
||||||
hysteria_cli_path: str
|
hysteria_cli_path: str
|
||||||
users_json_path: str
|
users_json_path: str
|
||||||
|
nodes_json_path: str
|
||||||
rate_limit: int
|
rate_limit: int
|
||||||
rate_limit_window: int
|
rate_limit_window: int
|
||||||
sni: str
|
sni: str
|
||||||
@ -106,6 +107,13 @@ class UserInfo:
|
|||||||
return f"Upload: {upload}, Download: {download}, Total: {total}"
|
return f"Upload: {upload}, Download: {download}, Total: {total}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NodeURI:
|
||||||
|
label: str
|
||||||
|
uri: str
|
||||||
|
qrcode: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TemplateContext:
|
class TemplateContext:
|
||||||
username: str
|
username: str
|
||||||
@ -113,11 +121,9 @@ class TemplateContext:
|
|||||||
usage_raw: str
|
usage_raw: str
|
||||||
expiration_date: str
|
expiration_date: str
|
||||||
sublink_qrcode: str
|
sublink_qrcode: str
|
||||||
ipv4_qrcode: Optional[str]
|
|
||||||
ipv6_qrcode: Optional[str]
|
|
||||||
sub_link: str
|
sub_link: str
|
||||||
ipv4_uri: Optional[str]
|
local_uris: List[NodeURI] = field(default_factory=list)
|
||||||
ipv6_uri: Optional[str]
|
node_uris: List[NodeURI] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class Utils:
|
class Utils:
|
||||||
@ -244,17 +250,23 @@ class HysteriaCLI:
|
|||||||
print(f"JSONDecodeError: {e}, Raw output: {raw_info_str}")
|
print(f"JSONDecodeError: {e}, Raw output: {raw_info_str}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_user_uri(self, username: str, ip_version: Optional[str] = None) -> str:
|
def get_all_uris(self, username: str) -> List[str]:
|
||||||
if ip_version:
|
"""Fetches all available URIs (local and nodes) for a user."""
|
||||||
return self._run_command(['show-user-uri', '-u', username, '-ip', ip_version])
|
|
||||||
else:
|
|
||||||
return self._run_command(['show-user-uri', '-u', username, '-a'])
|
|
||||||
|
|
||||||
def get_uris(self, username: str) -> Tuple[Optional[str], Optional[str]]:
|
|
||||||
output = self._run_command(['show-user-uri', '-u', username, '-a'])
|
output = self._run_command(['show-user-uri', '-u', username, '-a'])
|
||||||
ipv4_uri = re.search(r'IPv4:\s*(.*)', output)
|
if not output:
|
||||||
ipv6_uri = re.search(r'IPv6:\s*(.*)', output)
|
return []
|
||||||
return (ipv4_uri.group(1).strip() if ipv4_uri else None, ipv6_uri.group(1).strip() if ipv6_uri else None)
|
# Find all hy2:// links in the output
|
||||||
|
return re.findall(r'(hy2://[^\s]+)', output)
|
||||||
|
|
||||||
|
def get_all_labeled_uris(self, username: str) -> List[Dict[str, str]]:
|
||||||
|
"""Fetches all URIs and their labels."""
|
||||||
|
output = self._run_command(['show-user-uri', '-u', username, '-a'])
|
||||||
|
if not output:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# This regex captures the label (e.g., "IPv4", "Node: DE (IPv6)") and the URI
|
||||||
|
matches = re.findall(r"^(.*?):\s*(hy2://[^\s]+)", output, re.MULTILINE)
|
||||||
|
return [{'label': label.strip(), 'uri': uri} for label, uri in matches]
|
||||||
|
|
||||||
|
|
||||||
class UriParser:
|
class UriParser:
|
||||||
@ -303,74 +315,67 @@ class SingboxConfigGenerator:
|
|||||||
raise RuntimeError(f"Error loading Singbox template: {e}") from e
|
raise RuntimeError(f"Error loading Singbox template: {e}") from e
|
||||||
return self._template_cache.copy()
|
return self._template_cache.copy()
|
||||||
|
|
||||||
def generate_config(self, username: str, ip_version: str, fragment: str) -> Optional[Dict[str, Any]]:
|
def generate_config_from_uri(self, uri: str, username: str, fragment: str) -> Optional[Dict[str, Any]]:
|
||||||
try:
|
"""Generates a Singbox outbound config from a single Hysteria URI."""
|
||||||
uri = self.hysteria_cli.get_user_uri(username, ip_version)
|
|
||||||
except Exception:
|
|
||||||
print(f"Failed to get URI for {username} with IP version {ip_version}. Skipping.")
|
|
||||||
return None
|
|
||||||
if not uri:
|
if not uri:
|
||||||
print(f"No URI found for {username} with IP version {ip_version}. Skipping.")
|
|
||||||
return None
|
return None
|
||||||
components = UriParser.extract_uri_components(uri, f'IPv{ip_version}:')
|
|
||||||
if components is None or components.port is None:
|
# A simplified parser since we already have the full URI
|
||||||
print(f"Invalid URI components for {username} with IP version {ip_version}. Skipping.")
|
try:
|
||||||
|
parsed_url = urlparse(uri)
|
||||||
|
server = parsed_url.hostname
|
||||||
|
server_port = parsed_url.port
|
||||||
|
password = parsed_url.password
|
||||||
|
full_user = unquote(parsed_url.username)
|
||||||
|
obfs_password = parse_qs(parsed_url.query).get('obfs-password', [''])[0]
|
||||||
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"outbounds": [{
|
"type": "hysteria2",
|
||||||
"type": "hysteria2",
|
"tag": unquote(parsed_url.fragment),
|
||||||
"tag": f"{username}-Hysteria2",
|
"server": server,
|
||||||
"server": components.ip,
|
"server_port": server_port,
|
||||||
"server_port": components.port,
|
"obfs": {
|
||||||
"obfs": {
|
"type": "salamander",
|
||||||
"type": "salamander",
|
"password": obfs_password
|
||||||
"password": components.obfs_password
|
},
|
||||||
},
|
"password": f"{full_user}:{password}",
|
||||||
"password": f"{username}:{components.password}",
|
"tls": {
|
||||||
"tls": {
|
"enabled": True,
|
||||||
"enabled": True,
|
"server_name": fragment if fragment else self.default_sni,
|
||||||
"server_name": fragment if fragment else self.default_sni,
|
"insecure": True
|
||||||
"insecure": True
|
}
|
||||||
}
|
|
||||||
}]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def combine_configs(self, username: str, config_v4: Optional[Dict[str, Any]], config_v6: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
def combine_configs(self, all_uris: List[str], username: str, fragment: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Generates a combined Singbox config from a list of URIs."""
|
||||||
|
if not all_uris:
|
||||||
|
return None
|
||||||
|
|
||||||
combined_config = self.get_template()
|
combined_config = self.get_template()
|
||||||
combined_config['outbounds'] = [outbound for outbound in combined_config['outbounds']
|
# Clear any placeholder hysteria2 outbounds
|
||||||
if outbound.get('type') != 'hysteria2']
|
combined_config['outbounds'] = [out for out in combined_config['outbounds'] if out.get('type') != 'hysteria2']
|
||||||
|
|
||||||
modified_v4_outbounds = []
|
hysteria_outbounds = []
|
||||||
if config_v4:
|
for uri in all_uris:
|
||||||
v4_outbound = config_v4['outbounds'][0]
|
outbound = self.generate_config_from_uri(uri, username, fragment)
|
||||||
v4_outbound['tag'] = f"{username}-IPv4"
|
if outbound:
|
||||||
modified_v4_outbounds.append(v4_outbound)
|
hysteria_outbounds.append(outbound)
|
||||||
|
|
||||||
modified_v6_outbounds = []
|
if not hysteria_outbounds:
|
||||||
if config_v6:
|
return None
|
||||||
v6_outbound = config_v6['outbounds'][0]
|
|
||||||
v6_outbound['tag'] = f"{username}-IPv6"
|
|
||||||
modified_v6_outbounds.append(v6_outbound)
|
|
||||||
|
|
||||||
select_outbounds = ["auto"]
|
all_tags = [out['tag'] for out in hysteria_outbounds]
|
||||||
if config_v4:
|
|
||||||
select_outbounds.append(f"{username}-IPv4")
|
|
||||||
if config_v6:
|
|
||||||
select_outbounds.append(f"{username}-IPv6")
|
|
||||||
|
|
||||||
auto_outbounds = []
|
|
||||||
if config_v4:
|
|
||||||
auto_outbounds.append(f"{username}-IPv4")
|
|
||||||
if config_v6:
|
|
||||||
auto_outbounds.append(f"{username}-IPv6")
|
|
||||||
|
|
||||||
|
# Update 'select' and 'auto' groups
|
||||||
for outbound in combined_config['outbounds']:
|
for outbound in combined_config['outbounds']:
|
||||||
if outbound.get('tag') == 'select':
|
if outbound.get('tag') == 'select':
|
||||||
outbound['outbounds'] = select_outbounds
|
outbound['outbounds'] = ["auto"] + all_tags
|
||||||
elif outbound.get('tag') == 'auto':
|
elif outbound.get('tag') == 'auto':
|
||||||
outbound['outbounds'] = auto_outbounds
|
outbound['outbounds'] = all_tags
|
||||||
combined_config['outbounds'].extend(modified_v4_outbounds + modified_v6_outbounds)
|
|
||||||
|
combined_config['outbounds'].extend(hysteria_outbounds)
|
||||||
return combined_config
|
return combined_config
|
||||||
|
|
||||||
|
|
||||||
@ -383,13 +388,13 @@ class SubscriptionManager:
|
|||||||
user_info = self.hysteria_cli.get_user_info(username)
|
user_info = self.hysteria_cli.get_user_info(username)
|
||||||
if user_info is None:
|
if user_info is None:
|
||||||
return "User not found"
|
return "User not found"
|
||||||
ipv4_uri, ipv6_uri = self.hysteria_cli.get_uris(username)
|
|
||||||
output_lines = [uri for uri in [ipv4_uri, ipv6_uri] if uri]
|
all_uris = self.hysteria_cli.get_all_uris(username)
|
||||||
if not output_lines:
|
if not all_uris:
|
||||||
return "No URI available"
|
return "No URI available"
|
||||||
|
|
||||||
processed_uris = []
|
processed_uris = []
|
||||||
for uri in output_lines:
|
for uri in all_uris:
|
||||||
if "v2ray" in user_agent and "ng" in user_agent:
|
if "v2ray" in user_agent and "ng" in user_agent:
|
||||||
match = re.search(r'pinSHA256=sha256/([^&]+)', uri)
|
match = re.search(r'pinSHA256=sha256/([^&]+)', uri)
|
||||||
if match:
|
if match:
|
||||||
@ -455,6 +460,7 @@ class HysteriaServer:
|
|||||||
singbox_template_path = '/etc/hysteria/core/scripts/normalsub/singbox.json'
|
singbox_template_path = '/etc/hysteria/core/scripts/normalsub/singbox.json'
|
||||||
hysteria_cli_path = '/etc/hysteria/core/cli.py'
|
hysteria_cli_path = '/etc/hysteria/core/cli.py'
|
||||||
users_json_path = os.getenv('HYSTERIA_USERS_JSON_PATH', '/etc/hysteria/users.json')
|
users_json_path = os.getenv('HYSTERIA_USERS_JSON_PATH', '/etc/hysteria/users.json')
|
||||||
|
nodes_json_path = '/etc/hysteria/nodes.json'
|
||||||
rate_limit = 100
|
rate_limit = 100
|
||||||
rate_limit_window = 60
|
rate_limit_window = 60
|
||||||
template_dir = os.path.dirname(__file__)
|
template_dir = os.path.dirname(__file__)
|
||||||
@ -467,6 +473,7 @@ class HysteriaServer:
|
|||||||
singbox_template_path=singbox_template_path,
|
singbox_template_path=singbox_template_path,
|
||||||
hysteria_cli_path=hysteria_cli_path,
|
hysteria_cli_path=hysteria_cli_path,
|
||||||
users_json_path=users_json_path,
|
users_json_path=users_json_path,
|
||||||
|
nodes_json_path=nodes_json_path,
|
||||||
rate_limit=rate_limit, rate_limit_window=rate_limit_window,
|
rate_limit=rate_limit, rate_limit_window=rate_limit_window,
|
||||||
sni=sni, template_dir=template_dir,
|
sni=sni, template_dir=template_dir,
|
||||||
subpath=subpath)
|
subpath=subpath)
|
||||||
@ -548,22 +555,21 @@ class HysteriaServer:
|
|||||||
return web.Response(text=self.template_renderer.render(context), content_type='text/html')
|
return web.Response(text=self.template_renderer.render(context), content_type='text/html')
|
||||||
|
|
||||||
async def _handle_singbox(self, username: str, fragment: str, user_info: UserInfo) -> web.Response:
|
async def _handle_singbox(self, username: str, fragment: str, user_info: UserInfo) -> web.Response:
|
||||||
config_v4 = self.singbox_generator.generate_config(username, '4', fragment)
|
all_uris = self.hysteria_cli.get_all_uris(username)
|
||||||
config_v6 = self.singbox_generator.generate_config(username, '6', fragment)
|
if not all_uris:
|
||||||
if config_v4 is None and config_v6 is None:
|
|
||||||
return web.Response(status=404, text=f"Error: No valid URIs found for user {username}.")
|
return web.Response(status=404, text=f"Error: No valid URIs found for user {username}.")
|
||||||
combined_config = self.singbox_generator.combine_configs(username, config_v4, config_v6)
|
combined_config = self.singbox_generator.combine_configs(all_uris, username, fragment)
|
||||||
return web.Response(text=json.dumps(combined_config, indent=4, sort_keys=True), content_type='application/json')
|
return web.Response(text=json.dumps(combined_config, indent=4, sort_keys=True), content_type='application/json')
|
||||||
|
|
||||||
async def _handle_normalsub(self, request: web.Request, username: str, user_info: UserInfo) -> web.Response:
|
async def _handle_normalsub(self, request: web.Request, username: str, user_info: UserInfo) -> web.Response:
|
||||||
user_agent = request.headers.get('User-Agent', '').lower()
|
user_agent = request.headers.get('User-Agent', '').lower()
|
||||||
subscription = self.subscription_manager.get_normal_subscription(username, user_agent)
|
subscription = self.subscription_manager.get_normal_subscription(username, user_agent)
|
||||||
if subscription == "User not found": # Should be caught earlier by user_info check
|
if subscription == "User not found":
|
||||||
return web.Response(status=404, text=f"User '{username}' not found.")
|
return web.Response(status=404, text=f"User '{username}' not found.")
|
||||||
return web.Response(text=subscription, content_type='text/plain')
|
return web.Response(text=subscription, content_type='text/plain')
|
||||||
|
|
||||||
async def _get_template_context(self, username: str, user_info: UserInfo) -> TemplateContext:
|
async def _get_template_context(self, username: str, user_info: UserInfo) -> TemplateContext:
|
||||||
ipv4_uri, ipv6_uri = self.hysteria_cli.get_uris(username)
|
labeled_uris = self.hysteria_cli.get_all_labeled_uris(username)
|
||||||
port_str = f":{self.config.external_port}" if self.config.external_port not in [80, 443, 0] else ""
|
port_str = f":{self.config.external_port}" if self.config.external_port not in [80, 443, 0] else ""
|
||||||
base_url = f"https://{self.config.domain}{port_str}"
|
base_url = f"https://{self.config.domain}{port_str}"
|
||||||
|
|
||||||
@ -571,10 +577,21 @@ class HysteriaServer:
|
|||||||
print(f"Warning: Constructed base URL '{base_url}' might be invalid. Check domain and port config.")
|
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 = f"{base_url}/{self.config.subpath}/sub/normal/{user_info.password}"
|
||||||
|
|
||||||
ipv4_qrcode = Utils.generate_qrcode_base64(ipv4_uri)
|
|
||||||
ipv6_qrcode = Utils.generate_qrcode_base64(ipv6_uri)
|
|
||||||
sublink_qrcode = Utils.generate_qrcode_base64(sub_link)
|
sublink_qrcode = Utils.generate_qrcode_base64(sub_link)
|
||||||
|
|
||||||
|
local_uris = []
|
||||||
|
node_uris = []
|
||||||
|
|
||||||
|
for item in labeled_uris:
|
||||||
|
node_uri = NodeURI(
|
||||||
|
label=item['label'],
|
||||||
|
uri=item['uri'],
|
||||||
|
qrcode=Utils.generate_qrcode_base64(item['uri'])
|
||||||
|
)
|
||||||
|
if item['label'].startswith('Node:'):
|
||||||
|
node_uris.append(node_uri)
|
||||||
|
else:
|
||||||
|
local_uris.append(node_uri)
|
||||||
|
|
||||||
return TemplateContext(
|
return TemplateContext(
|
||||||
username=username,
|
username=username,
|
||||||
@ -582,11 +599,9 @@ class HysteriaServer:
|
|||||||
usage_raw=user_info.usage_detailed,
|
usage_raw=user_info.usage_detailed,
|
||||||
expiration_date=user_info.expiration_date,
|
expiration_date=user_info.expiration_date,
|
||||||
sublink_qrcode=sublink_qrcode,
|
sublink_qrcode=sublink_qrcode,
|
||||||
ipv4_qrcode=ipv4_qrcode,
|
|
||||||
ipv6_qrcode=ipv6_qrcode,
|
|
||||||
sub_link=sub_link,
|
sub_link=sub_link,
|
||||||
ipv4_uri=ipv4_uri,
|
local_uris=local_uris,
|
||||||
ipv6_uri=ipv6_uri
|
node_uris=node_uris
|
||||||
)
|
)
|
||||||
|
|
||||||
async def robots_handler(self, request: web.Request) -> web.Response:
|
async def robots_handler(self, request: web.Request) -> web.Response:
|
||||||
@ -605,7 +620,6 @@ class HysteriaServer:
|
|||||||
port=self.config.aiohttp_listen_port
|
port=self.config.aiohttp_listen_port
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
server = HysteriaServer()
|
server = HysteriaServer()
|
||||||
server.run()
|
server.run()
|
||||||
@ -41,7 +41,6 @@
|
|||||||
color: var(--text-dark);
|
color: var(--text-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animated background elements */
|
|
||||||
.background-animation {
|
.background-animation {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@ -78,17 +77,14 @@
|
|||||||
0% {
|
0% {
|
||||||
transform: translate(0, 0) rotate(0deg);
|
transform: translate(0, 0) rotate(0deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
transform: translate(100px, 100px) rotate(180deg);
|
transform: translate(100px, 100px) rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
transform: translate(0, 0) rotate(360deg);
|
transform: translate(0, 0) rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rest of the styles remain the same */
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1000px;
|
max-width: 1000px;
|
||||||
margin: 2rem auto;
|
margin: 2rem auto;
|
||||||
@ -175,10 +171,10 @@
|
|||||||
background: var(--card-bg-light);
|
background: var(--card-bg-light);
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
/* Add this to contain the header */
|
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-header {
|
.qr-header {
|
||||||
@ -196,7 +192,6 @@
|
|||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.dark-mode .qr-section {
|
.dark-mode .qr-section {
|
||||||
background: rgba(31, 41, 55, 0.8);
|
background: rgba(31, 41, 55, 0.8);
|
||||||
border-color: rgba(255, 255, 255, 0.05);
|
border-color: rgba(255, 255, 255, 0.05);
|
||||||
@ -229,6 +224,14 @@
|
|||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.uri-unavailable {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .uri-unavailable {
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-group {
|
.btn-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -278,6 +281,7 @@
|
|||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
display: none;
|
display: none;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark-mode .loading-indicator {
|
.dark-mode .loading-indicator {
|
||||||
@ -355,63 +359,81 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="qr-section">
|
<div class="qr-section">
|
||||||
<div class="qr-header">
|
<div class="qr-header">
|
||||||
<i class="fas fa-qrcode"></i>
|
<i class="fas fa-link"></i>
|
||||||
QR Codes
|
Subscription Link
|
||||||
</div>
|
</div>
|
||||||
<div class="qr-content">
|
<div class="qr-content">
|
||||||
<div class="qr-grid">
|
<div class="qr-grid">
|
||||||
<div class="qr-item">
|
<div class="qr-item" style="grid-column: 1 / -1;">
|
||||||
<h3 class="qr-title">Subscription Link</h3>
|
<h3 class="qr-title">Universal Subscription Link</h3>
|
||||||
<img src="{{ sublink_qrcode }}" alt="Subscription QR Code" class="qrcode">
|
<img src="{{ sublink_qrcode }}" alt="Subscription QR Code" class="qrcode">
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button class="btn btn-primary" onclick="copyToClipboard('{{ sub_link }}')">
|
<button class="btn btn-primary" onclick="copyToClipboard('{{ sub_link }}')">
|
||||||
<i class="fas fa-copy"></i> Copy
|
<i class="fas fa-copy"></i> Copy
|
||||||
</button>
|
</button>
|
||||||
<!-- <a href="{{ sub_link }}" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">
|
|
||||||
<i class="fas fa-external-link-alt"></i> Open
|
|
||||||
</a> -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="qr-item">
|
|
||||||
<h3 class="qr-title">IPv4 URI</h3>
|
|
||||||
{% if ipv4_qrcode %}
|
|
||||||
<img src="{{ ipv4_qrcode }}" alt="IPv4 QR Code" class="qrcode">
|
|
||||||
<div class="btn-group">
|
|
||||||
<button class="btn btn-primary" onclick="copyToClipboard('{{ ipv4_uri }}')">
|
|
||||||
<i class="fas fa-copy"></i> Copy
|
|
||||||
</button>
|
|
||||||
<!-- <a href="{{ ipv4_uri }}" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">
|
|
||||||
<i class="fas fa-external-link-alt"></i> Open
|
|
||||||
</a> -->
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="uri-unavailable">IPv4 URI not available</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="qr-item">
|
|
||||||
<h3 class="qr-title">IPv6 URI</h3>
|
|
||||||
{% if ipv6_qrcode %}
|
|
||||||
<img src="{{ ipv6_qrcode }}" alt="IPv6 QR Code" class="qrcode">
|
|
||||||
<div class="btn-group">
|
|
||||||
<button class="btn btn-primary" onclick="copyToClipboard('{{ ipv6_uri }}')">
|
|
||||||
<i class="fas fa-copy"></i> Copy
|
|
||||||
</button>
|
|
||||||
<!-- <a href="{{ ipv6_uri }}" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">
|
|
||||||
<i class="fas fa-external-link-alt"></i> Open
|
|
||||||
</a> -->
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="uri-unavailable">IPv6 URI not available</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="qr-section">
|
||||||
|
<div class="qr-header">
|
||||||
|
<i class="fas fa-server"></i>
|
||||||
|
Local Server Connections
|
||||||
|
</div>
|
||||||
|
<div class="qr-content">
|
||||||
|
<div class="qr-grid">
|
||||||
|
{% for item in local_uris %}
|
||||||
|
<div class="qr-item">
|
||||||
|
<h3 class="qr-title">{{ item.label }} URI</h3>
|
||||||
|
{% if item.qrcode %}
|
||||||
|
<img src="{{ item.qrcode }}" alt="{{ item.label }} QR Code" class="qrcode">
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-primary" onclick="copyToClipboard('{{ item.uri }}')">
|
||||||
|
<i class="fas fa-copy"></i> Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="uri-unavailable">{{ item.label }} URI not available</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if node_uris %}
|
||||||
|
<div class="qr-section">
|
||||||
|
<div class="qr-header">
|
||||||
|
<i class="fas fa-globe-americas"></i>
|
||||||
|
External Nodes
|
||||||
|
</div>
|
||||||
|
<div class="qr-content">
|
||||||
|
<div class="qr-grid">
|
||||||
|
{% for item in node_uris %}
|
||||||
|
<div class="qr-item">
|
||||||
|
<h3 class="qr-title">{{ item.label }}</h3>
|
||||||
|
{% if item.qrcode %}
|
||||||
|
<img src="{{ item.qrcode }}" alt="{{ item.label }} QR Code" class="qrcode">
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-primary" onclick="copyToClipboard('{{ item.uri }}')">
|
||||||
|
<i class="fas fa-copy"></i> Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="uri-unavailable">{{ item.label }} URI not available</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
Reference in New Issue
Block a user