1479 lines
53 KiB
Plaintext
1479 lines
53 KiB
Plaintext
(function () {
|
|
"use strict";
|
|
|
|
var theme = window.NEBULA_THEME || {};
|
|
var app = document.getElementById("app");
|
|
var loader = document.getElementById("nebula-loader");
|
|
var themeMediaQuery = getThemeMediaQuery();
|
|
|
|
if (!app) {
|
|
return;
|
|
}
|
|
|
|
var state = {
|
|
mode: "login",
|
|
loading: true,
|
|
message: "",
|
|
messageType: "",
|
|
themePreference: getStoredThemePreference(),
|
|
themeMode: "dark",
|
|
uplinkMbps: null,
|
|
currentRoute: getCurrentRoute(),
|
|
nodePage: 1,
|
|
localIp: null,
|
|
reportedIps: [],
|
|
authToken: getStoredToken(),
|
|
user: null,
|
|
subscribe: null,
|
|
stats: null,
|
|
knowledge: [],
|
|
tickets: [],
|
|
selectedTicketId: null,
|
|
selectedTicket: null,
|
|
servers: [],
|
|
ipv6AuthToken: getStoredIpv6Token(),
|
|
ipv6User: null,
|
|
ipv6Subscribe: null,
|
|
ipv6Eligibility: null,
|
|
ipv6Servers: [],
|
|
sessionOverview: null,
|
|
ipv6SessionOverview: null,
|
|
realNameVerification: null,
|
|
appConfig: null,
|
|
isSidebarOpen: false,
|
|
serverSearch: "",
|
|
cachedSubNodes: null,
|
|
ipv6CachedSubNodes: null
|
|
};
|
|
|
|
init();
|
|
|
|
async function init() {
|
|
applyThemeMode();
|
|
applyCustomBackground();
|
|
loadUplinkMetric();
|
|
window.setInterval(loadUplinkMetric, 5000);
|
|
|
|
try {
|
|
var verify = getVerifyToken();
|
|
if (verify) {
|
|
var verifyResponse = await fetchJson("/api/v1/passport/auth/token2Login?verify=" + encodeURIComponent(verify), {
|
|
method: "GET",
|
|
auth: false
|
|
});
|
|
var verifyPayload = unwrap(verifyResponse);
|
|
if (verifyPayload && verifyPayload.auth_data) {
|
|
saveToken(verifyPayload.auth_data);
|
|
clearVerifyToken();
|
|
state.authToken = getStoredToken();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
showMessage(error.message || "Quick login failed", "error");
|
|
}
|
|
|
|
if (state.authToken) {
|
|
var loaded = await loadDashboard();
|
|
if (!loaded) {
|
|
state.loading = false;
|
|
}
|
|
} else {
|
|
state.loading = false;
|
|
}
|
|
|
|
render();
|
|
app.addEventListener("click", onClick);
|
|
app.addEventListener("input", onInput);
|
|
app.addEventListener("submit", onSubmit);
|
|
window.addEventListener("hashchange", onRouteChange);
|
|
bindSystemThemeListener();
|
|
}
|
|
|
|
async function loadDashboard(silent) {
|
|
if (!state.authToken) {
|
|
state.loading = false;
|
|
return false;
|
|
}
|
|
|
|
state.loading = !silent;
|
|
if (!silent) {
|
|
render();
|
|
}
|
|
|
|
try {
|
|
var results = await Promise.all([
|
|
fetchJson("/api/v1/user/info", { method: "GET" }),
|
|
fetchJson("/api/v1/user/getSubscribe", { method: "GET" }),
|
|
fetchJson("/api/v1/user/getStat", { method: "GET" }),
|
|
fetchJson("/api/v1/user/server/fetch", { method: "GET" }),
|
|
fetchJson("/api/v1/user/ticket/fetch", { method: "GET" }),
|
|
fetchSessionOverview(),
|
|
fetchJson("/api/v1/user/comm/config", { method: "GET" }),
|
|
fetchRealNameVerificationStatus(),
|
|
fetchJson("/api/v1/user/user-add-ipv6-subscription/check", { method: "GET" })
|
|
]);
|
|
|
|
state.user = unwrap(results[0]);
|
|
state.subscribe = unwrap(results[1]);
|
|
state.stats = unwrap(results[2]);
|
|
state.servers = unwrap(results[3]) || [];
|
|
state.knowledge = [];
|
|
state.tickets = unwrap(results[4]) || [];
|
|
state.sessionOverview = results[5];
|
|
state.appConfig = unwrap(results[6]) || {};
|
|
state.realNameVerification = results[7] || null;
|
|
state.ipv6Eligibility = unwrap(results[8]) || null;
|
|
|
|
if (state.ipv6AuthToken) {
|
|
try {
|
|
var ipv6Results = await Promise.all([
|
|
fetchJson("/api/v1/user/info", { method: "GET", ipv6: true }),
|
|
fetchJson("/api/v1/user/getSubscribe", { method: "GET", ipv6: true }),
|
|
fetchJson("/api/v1/user/server/fetch", { method: "GET", ipv6: true }),
|
|
fetchSessionOverview(true)
|
|
]);
|
|
state.ipv6User = unwrap(ipv6Results[0]);
|
|
state.ipv6Subscribe = unwrap(ipv6Results[1]);
|
|
state.ipv6Servers = unwrap(ipv6Results[2]) || [];
|
|
state.ipv6SessionOverview = ipv6Results[3];
|
|
} catch (e) {
|
|
if (handleIpv6AuthFailure(e)) {
|
|
console.warn("Nebula: cleared stale IPv6 session after unauthorized response");
|
|
}
|
|
console.error("Failed to load IPv6 dashboard data", e);
|
|
}
|
|
}
|
|
|
|
state.nodePage = 1;
|
|
state.loading = false;
|
|
return true;
|
|
} catch (error) {
|
|
state.loading = false;
|
|
if (error.status === 401 || error.status === 403) {
|
|
var hadSession = Boolean(state.authToken);
|
|
clearToken();
|
|
resetDashboard();
|
|
showMessage(hadSession ? "Session expired. Please sign in again." : "", hadSession ? "error" : "");
|
|
return false;
|
|
}
|
|
showMessage(error.message || "Failed to load dashboard", "error");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function fetchSessionOverview(isIpv6) {
|
|
try {
|
|
var response = await fetchJson("/api/v1/user/user-online-devices/get-ip", { method: "GET", ipv6: !!isIpv6 });
|
|
var ipPayload = unwrap(response) || {};
|
|
|
|
var reportedIps = ipPayload.ips || [];
|
|
if (!isIpv6) {
|
|
state.reportedIps = reportedIps;
|
|
}
|
|
|
|
var overview = ipPayload.session_overview || { sessions: [], online_ips: [] };
|
|
if (!isIpv6) {
|
|
overview.online_ips = state.reportedIps;
|
|
}
|
|
|
|
return buildSessionOverview(overview.sessions, overview, !!isIpv6);
|
|
} catch (error) {
|
|
if (isIpv6 && handleIpv6AuthFailure(error)) {
|
|
return null;
|
|
}
|
|
console.error("Nebula: Failed to synchronize online status", error);
|
|
// Fallback for sessions if plugin fails
|
|
try {
|
|
var sessionsFallback = unwrap(await fetchJson("/api/v1/user/getActiveSession", { method: "GET", ipv6: !!isIpv6 })) || [];
|
|
return buildSessionOverview(sessionsFallback, {}, !!isIpv6);
|
|
} catch (e) {
|
|
if (isIpv6 && handleIpv6AuthFailure(e)) {
|
|
return null;
|
|
}
|
|
return buildSessionOverview([], {}, !!isIpv6);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function fetchRealNameVerificationStatus() {
|
|
try {
|
|
var response = await fetchJson("/api/v1/user/real-name-verification/status", { method: "GET" });
|
|
var payload = unwrap(response) || {};
|
|
return Object.assign({
|
|
enabled: true,
|
|
status: "unverified",
|
|
status_label: "閿熷€熷▼閹洟娓归敓?,
|
|
can_submit: true,
|
|
real_name: "",
|
|
identity_no_masked: "",
|
|
notice: "",
|
|
reject_reason: "",
|
|
submitted_at: null,
|
|
reviewed_at: null
|
|
}, payload);
|
|
} catch (error) {
|
|
return {
|
|
enabled: false,
|
|
status: "unavailable",
|
|
status_label: "Unavailable",
|
|
can_submit: false,
|
|
real_name: "",
|
|
identity_no_masked: "",
|
|
notice: "",
|
|
reject_reason: "",
|
|
submitted_at: null,
|
|
reviewed_at: null
|
|
};
|
|
}
|
|
}
|
|
|
|
async function fetchJson(url, options) {
|
|
options = options || {};
|
|
var headers = {
|
|
"Content-Type": "application/json",
|
|
"X-Requested-With": "XMLHttpRequest"
|
|
};
|
|
|
|
if (options.auth !== false) {
|
|
var token = options.ipv6 ? (state.ipv6AuthToken || getStoredIpv6Token()) : (state.authToken || getStoredToken());
|
|
if (token) {
|
|
headers.Authorization = normalizeAuthHeader(token);
|
|
}
|
|
}
|
|
|
|
var response = await fetch(url, {
|
|
method: options.method || "GET",
|
|
headers: headers,
|
|
credentials: "same-origin",
|
|
body: options.body ? JSON.stringify(options.body) : undefined
|
|
});
|
|
|
|
var payload = null;
|
|
try {
|
|
payload = await response.json();
|
|
} catch (error) {
|
|
payload = null;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
var message = payload && (payload.message || payload.error) || "Request failed";
|
|
var err = new Error(message);
|
|
err.status = response.status;
|
|
throw err;
|
|
}
|
|
|
|
return payload;
|
|
}
|
|
|
|
function unwrap(payload) {
|
|
if (!payload) {
|
|
return null;
|
|
}
|
|
if (typeof payload.data !== "undefined") {
|
|
return payload.data;
|
|
}
|
|
if (typeof payload.total !== "undefined" && Array.isArray(payload.data)) {
|
|
return payload.data;
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
function onClick(event) {
|
|
if (state.isSidebarOpen) {
|
|
var sidebar = event.target.closest(".dashboard-sidebar");
|
|
var toggleBtn = event.target.closest("[data-action='toggle-sidebar']");
|
|
if (!sidebar && !toggleBtn) {
|
|
state.isSidebarOpen = false;
|
|
document.documentElement.dataset.sidebarOpen = "false";
|
|
var sidebarEl = document.querySelector(".dashboard-sidebar");
|
|
var overlayEl = document.querySelector(".sidebar-overlay");
|
|
if (sidebarEl) sidebarEl.classList.remove("is-open");
|
|
if (overlayEl) overlayEl.classList.remove("is-visible");
|
|
}
|
|
}
|
|
|
|
var actionEl = event.target.closest("[data-action]");
|
|
if (!actionEl) return;
|
|
|
|
var action = actionEl.getAttribute("data-action");
|
|
|
|
if (action === "switch-mode") {
|
|
state.mode = actionEl.getAttribute("data-mode") || "login";
|
|
showMessage("", "");
|
|
render();
|
|
return;
|
|
}
|
|
|
|
if (action === "navigate") {
|
|
state.isSidebarOpen = false;
|
|
document.documentElement.dataset.sidebarOpen = "false";
|
|
setCurrentRoute(actionEl.getAttribute("data-route") || "overview");
|
|
return;
|
|
}
|
|
|
|
if (action === "toggle-sidebar") {
|
|
state.isSidebarOpen = !state.isSidebarOpen;
|
|
document.documentElement.dataset.sidebarOpen = String(state.isSidebarOpen);
|
|
var sidebar = document.querySelector(".dashboard-sidebar");
|
|
var overlay = document.querySelector(".sidebar-overlay");
|
|
if (sidebar) sidebar.classList.toggle("is-open", state.isSidebarOpen);
|
|
if (overlay) overlay.classList.toggle("is-visible", state.isSidebarOpen);
|
|
return;
|
|
}
|
|
|
|
if (action === "close-sidebar") {
|
|
state.isSidebarOpen = false;
|
|
document.documentElement.dataset.sidebarOpen = "false";
|
|
var sidebar = document.querySelector(".dashboard-sidebar");
|
|
var overlay = document.querySelector(".sidebar-overlay");
|
|
if (sidebar) sidebar.classList.remove("is-open");
|
|
if (overlay) overlay.classList.remove("is-visible");
|
|
return;
|
|
}
|
|
|
|
if (action === "toggle-theme-mode") {
|
|
toggleThemeMode();
|
|
return;
|
|
}
|
|
|
|
if (action === "change-node-page") {
|
|
setNodePage(Number(actionEl.getAttribute("data-page") || 1));
|
|
return;
|
|
}
|
|
|
|
if (action === "logout") {
|
|
clearToken();
|
|
clearIpv6Token();
|
|
resetDashboard();
|
|
render();
|
|
return;
|
|
}
|
|
|
|
if (action === "refresh-dashboard") {
|
|
loadDashboard(true).then(render);
|
|
return;
|
|
}
|
|
|
|
if (action === "copy-subscribe") {
|
|
copyText((state.subscribe && state.subscribe.subscribe_url) || "");
|
|
return;
|
|
}
|
|
|
|
if (action === "copy-ipv6-subscribe") {
|
|
copyText((state.ipv6Subscribe && state.ipv6Subscribe.subscribe_url) || "");
|
|
return;
|
|
}
|
|
|
|
if (action === "copy-text") {
|
|
copyText(actionEl.getAttribute("data-value") || "");
|
|
return;
|
|
}
|
|
|
|
if (action === "reset-security") {
|
|
handleResetSecurity(actionEl);
|
|
return;
|
|
}
|
|
|
|
if (action === "reset-ipv6-security") {
|
|
handleResetSecurity(actionEl, true);
|
|
return;
|
|
}
|
|
|
|
if (action === "remove-session") {
|
|
handleRemoveSession(actionEl);
|
|
return;
|
|
}
|
|
|
|
if (action === "remove-other-sessions") {
|
|
handleRemoveOtherSessions(actionEl);
|
|
return;
|
|
}
|
|
|
|
if (action === "open-ticket-detail") {
|
|
handleOpenTicketDetail(actionEl);
|
|
return;
|
|
}
|
|
|
|
if (action === "open-knowledge-article") {
|
|
handleOpenKnowledgeArticle(actionEl);
|
|
return;
|
|
}
|
|
|
|
if (action === "close-ticket-detail") {
|
|
state.selectedTicketId = null;
|
|
state.selectedTicket = null;
|
|
render();
|
|
}
|
|
|
|
if (action === "enable-ipv6") {
|
|
handleEnableIpv6(actionEl);
|
|
return;
|
|
}
|
|
|
|
if (action === "sync-ipv6-password") {
|
|
handleSyncIpv6Password(actionEl);
|
|
return;
|
|
}
|
|
|
|
if (action === "copy-node-info") {
|
|
handleCopyNodeInfo(actionEl.getAttribute("data-node-id"));
|
|
return;
|
|
}
|
|
|
|
if (action === "set-mode") {
|
|
state.mode = actionEl.getAttribute("data-mode") || "login";
|
|
render();
|
|
return;
|
|
}
|
|
|
|
if (action === "send-reset-code") {
|
|
var form = actionEl.closest("form");
|
|
var email = form.querySelector("[name='email']").value;
|
|
if (!email) {
|
|
showMessage("闂囧倻鎲楅敓鏂ゆ嫹娴犲尅鎷烽摝鐐插珝鐨堟啰閿熸枻鎷?, "warning");
|
|
return;
|
|
}
|
|
actionEl.disabled = true;
|
|
fetchJson("/api/v1/passport/comm/sendEmailVerify", {
|
|
method: "POST",
|
|
auth: false,
|
|
body: { email: email }
|
|
}).then(function () {
|
|
showMessage("閹惧顏堕敓鏂ゆ嫹閿熻姤鐡忛敓鐣屽悩閿熸枻鎷烽敓浠嬫腹閻熺噦鎷烽敓鎴掓唉閿熸枻鎷烽敓鏂ゆ嫹閾︾儑鎷?, "success");
|
|
startCountdown(actionEl, 60);
|
|
}).catch(function (error) {
|
|
showMessage(error.message || "閿熺晫鎲伴敓鑺ユ嫟鐠滅櫢鎷烽敓鏂ゆ嫹娴犲啴鐓囬敓?, "error");
|
|
actionEl.disabled = false;
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
function startCountdown(button, seconds) {
|
|
var originalText = "閿熺晫鎲伴敓鑺ユ嫟鐠滅櫢鎷烽敓鏂ゆ嫹";
|
|
button.disabled = true;
|
|
var current = seconds;
|
|
var timer = setInterval(function () {
|
|
if (current <= 0) {
|
|
clearInterval(timer);
|
|
button.innerText = originalText;
|
|
button.disabled = false;
|
|
return;
|
|
}
|
|
button.innerText = current + "s";
|
|
current--;
|
|
}, 1000);
|
|
}
|
|
|
|
async function handleCopyNodeInfo(nodeId) {
|
|
var server = (state.servers || []).concat(state.ipv6Servers || []).find(function (s) {
|
|
return String(s.id) === String(nodeId);
|
|
});
|
|
if (!server) return;
|
|
|
|
var isIpv6Node = (state.ipv6Servers || []).some(function (s) {
|
|
return String(s.id) === String(nodeId);
|
|
});
|
|
|
|
var sub = isIpv6Node ? state.ipv6Subscribe : state.subscribe;
|
|
var subUrl = (sub && sub.subscribe_url) || "";
|
|
var targetName = (server.name || "").trim();
|
|
var cacheKey = isIpv6Node ? "ipv6CachedSubNodes" : "cachedSubNodes";
|
|
var storageKey = isIpv6Node ? "__nebula_ipv6_sub_content__" : "__nebula_sub_content__";
|
|
|
|
// Try to get link from subscription if possible
|
|
if (subUrl) {
|
|
try {
|
|
if (!state[cacheKey]) {
|
|
// Check if we have a manually pasted sub content in sessionStorage
|
|
var savedSub = window.sessionStorage.getItem(storageKey);
|
|
if (savedSub) {
|
|
console.log("Nebula: Using session-cached " + (isIpv6Node ? "IPv6 " : "") + "subscription content");
|
|
state[cacheKey] = parseSubscriptionToLines(savedSub);
|
|
} else {
|
|
console.log("Nebula: Synchronizing " + (isIpv6Node ? "IPv6 " : "") + "subscription metadata...");
|
|
state[cacheKey] = await fetchSubscriptionNodes(subUrl);
|
|
}
|
|
}
|
|
|
|
if (state[cacheKey] && state[cacheKey].length) {
|
|
var link = findNodeLinkByName(state[cacheKey], targetName);
|
|
if (link) {
|
|
var clashYaml = linkToClash(link);
|
|
showNodeInfoModal(server, clashYaml, link);
|
|
return;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Nebula: Failed to parse subscription", error);
|
|
// If it looks like a CORS error (TypeError: Failed to fetch)
|
|
if (error instanceof TypeError) {
|
|
showCorsHelpModal(subUrl, nodeId, isIpv6Node);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback to text info if link not found or subscription failed
|
|
var tags = normalizeServerTags(server.tags);
|
|
var info = [
|
|
"閿熸枻鎷烽敓鏂ゆ嫹濠娿劌顩? " + (server.name || "閿熷€熷Ъ绌扮洴閿熻姤鍝楅敓鏂ゆ嫹閿?),
|
|
"閿熸枻鎷烽敓鍊熸綏妞佸喛鎷? " + (server.type || "閿熷€熷Р娴?),
|
|
"閿熸枻鎷烽敓鏂ゆ嫹濠婎煉鎷? " + (server.rate || 1) + "x",
|
|
"閿熸枻鎷烽敓鏂ゆ嫹閸″棴鎷烽敓? " + (server.is_online ? "閿熻棄鍤遍悷? : "閾︽娊顦遍悷?),
|
|
tags.length ? "閿熸枻鎷烽敓鏂ゆ嫹閿熻棄鈧? " + tags.join(", ") : ""
|
|
].filter(Boolean).join("\n");
|
|
|
|
copyText(info, "閹惧矁鍔婇敓鏂ゆ嫹閸″鎷烽敓钘夘劗閹跺拑鎷烽惍宀€绗愰敓鍊熷礉閿熶粙婀€閿濅緤鎷烽敓鑺ユ癄绌扮铂閺佽埖鐎奸敓浠嬪濠婃俺娅ㄦ0鍙夘煹閿?);
|
|
}
|
|
|
|
function showNodeInfoModal(server, clashYaml, standardLink) {
|
|
var overlay = document.createElement("div");
|
|
overlay.className = "nebula-modal-overlay";
|
|
|
|
var modal = document.createElement("div");
|
|
modal.className = "nebula-modal stack";
|
|
modal.style.maxWidth = "600px";
|
|
modal.style.width = "min(540px, 100%)";
|
|
|
|
modal.innerHTML = [
|
|
'<h3 class="nebula-modal-title" style="margin-bottom: 20px;">' + escapeHtml(server.name || "閿熸枻鎷烽敓浠嬫腹鐞涘矉鎷?) + '</h3>',
|
|
'<div class="node-specs" style="margin-bottom:24px; display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">',
|
|
'<div class="node-spec" style="background: rgba(var(--accent-rgb), 0.05); border-color: rgba(var(--accent-rgb), 0.15); text-align: center; padding: 14px;">' +
|
|
'<span class="meta-label" style="margin-top:0; font-size:10px; opacity:0.7;">閿熸枻鎷烽敓鍊熸綏妞佸喛鎷?/span>' +
|
|
'<strong style="color: var(--accent); font-size: 18px; margin-top: 4px; display: block;">' + escapeHtml(server.type || "unknown").toUpperCase() + '</strong>' +
|
|
'</div>',
|
|
'<div class="node-spec" style="background: rgba(var(--accent-rgb), 0.05); border-color: rgba(var(--accent-rgb), 0.15); text-align: center; padding: 14px;">' +
|
|
'<span class="meta-label" style="margin-top:0; font-size:10px; opacity:0.7;">閾﹀繑鈷嶉敓鏂ゆ嫹濠婎煉鎷?/span>' +
|
|
'<strong style="color: var(--accent); font-size: 18px; margin-top: 4px; display: block;">' + escapeHtml(String(server.rate || 1)) + 'x</strong>' +
|
|
'</div>',
|
|
'</div>',
|
|
'<div class="field" style="margin-top: 8px;"><label>Clash 閿熻姤鑺遍拕顓熷枑閿熸枻鎷?(閹惧矁鍔欓敓鏂ゆ嫹閿?</label>',
|
|
'<pre class="node-info-pre" style="max-height: 180px; font-size: 11px;">' + escapeHtml(maskClashYaml(clashYaml)) + '</pre>',
|
|
'</div>',
|
|
'<div class="nebula-modal-footer" style="padding-top: 24px; display: flex; gap: 10px; flex-wrap: wrap;">',
|
|
'<button class="btn btn-primary" id="node-info-copy-link" style="flex: 1; min-width: 100px; white-space: nowrap;">閿熸枻鎷烽敓鏂ゆ嫹濠㈠棴鎷?/button>',
|
|
'<button class="btn btn-primary" id="node-info-copy-clash" style="flex: 1; min-width: 100px; white-space: nowrap;">Clash 閿熻姤绶犻敓?/button>',
|
|
'<button class="btn btn-ghost" id="node-info-close" style="flex: 1; min-width: 100px; white-space: nowrap;">閿熻棄鏌岀ü顢?/button>',
|
|
'</div>'
|
|
].join("");
|
|
|
|
document.body.appendChild(overlay);
|
|
overlay.appendChild(modal);
|
|
|
|
modal.querySelector("#node-info-close").onclick = function() {
|
|
document.body.removeChild(overlay);
|
|
};
|
|
|
|
modal.querySelector("#node-info-copy-link").onclick = function() {
|
|
copyText(standardLink, "閿熸枻鎷烽敓鏂ゆ嫹濠娿劏鏁敓鑺ユ癄绌扮铂閹惧矁鍔婇敓鏂ゆ嫹閿?);
|
|
document.body.removeChild(overlay);
|
|
};
|
|
|
|
modal.querySelector("#node-info-copy-clash").onclick = function() {
|
|
copyText(clashYaml, "Clash 閿熻姤鑺遍拕顓熸嫲閼达綇鎷烽敓鏂ゆ嫹");
|
|
document.body.removeChild(overlay);
|
|
};
|
|
|
|
overlay.onclick = function(e) {
|
|
if (e.target === overlay) document.body.removeChild(overlay);
|
|
};
|
|
}
|
|
|
|
function showCorsHelpModal(subUrl, nodeId, isIpv6) {
|
|
var title = (isIpv6 ? "IPv6 " : "") + "閿熻棄纭堕柈濠囨箑閿濅緤鎷烽敓钘夋暤閺?;
|
|
showNebulaModal({
|
|
title: title,
|
|
content: "閿熻姤娴岄敓鐣屾А鐠佹冻鎷烽敓钘夊煝濡ゅ鎷烽敓?(CORS) 閾︽埌顥屽锛嗛敓閲戭棁纭锋嫹閸ゆ纭堕敓鐣屾В闂佈冨櫩閿熸垝渚涢敓鍊熸緥閻撻敮鏌嶉敓鐣屾啺閿熸枻鎷烽敓鏂ゆ嫹閿熻姤鑺遍拕顓ㄦ嫹閺嗘顬囨牭鎷烽敓鐣岀崝閿熷€燁潌绋峰暈閿熻棄纭堕柈濠囧閿熺晫鏀埈鐔镐淮閿熸枻鎷烽敓绲搉\n1. 閿熻棄顒粙顒勫鐨堚拤娲愁噯鎷烽崢鏉垮厳閿熻棄鍠楅敓浠嬫焻閿熷€熷暎闁垫繂澧遍敓鑺ユ寠閿熶粙婀€閿濅緤鎷烽敓鑺ユ癄绌扮铂\n2. 閹差厽绮撮敓鍊熸緥閻撻敮鏌嶉敓鑺ユ碂閸徰嶆嫹閿熸枻鎷烽敓钘夊悁閿熻姤鎳撻柍绶?. 妞佸牠銈粣鎷烽悽鍥ㄦ瀮閿熸枻鎷风€涚數矛閿熼噾鈻嶎垳鎶氶摝搴濅簼閺傛劧鎷烽崲銉ュ剻閹筋喚娼堥敓鍊熸将妞佸矉鎷?,
|
|
confirmText: "閿熻姤鐎奸敓浠嬫箑閿濅緤鎷烽敓鑺ユ癄绌扮铂",
|
|
cancelText: "閿熼噾顦撮潻鎷烽敓鍊燁潌绋峰暈閾﹀簼浜濋弬?,
|
|
onConfirm: function() {
|
|
window.open(subUrl, "_blank");
|
|
showMessage("闂囧牞绲归敓鑺ユ嫲閼达綁濡敓钘夋暤閿熷€熸綑閺嗙硶鏌嶉敓鑺ョ€奸敓钘夋鐠滃湱鐛旈幉顓熺泊閿熸枻鎷烽崱鍜冩嫹閹藉府鎷?, "info");
|
|
},
|
|
onCancel: function() {
|
|
showManualPasteModal(nodeId, isIpv6);
|
|
}
|
|
});
|
|
}
|
|
|
|
function showManualPasteModal(nodeId, isIpv6) {
|
|
var overlay = document.createElement("div");
|
|
overlay.className = "nebula-modal-overlay";
|
|
|
|
var modal = document.createElement("div");
|
|
modal.className = "nebula-modal stack";
|
|
modal.style.maxWidth = "550px";
|
|
|
|
var storageKey = isIpv6 ? "__nebula_ipv6_sub_content__" : "__nebula_sub_content__";
|
|
var cacheKey = isIpv6 ? "ipv6CachedSubNodes" : "cachedSubNodes";
|
|
|
|
modal.innerHTML = [
|
|
'<h3 class="nebula-modal-title">閾﹀簼浜濋弬?' + (isIpv6 ? "IPv6 " : "") + '闂囧牞绲归敓鏂ゆ嫹閿熻姤宕?/h3>',
|
|
'<p class="hero-description" style="font-size:13px;margin-bottom:12px">闂囧倻鎲伴敓浠嬫禌閹晪鎷烽敓鑺ョ泊閿熸枻鎷烽崱銉嫹闂囧牞绲归敓鐣屾绌戝仭鐟绢煉鎷烽敓鏂ゆ嫹闂婃劖鐖堕柕渚€濮栫殘鈷夋闯顕嗘嫹閿熸枻鎷烽梾鐐电埆閿熶粙濮栫粻闈╂嫹闂侇偓鎷烽敓鑺ユ寭閸♀槄鎷烽幗顔笺€呴敓鏂ゆ嫹</p>',
|
|
'<textarea id="nebula-sub-paste" class="field" style="width:100%;min-height:220px;font-size:12px;background:rgba(255,255,255,0.03);border-radius:12px;padding:12px" placeholder="閾﹀簼浜濋弬鎰敧閺傚浄鎷?.."></textarea>',
|
|
'<div class="nebula-modal-footer">',
|
|
'<button class="btn btn-ghost" id="nebula-paste-cancel">閿熼噾顢㈡鎷?/button>',
|
|
'<button class="btn btn-primary" id="nebula-paste-save">闂侇偓鎷烽敓鑺ユ寭閸♀槄鎷烽幗顕嗘嫹</button>',
|
|
'</div>'
|
|
].join("");
|
|
|
|
document.body.appendChild(overlay);
|
|
overlay.appendChild(modal);
|
|
|
|
modal.querySelector("#nebula-paste-cancel").onclick = function() {
|
|
document.body.removeChild(overlay);
|
|
};
|
|
|
|
modal.querySelector("#nebula-paste-save").onclick = function() {
|
|
var content = modal.querySelector("#nebula-sub-paste").value.trim();
|
|
if (content) {
|
|
window.sessionStorage.setItem(storageKey, content);
|
|
state[cacheKey] = parseSubscriptionToLines(content);
|
|
document.body.removeChild(overlay);
|
|
showMessage("闂囧牞绲归敓鏂ゆ嫹閸炶櫕妞忛敓钘夌《闁鎷风鍋ч敓钘夋閿?, "success");
|
|
if (nodeId) handleCopyNodeInfo(nodeId);
|
|
} else {
|
|
showMessage("閿熸枻鎷烽幑鍡涘濠娾晪鎷烽柕婵堢暏瀵?, "error");
|
|
}
|
|
};
|
|
}
|
|
|
|
async function fetchSubscriptionNodes(url) {
|
|
try {
|
|
var response = await fetch(url);
|
|
if (!response.ok) throw new Error("Network response was not ok");
|
|
var text = await response.text();
|
|
return parseSubscriptionToLines(text);
|
|
} catch (e) {
|
|
throw e; // Reraise to be caught by handleCopyNodeInfo for CORS check
|
|
}
|
|
}
|
|
|
|
function parseSubscriptionToLines(text) {
|
|
var decoded = "";
|
|
try {
|
|
decoded = utf8Base64Decode(text.trim());
|
|
} catch (e) {
|
|
decoded = text;
|
|
}
|
|
return decoded.split(/\r?\n/).map(function(s) { return s.trim(); }).filter(Boolean);
|
|
}
|
|
|
|
function findNodeLinkByName(links, name) {
|
|
if (!name) return null;
|
|
var target = name.trim();
|
|
|
|
console.log("Nebula: Searching for node '" + name + "' among " + links.length + " links (Strict Match)");
|
|
|
|
for (var i = 0; i < links.length; i++) {
|
|
var link = links[i].trim();
|
|
var remark = "";
|
|
|
|
if (link.indexOf("#") !== -1) {
|
|
var parts = link.split("#");
|
|
var rawRemark = parts[parts.length - 1];
|
|
try {
|
|
remark = decodeURIComponent(rawRemark.replace(/\+/g, "%20"));
|
|
} catch (e) {
|
|
remark = rawRemark;
|
|
}
|
|
} else if (link.indexOf("vmess://") === 0) {
|
|
try {
|
|
var jsonStr = utf8Base64Decode(link.slice(8));
|
|
var json = JSON.parse(jsonStr);
|
|
remark = json.ps || "";
|
|
} catch (e) {}
|
|
} else if (link.indexOf("name:") !== -1) {
|
|
var yamlMatch = link.match(/name:\s*["']?([^"']+)["']?/);
|
|
if (yamlMatch) remark = yamlMatch[1];
|
|
}
|
|
|
|
// Strict exact match after trimming
|
|
if ((remark || "").trim() === target) {
|
|
return link;
|
|
}
|
|
}
|
|
|
|
console.warn("Nebula: No exact match found for node '" + name + "'");
|
|
return null;
|
|
}
|
|
|
|
function maskClashYaml(yaml) {
|
|
if (!yaml) return "";
|
|
return yaml
|
|
.replace(/server:\s*.+/g, "server: **********")
|
|
.replace(/password:\s*.+/g, "password: **********")
|
|
.replace(/uuid:\s*.+/g, "uuid: **********")
|
|
.replace(/public-key:\s*.+/g, "public-key: **********");
|
|
}
|
|
|
|
function linkToClash(link) {
|
|
if (!link) return "";
|
|
|
|
var remark = "";
|
|
if (link.indexOf("#") !== -1) {
|
|
var parts = link.split("#");
|
|
try {
|
|
remark = decodeURIComponent(parts[parts.length - 1].replace(/\+/g, "%20"));
|
|
} catch (e) {
|
|
remark = parts[parts.length - 1];
|
|
}
|
|
}
|
|
|
|
// Shadowsocks
|
|
if (link.indexOf("ss://") === 0) {
|
|
var body = link.split("#")[0].slice(5);
|
|
var parts = body.split("@");
|
|
if (parts.length < 2) return link;
|
|
|
|
var userinfo = parts[0];
|
|
var serverinfo = parts[1];
|
|
var decodedUser = "";
|
|
try {
|
|
decodedUser = atob(userinfo.replace(/-/g, "+").replace(/_/g, "/"));
|
|
} catch (e) {
|
|
decodedUser = userinfo;
|
|
}
|
|
|
|
var userParts = decodedUser.split(":");
|
|
var serverParts = serverinfo.split(":");
|
|
|
|
return [
|
|
"- name: \"" + (remark || "SS Node") + "\"",
|
|
" type: ss",
|
|
" server: " + serverParts[0],
|
|
" port: " + (serverParts[1] || 443),
|
|
" cipher: " + (userParts[0] || "aes-256-gcm"),
|
|
" password: " + (userParts[1] || ""),
|
|
" udp: true"
|
|
].join("\n");
|
|
}
|
|
|
|
// VMess
|
|
if (link.indexOf("vmess://") === 0) {
|
|
try {
|
|
var json = JSON.parse(utf8Base64Decode(link.slice(8)));
|
|
var yaml = [
|
|
"- name: \"" + (json.ps || remark || "VMess Node") + "\"",
|
|
" type: vmess",
|
|
" server: " + json.add,
|
|
" port: " + (json.port || 443),
|
|
" uuid: " + json.id,
|
|
" alterId: " + (json.aid || 0),
|
|
" cipher: auto",
|
|
" udp: true",
|
|
" tls: " + (json.tls === "tls" ? "true" : "false"),
|
|
" network: " + (json.net || "tcp"),
|
|
" servername: " + (json.sni || json.host || "")
|
|
];
|
|
if (json.net === "ws") {
|
|
yaml.push(" ws-opts:");
|
|
yaml.push(" path: " + (json.path || "/"));
|
|
if (json.host) yaml.push(" headers:");
|
|
if (json.host) yaml.push(" Host: " + json.host);
|
|
}
|
|
if (json.net === "grpc") {
|
|
yaml.push(" grpc-opts:");
|
|
yaml.push(" grpc-service-name: " + (json.path || ""));
|
|
}
|
|
return yaml.join("\n");
|
|
} catch (e) {
|
|
return link;
|
|
}
|
|
}
|
|
|
|
// VLESS
|
|
if (link.indexOf("vless://") === 0) {
|
|
var body = link.split("#")[0].slice(8);
|
|
var main = body.split("?")[0];
|
|
var query = body.split("?")[1] || "";
|
|
var parts = main.split("@");
|
|
var uuid = parts[0];
|
|
var serverParts = (parts[1] || "").split(":");
|
|
|
|
var params = {};
|
|
query.split("&").forEach(function (pair) {
|
|
var p = pair.split("=");
|
|
params[p[0]] = decodeURIComponent(p[1] || "");
|
|
});
|
|
|
|
var yaml = [
|
|
"- name: \"" + (remark || "VLESS Node") + "\"",
|
|
" type: vless",
|
|
" server: " + serverParts[0],
|
|
" port: " + (parseInt(serverParts[1]) || 443),
|
|
" uuid: " + uuid,
|
|
" udp: true",
|
|
" tls: " + (params.security !== "none" ? "true" : "false"),
|
|
" skip-cert-verify: true"
|
|
];
|
|
|
|
if (params.sni) yaml.push(" servername: " + params.sni);
|
|
if (params.type) yaml.push(" network: " + params.type);
|
|
if (params.flow) yaml.push(" flow: " + params.flow);
|
|
if (params.fp) yaml.push(" client-fingerprint: " + params.fp);
|
|
if (params.security === "reality") {
|
|
yaml.push(" reality-opts:");
|
|
if (params.pbk) yaml.push(" public-key: " + params.pbk);
|
|
if (params.sid) yaml.push(" short-id: " + params.sid);
|
|
}
|
|
if (params.type === "ws") {
|
|
yaml.push(" ws-opts:");
|
|
yaml.push(" path: " + (params.path || "/"));
|
|
if (params.host) yaml.push(" headers:");
|
|
if (params.host) yaml.push(" Host: " + params.host);
|
|
}
|
|
if (params.type === "grpc") {
|
|
yaml.push(" grpc-opts:");
|
|
yaml.push(" grpc-service-name: " + (params.serviceName || ""));
|
|
}
|
|
|
|
return yaml.join("\n");
|
|
}
|
|
|
|
// Trojan
|
|
if (link.indexOf("trojan://") === 0) {
|
|
var body = link.split("#")[0].slice(9);
|
|
var main = body.split("?")[0];
|
|
var query = body.split("?")[1] || "";
|
|
var parts = main.split("@");
|
|
var password = parts[0];
|
|
var serverParts = (parts[1] || "").split(":");
|
|
|
|
var params = {};
|
|
query.split("&").forEach(function (pair) {
|
|
var p = pair.split("=");
|
|
params[p[0]] = decodeURIComponent(p[1] || "");
|
|
});
|
|
|
|
var yaml = [
|
|
"- name: \"" + (remark || "Trojan Node") + "\"",
|
|
" type: trojan",
|
|
" server: " + serverParts[0],
|
|
" port: " + (parseInt(serverParts[1]) || 443),
|
|
" password: " + password,
|
|
" udp: true",
|
|
" sni: " + (params.sni || params.peer || ""),
|
|
" skip-cert-verify: true"
|
|
];
|
|
|
|
if (params.type === "ws") {
|
|
yaml.push(" ws-opts:");
|
|
yaml.push(" path: " + (params.path || "/"));
|
|
if (params.host) yaml.push(" headers:");
|
|
if (params.host) yaml.push(" Host: " + params.host);
|
|
}
|
|
if (params.type === "grpc") {
|
|
yaml.push(" grpc-opts:");
|
|
yaml.push(" grpc-service-name: " + (params.serviceName || ""));
|
|
}
|
|
|
|
return yaml.join("\n");
|
|
}
|
|
|
|
return link;
|
|
}
|
|
|
|
function utf8Base64Decode(str) {
|
|
try {
|
|
var b64 = str.trim().replace(/-/g, "+").replace(/_/g, "/");
|
|
while (b64.length % 4 !== 0) b64 += "=";
|
|
|
|
var binary = atob(b64);
|
|
try {
|
|
if (typeof TextDecoder !== "undefined") {
|
|
var bytes = new Uint8Array(binary.length);
|
|
for (var i = 0; i < binary.length; i++) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
return new TextDecoder("utf-8").decode(bytes);
|
|
}
|
|
|
|
return decodeURIComponent(binary.split("").map(function (c) {
|
|
return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2);
|
|
}).join(""));
|
|
} catch (e) {
|
|
return binary;
|
|
}
|
|
} catch (e) {
|
|
throw new Error("Invalid base64 content: " + e.message);
|
|
}
|
|
}
|
|
|
|
function onInput(event) {
|
|
var target = event.target;
|
|
if (target.matches("[data-action='search-servers']")) {
|
|
state.serverSearch = target.value;
|
|
state.nodePage = 1;
|
|
render();
|
|
|
|
var input = document.querySelector("[data-action='search-servers']");
|
|
if (input) {
|
|
input.focus();
|
|
input.setSelectionRange(target.value.length, target.value.length);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function handleEnableIpv6(actionEl) {
|
|
actionEl.disabled = true;
|
|
try {
|
|
var response = await fetchJson("/api/v1/user/user-add-ipv6-subscription/enable", { method: "POST" });
|
|
var payload = unwrap(response) || {};
|
|
if (payload.auth_data) {
|
|
saveIpv6Token(payload.auth_data);
|
|
state.ipv6AuthToken = getStoredIpv6Token();
|
|
}
|
|
showMessage("IPv6 闂囧牞绲归敓鑺ユ嫲閼达綇鎷烽敓鍊熷礉閿熺晫鏀敓浠嬪Μ閿熺晫鎲绘?..", "success");
|
|
// Try to login to IPv6 account if possible, or just refresh dashboard
|
|
// Since we don't have the password here (state doesn't keep it),
|
|
// the user might need to sync password first or we assume it was synced during creation.
|
|
await loadDashboard(true);
|
|
render();
|
|
} catch (error) {
|
|
showMessage(error.message || "閹炬﹫鎷烽敓鍊熷殲娴犲啴鐓囬敓?, "error");
|
|
} finally {
|
|
actionEl.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function handleSyncIpv6Password(actionEl) {
|
|
actionEl.disabled = true;
|
|
try {
|
|
await fetchJson("/api/v1/user/user-add-ipv6-subscription/sync-password", { method: "POST" });
|
|
showMessage("閹炬牭鎷烽敓鑺ユ嫲閼达綇鎷烽悽鍥︾胺閿?IPv6 闂婃劘顢戦敓?, "success");
|
|
} catch (error) {
|
|
showMessage(error.message || "閿熻棄纭堕柈濠冨暢濮婎垱褰?, "error");
|
|
} finally {
|
|
actionEl.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function loadUplinkMetric() {
|
|
if (state.authToken) {
|
|
return;
|
|
}
|
|
var baseUrl = getMetricsBaseUrl();
|
|
if (!baseUrl) {
|
|
return;
|
|
}
|
|
try {
|
|
var response = await fetch(baseUrl + "/api/metrics/overview", {
|
|
method: "GET",
|
|
headers: {
|
|
"X-Requested-With": "XMLHttpRequest"
|
|
}
|
|
});
|
|
var payload = await response.json();
|
|
var network = (payload && payload.network) || {};
|
|
var tx = Number(network.tx || 0);
|
|
var total = tx;
|
|
if (total > 0) {
|
|
state.uplinkMbps = (total * 8) / 1000000;
|
|
var el = document.getElementById("nebula-hero-metric-stack");
|
|
if (el) {
|
|
el.innerHTML = renderUplinkMetricStack();
|
|
} else if (!state.authToken || !state.user) {
|
|
render();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
state.uplinkMbps = null;
|
|
}
|
|
}
|
|
|
|
function onSubmit(event) {
|
|
var form = event.target;
|
|
if (!form.matches("[data-form]")) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
var formType = form.getAttribute("data-form");
|
|
var data = Object.fromEntries(new FormData(form).entries());
|
|
var submitButton = form.querySelector("[type='submit']");
|
|
|
|
if (submitButton) {
|
|
submitButton.disabled = true;
|
|
}
|
|
|
|
if (formType === "create-ticket") {
|
|
fetchJson("/api/v1/user/ticket/save", {
|
|
method: "POST",
|
|
body: {
|
|
subject: data.subject || "",
|
|
level: data.level || "0",
|
|
message: data.message || ""
|
|
}
|
|
}).then(function () {
|
|
showMessage("閹惧奔绨烽敓鑺ユ嫲閼达綇鎷烽幘鎲嬫嫹", "success");
|
|
state.selectedTicketId = null;
|
|
state.selectedTicket = null;
|
|
return loadDashboard(true);
|
|
}).then(render).catch(function (error) {
|
|
showMessage(error.message || "閹惧奔绨烽敓鏂ゆ嫹棣ユ殥闁絾鍟冲顖涘絿", "error");
|
|
render();
|
|
}).finally(function () {
|
|
if (submitButton) {
|
|
submitButton.disabled = false;
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (formType === "real-name-verification") {
|
|
fetchJson("/api/v1/user/real-name-verification/submit", {
|
|
method: "POST",
|
|
body: {
|
|
real_name: data.real_name || "",
|
|
identity_no: data.identity_no || ""
|
|
}
|
|
}).then(function (response) {
|
|
var payload = unwrap(response) || {};
|
|
var verification = payload.verification || payload;
|
|
state.realNameVerification = verification;
|
|
showMessage(
|
|
verification && verification.status === "approved"
|
|
? "閹芥歇顕嗘嫹闂囧牊鏌熼敓鑺ユ嫲閼淬倧鎷烽張鐑囨嫹閿熸枻鎷?
|
|
: "闂囧牊鏌熼敓浠嬫緰閳儻鎷烽幘宀冨姅閿熶粙鍩堝鐧告嫹闂囧倻鎲洪敓鑺ユ毇閿熻姤浠敓鍊熲敆閿熸枻鎷?,
|
|
"success"
|
|
);
|
|
return loadDashboard(true);
|
|
}).then(render).catch(function (error) {
|
|
showMessage(error.message || "闂囧牊鏌熼敓鏂ゆ嫹绌戝仭濠曡鲸鍟冲顖涘絿", "error");
|
|
render();
|
|
}).finally(function () {
|
|
if (submitButton) {
|
|
submitButton.disabled = false;
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (formType === "change-password") {
|
|
if (data.new_password !== data.confirm_password) {
|
|
showMessage("闁垫繃鏋熷ú濠氣拤閺嬪骏鎷烽敓鏂ゆ嫹姒樺灚鎸呴敓鏂ゆ嫹闁垫繃铏庨敓鏂ゆ嫹閿?, "error");
|
|
if (submitButton) submitButton.disabled = false;
|
|
return;
|
|
}
|
|
fetchJson("/api/v1/user/changePassword", {
|
|
method: "POST",
|
|
body: {
|
|
old_password: data.old_password || "",
|
|
new_password: data.new_password || ""
|
|
}
|
|
}).then(function () {
|
|
showMessage("閹炬牭鎷烽敓鑺ユ嫲閼存牞鈧煉鎷风亸宥忔嫹閹捐京鐣¢幃鍜冩嫹閿?IPv6 閿熸枻鎷烽敓鏂ゆ嫹瀹勬洟鍎曢敓钘夋殠閿熸枻鎷烽敓?, "success");
|
|
form.reset();
|
|
}).catch(function (error) {
|
|
showMessage(error.message || "閹炬牭鎷烽敓浠嬫緰閺嶇鍤庨幉顓燁潽閹?, "error");
|
|
}).finally(function () {
|
|
if (submitButton) {
|
|
submitButton.disabled = false;
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (formType === "forget-password") {
|
|
fetchJson("/api/v1/passport/auth/forget", {
|
|
method: "POST",
|
|
auth: false,
|
|
body: {
|
|
email: data.email,
|
|
email_code: data.email_code,
|
|
password: data.password
|
|
}
|
|
}).then(function () {
|
|
showMessage("閹炬牭鎷烽敓鑺ユ嫲閼淬倧鎷烽摝鍨啂閿熶粙娓归悷姘虫饯閿熻棄鍠楁鍨寘閿熸枻鎷烽敓浠嬵槰閿?, "success");
|
|
state.mode = "login";
|
|
render();
|
|
}).catch(function (error) {
|
|
showMessage(error.message || "閿熻姤鑺遍拕顓熷暢濮婎垱褰?, "error");
|
|
}).finally(function () {
|
|
if (submitButton) {
|
|
submitButton.disabled = false;
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
fetchJson(formType === "register" ? "/api/v1/passport/auth/register" : "/api/v1/passport/auth/login", {
|
|
method: "POST",
|
|
auth: false,
|
|
body: data
|
|
}).then(function (response) {
|
|
var payload = unwrap(response);
|
|
if (!payload || !payload.auth_data) {
|
|
throw new Error("Authentication succeeded but token payload is missing");
|
|
}
|
|
saveToken(payload.auth_data);
|
|
state.authToken = getStoredToken();
|
|
|
|
// Try IPv6 login/register if email/password available
|
|
if (data.email && data.password) {
|
|
var ipv6Email = data.email.replace("@", "-ipv6@");
|
|
var ipv6Action = formType === "register" ? "/api/v1/passport/auth/register" : "/api/v1/passport/auth/login";
|
|
|
|
fetchJson(ipv6Action, {
|
|
method: "POST",
|
|
auth: false,
|
|
body: Object.assign({}, data, { email: ipv6Email })
|
|
}).then(function (ipv6Response) {
|
|
var ipv6Payload = unwrap(ipv6Response);
|
|
if (ipv6Payload && ipv6Payload.auth_data) {
|
|
saveIpv6Token(ipv6Payload.auth_data);
|
|
state.ipv6AuthToken = getStoredIpv6Token();
|
|
}
|
|
}).catch(function () {
|
|
// Ignore IPv6 errors as it might not be enabled yet or fail
|
|
}).finally(function() {
|
|
loadDashboard(true).then(render);
|
|
});
|
|
}
|
|
|
|
showMessage(formType === "register" ? "Account created" : "Signed in", "success");
|
|
return loadDashboard(true);
|
|
}).then(render).catch(function (error) {
|
|
showMessage(error.message || "Unable to continue", "error");
|
|
render();
|
|
}).finally(function () {
|
|
if (submitButton) {
|
|
submitButton.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
function handleResetSecurity(actionEl, isIpv6) {
|
|
showNebulaModal({
|
|
title: "閹芥澘灏庨敓鏂ゆ嫹闂夋拝鎷?,
|
|
content: "閾︽牗顢呴敓浠嬫焼閿熸枻鎷烽摝鍨墯閹儻鎷烽敓鍊熺崷閿熸枻鎷烽敓钘夋缂冮潻鎷烽摝鍨敃鐟炬﹫鎷烽敓鏂ゆ嫹閸ゆ鎷烽敓鏂ゆ嫹缁佸鎷烽棁鍫倒閿熸枻鎷烽崯锝忔嫹閹剧媴鎷锋禒鍐挎嫹閿熸枻鎷烽梿绛愶紦鍥锋嫹闂侇剨鎷烽敓鏂ゆ嫹閸熸宸╅敓鐣岊攼閸嶅函鎷峰銊ㄦ暘闂囧牞绲归敓鏂ゆ嫹閿?,
|
|
confirmText: "閾︽牗鐗氶幁鏇嫹濠娿劏鏁?,
|
|
onConfirm: function () {
|
|
actionEl.disabled = true;
|
|
fetchJson("/api/v1/user/resetSecurity", { method: "GET", ipv6: !!isIpv6 }).then(function (response) {
|
|
var subscribeUrl = unwrap(response);
|
|
var sub = isIpv6 ? state.ipv6Subscribe : state.subscribe;
|
|
if (sub && subscribeUrl) {
|
|
sub.subscribe_url = subscribeUrl;
|
|
}
|
|
showMessage((isIpv6 ? "IPv6 " : "") + "闂囧牞绲归敓浠嬫閺傘倧鎷烽幘宀冨妺閿熷€熸緥閿?, "success");
|
|
render();
|
|
}).catch(function (error) {
|
|
showMessage(error.message || "閿熻姤鑺遍拕顓熷暢濮婎垱褰?, "error");
|
|
render();
|
|
}).finally(function () {
|
|
actionEl.disabled = false;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function handleRemoveSession(actionEl) {
|
|
var sessionId = actionEl.getAttribute("data-session-id");
|
|
if (!sessionId) {
|
|
return;
|
|
}
|
|
|
|
actionEl.disabled = true;
|
|
fetchJson("/api/v1/user/removeActiveSession", {
|
|
method: "POST",
|
|
body: { session_id: sessionId }
|
|
}).then(function () {
|
|
showMessage("Session revoked", "success");
|
|
return loadDashboard(true);
|
|
}).then(render).catch(function (error) {
|
|
showMessage(error.message || "Unable to revoke session", "error");
|
|
render();
|
|
}).finally(function () {
|
|
actionEl.disabled = false;
|
|
});
|
|
}
|
|
|
|
function handleRemoveOtherSessions(actionEl) {
|
|
var sessions = ((state.sessionOverview && state.sessionOverview.sessions) || []).filter(function (session) {
|
|
return !session.is_current;
|
|
});
|
|
|
|
if (!sessions.length) {
|
|
showMessage("No other sessions to sign out.", "success");
|
|
render();
|
|
return;
|
|
}
|
|
|
|
actionEl.disabled = true;
|
|
Promise.all(sessions.map(function (session) {
|
|
return fetchJson("/api/v1/user/removeActiveSession", {
|
|
method: "POST",
|
|
body: { session_id: session.id }
|
|
});
|
|
})).then(function () {
|
|
showMessage("Signed out all other sessions.", "success");
|
|
return loadDashboard(true);
|
|
}).then(render).catch(function (error) {
|
|
showMessage(error.message || "Unable to sign out other sessions", "error");
|
|
render();
|
|
}).finally(function () {
|
|
actionEl.disabled = false;
|
|
});
|
|
}
|
|
|
|
function handleOpenTicketDetail(actionEl) {
|
|
var ticketId = actionEl.getAttribute("data-ticket-id");
|
|
if (!ticketId) {
|
|
return;
|
|
}
|
|
actionEl.disabled = true;
|
|
fetchJson("/api/v1/user/ticket/fetch?id=" + encodeURIComponent(ticketId), { method: "GET" }).then(function (response) {
|
|
state.selectedTicketId = ticketId;
|
|
state.selectedTicket = unwrap(response);
|
|
render();
|
|
}).catch(function (error) {
|
|
showMessage(error.message || "閹惧奔绨烽敓浠嬫腹鐞涘矉鎷烽敓濮愮毠閾﹀憡鍟冲顖涘絿", "error");
|
|
render();
|
|
}).finally(function () {
|
|
actionEl.disabled = false;
|
|
});
|
|
}
|
|
|
|
function handleOpenKnowledgeArticle(actionEl) {
|
|
var articleId = actionEl.getAttribute("data-article-id");
|
|
if (!articleId) {
|
|
return;
|
|
}
|
|
|
|
var article = findKnowledgeArticleById(articleId);
|
|
if (!article) {
|
|
showMessage("Knowledge article not found", "error");
|
|
return;
|
|
}
|
|
|
|
showKnowledgeArticleModal(article);
|
|
}
|
|
|
|
var currentLayout = null; // "loading", "login", "dashboard"
|
|
|
|
function render() {
|
|
if (state.loading) {
|
|
return;
|
|
}
|
|
|
|
var isLoggedIn = !!(state.authToken && state.user);
|
|
var targetLayout = isLoggedIn ? "dashboard" : "login";
|
|
|
|
// If layout type changed (e.g. Login -> Dashboard), do a full shell render
|
|
if (currentLayout !== targetLayout) {
|
|
currentLayout = targetLayout;
|
|
app.innerHTML = isLoggedIn ? renderDashboard() : renderAuth();
|
|
hideLoader();
|
|
return;
|
|
}
|
|
|
|
// If already in Dashboard, only update the content area and sidebar state
|
|
if (isLoggedIn) {
|
|
var usedTraffic = ((state.subscribe && state.subscribe.u) || 0) + ((state.subscribe && state.subscribe.d) || 0);
|
|
var totalTraffic = (state.subscribe && state.subscribe.transfer_enable) || 0;
|
|
var remainingTraffic = Math.max(totalTraffic - usedTraffic, 0);
|
|
var percent = totalTraffic > 0 ? Math.min(100, Math.round((usedTraffic / totalTraffic) * 100)) : 0;
|
|
var stats = Array.isArray(state.stats) ? state.stats : [0, 0, 0];
|
|
var overview = state.sessionOverview || {};
|
|
|
|
var contentArea = document.querySelector(".dashboard-main");
|
|
if (contentArea) {
|
|
contentArea.innerHTML = renderCurrentRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview);
|
|
}
|
|
|
|
var sidebarContainer = document.querySelector(".dashboard-shell");
|
|
if (sidebarContainer) {
|
|
var sidebar = sidebarContainer.querySelector(".dashboard-sidebar");
|
|
if (sidebar) {
|
|
sidebar.innerHTML = renderSidebar(remainingTraffic, usedTraffic, totalTraffic, percent, overview, true);
|
|
}
|
|
}
|
|
} else {
|
|
// For auth page, simple re-render is fine
|
|
app.innerHTML = renderAuth();
|
|
}
|
|
|
|
hideLoader();
|
|
}
|
|
|
|
function renderAuth() {
|
|
return [
|
|
'<div class="app-shell">',
|
|
renderTopbar(false),
|
|
'<div class="auth-grid">',
|
|
'<section class="hero glass-card">',
|
|
'<div class="hero-badge-row"><span class="nebula-pill">' + escapeHtml(theme.description || "") + "</span></div>",
|
|
'<div id="nebula-hero-metric-stack" class="hero-metric-stack">' + renderUplinkMetricStack() + '</div>',
|
|
(theme.config && theme.config.slogan) ? '<p class="hero-custom-line">' + escapeHtml(theme.config.slogan) + "</p>" : "",
|
|
"</section>",
|
|
'<section class="auth-panel glass-card">',
|
|
(theme.config && (theme.config.isRegisterEnabled || state.mode === "forget-password")) ? [
|
|
'<div class="form-tabs">',
|
|
tabButton("login", "閿熶粙顦ㄩ敓?, state.mode === "login"),
|
|
(theme.config && theme.config.isRegisterEnabled) ? tabButton("register", "閻︽粌鍚€閿?, state.mode === "register") : "",
|
|
tabButton("forget-password", "閿熻姤娉敓鑺ユ寘閿熸枻鎷?, state.mode === "forget-password"),
|
|
"</div>"
|
|
].join("") : [
|
|
'<div class="form-tabs">',
|
|
tabButton("login", "閿熶粙顦ㄩ敓?, state.mode === "login"),
|
|
tabButton("forget-password", "閿熻姤娉敓鑺ユ寘閿熸枻鎷?, state.mode === "forget-password"),
|
|
"</div>"
|
|
].join(""),
|
|
renderAuthPanelHeading(),
|
|
renderAuthForm(),
|
|
"</section>",
|
|
"</div>",
|
|
renderRecordFooter(),
|
|
"</div>"
|
|
].join("");
|
|
}
|
|
|
|
function renderDashboard() {
|
|
var usedTraffic = ((state.subscribe && state.subscribe.u) || 0) + ((state.subscribe && state.subscribe.d) || 0);
|
|
var totalTraffic = (state.subscribe && state.subscribe.transfer_enable) || 0;
|
|
var remainingTraffic = Math.max(totalTraffic - usedTraffic, 0);
|
|
var percent = totalTraffic > 0 ? Math.min(100, Math.round((usedTraffic / totalTraffic) * 100)) : 0;
|
|
var stats = Array.isArray(state.stats) ? state.stats : [0, 0, 0];
|
|
var overview = state.sessionOverview || {};
|
|
|
|
return renderDashboardByRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview);
|
|
}
|
|
|
|
function renderTopbar(isLoggedIn) {
|
|
return [
|
|
'<header class="topbar">',
|
|
'<div class="brand">',
|
|
isLoggedIn ? '<button class="menu-toggle" data-action="toggle-sidebar" aria-label="Menu"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg></button>' : '',
|
|
renderBrandMark(),
|
|
'<div><h1>' + escapeHtml(theme.title || "Nebula") + "</h1></div>",
|
|
"</div>",
|
|
'<div class="topbar-actions">',
|
|
renderThemeToggle(),
|
|
isLoggedIn
|
|
? '<button class="btn btn-secondary" data-action="refresh-dashboard">閿熺晫鎲绘?/button><button class="btn btn-ghost" data-action="logout">閿熸枻鎷烽敓鐣岀暏閽傚牊鏆ラ敓?/button>'
|
|
: '',
|
|
"</div>",
|
|
"</header>"
|
|
].join("");
|
|
}
|
|
|
|
function renderDashboardByRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview) {
|
|
return [
|
|
'<div class="app-shell">',
|
|
renderTopbar(true),
|
|
'<div class="sidebar-overlay' + (state.isSidebarOpen ? ' is-visible' : '') + '" data-action="close-sidebar"></div>',
|
|
'<section class="dashboard-shell">',
|
|
renderSidebar(remainingTraffic, usedTraffic, totalTraffic, percent, overview),
|
|
'<div class="dashboard-main">',
|
|
renderCurrentRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview),
|
|
'</div>',
|
|
'</section>',
|
|
renderRecordFooter(),
|
|
'</div>'
|
|
].join("");
|
|
}
|
|
|
|
function renderCurrentRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview) {
|
|
if (state.currentRoute === "access-history") {
|
|
return [
|
|
'<section class="dashboard-grid">',
|
|
renderSessionSection(overview.sessions || []),
|
|
renderIpSection(overview.online_ips || []),
|
|
'</section>'
|
|
].join("");
|
|
}
|
|
if (state.currentRoute === "nodes") {
|
|
return [
|
|
'<section class="dashboard-grid">',
|
|
renderSubscribeSection("span-12 section-card--overview"),
|
|
renderServerSection(state.servers || []),
|
|
'</section>'
|
|
].join("");
|
|
}
|
|
if (state.currentRoute === "ipv6-nodes") {
|
|
return [
|
|
'<section class="dashboard-grid">',
|
|
renderIpv6SubscribeSection("span-12 section-card--overview"),
|
|
renderServerSection(state.ipv6Servers || [], true),
|
|
'</section>'
|
|
].join("");
|
|
}
|
|
if (state.currentRoute === "tickets") {
|
|
return [
|
|
'<section class="dashboard-grid">',
|
|
renderTicketComposer(),
|
|
renderTicketSection(state.tickets || []),
|
|
renderTicketDetailSection(state.selectedTicket),
|
|
'</section>'
|
|
].join("");
|
|
}
|
|
if (state.currentRoute === "real-name") {
|
|
return [
|
|
'<section class="dashboard-grid">',
|
|
renderRealNameVerificationPanel(),
|
|
'</section>'
|
|
].join("");
|
|
}
|
|
if (state.currentRoute === "security") {
|
|
return [
|
|
'<section class="dashboard-grid">',
|
|
renderSecuritySection(),
|
|
'</section>'
|
|
].join("");
|
|
}
|
|
|
|
var html = [
|
|
'<section class="dashboard-grid dashboard-grid--overview">',
|
|
renderTrafficOverviewCard(remainingTraffic, usedTraffic, totalTraffic, percent, "span-12 section-card--overview"),
|
|
renderIpv6TrafficOverviewCard("span-12"),
|
|
renderAccessSnapshotCard(overview, "span-12 section-card--overview"),
|
|
renderRealNameVerificationOverviewCard("span-12 section-card--overview"),
|
|
'</section>'
|
|
].join("");
|
|
return html;
|
|
}
|
|
|
|
function renderLiveUserSurface(remainingTraffic, usedTraffic, totalTraffic, percent, overview) {
|
|
return [
|
|
'<section class="dashboard-hero">',
|
|
'<div class="hero glass-card">',
|
|
|
|
function renderLiveUserSurface(remainingTraffic, usedTraffic, totalTraffic, percent, overview) {
|
|
return [
|
|
'<section class="dashboard-hero">',
|
|
'<div class="hero glass-card">',
|
|
'<span class="nebula-pill">Subscription</span>',
|
|
"<h2>" + escapeHtml(theme.title || "Nebula") + "</h2>",
|
|
"<p>" + escapeHtml((state.subscribe && state.subscribe.plan && state.subscribe.plan.name) || "No active plan") + " 缁?" + escapeHtml((state.user && state.user.email) || "") + "</p>",
|
|
'<div class="dashboard-stat-grid">',
|
|
dashboardStat(formatTraffic(remainingTraffic), "Remaining traffic"),
|
|
dashboardStat(String(overview.online_ip_count || 0), "Current online IPs"),
|
|
dashboardStat(String(overview.active_session_count || 0), "Stored sessions"),
|
|
'</div>',
|
|
'<div class="progress"><span style="width:' + percent + '%"></span></div>',
|
|
'<p class="footer-note">Used ' + formatTraffic(usedTraffic) + ' of ' + formatTraffic(totalTraffic) + ' this cycle.</p>',
|
|
'</div>',
|
|
renderSubscribeSection(),
|
|
'</section>'
|
|
].join("");
|
|
}
|
|
|
|
function renderAccessSnapshot(overview) {
|
|
return [
|
|
'<section class="dashboard-grid">',
|
|
renderAccessSnapshotCard(overview),
|
|
renderOpsSection(Array.isArray(state.stats) ? state.stats : [0, 0, 0], overview),
|
|
'</section>'
|
|
].join("");
|
|
}
|
|
|
|
function renderAccessSnapshotCard(overview, extraClass) {
|
|
extraClass = extraClass || "span-7";
|
|
var ipv6Overview = state.ipv6SessionOverview;
|
|
var limitDisplay = formatLimit(overview.device_limit) + (ipv6Overview ? " / " + formatLimit(ipv6Overview.device_limit) : "");
|
|
return [
|
|
'<div class="section-card glass-card ' + escapeHtml(extraClass) + '">',
|