Merge branch 'feat/yacd-exp' into feat/fe-app-podkop

This commit is contained in:
divocat
2025-10-07 17:16:36 +03:00
66 changed files with 4798 additions and 1373 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
.idea
fe-app-podkop/node_modules
fe-app-podkop/.env

View File

@@ -7,7 +7,7 @@ export default [
js.configs.recommended,
...tseslint.configs.recommended,
{
ignores: ['node_modules'],
ignores: ['node_modules', 'watch-upload.js'],
},
{
rules: {

View File

@@ -10,14 +10,19 @@
"build": "tsup src/main.ts",
"dev": "tsup src/main.ts --watch",
"test": "vitest",
"ci": "yarn format && yarn lint --max-warnings=0 && yarn test --run && yarn build"
"ci": "yarn format && yarn lint --max-warnings=0 && yarn test --run && yarn build",
"watch:sftp": "node watch-upload.js"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "8.45.0",
"@typescript-eslint/parser": "8.45.0",
"chokidar": "4.0.3",
"dotenv": "17.2.3",
"eslint": "9.36.0",
"eslint-config-prettier": "10.1.8",
"glob": "11.0.3",
"prettier": "3.6.2",
"ssh2-sftp-client": "12.0.1",
"tsup": "8.5.0",
"typescript": "5.9.3",
"typescript-eslint": "8.45.0",

View File

@@ -0,0 +1,2 @@
export * from './types';
export * from './methods';

View File

@@ -0,0 +1,28 @@
import { IBaseApiResponse } from '../types';
export async function createBaseApiRequest<T>(
fetchFn: () => Promise<Response>,
): Promise<IBaseApiResponse<T>> {
try {
const response = await fetchFn();
if (!response.ok) {
return {
success: false as const,
message: `${_('HTTP error')} ${response.status}: ${response.statusText}`,
};
}
const data: T = await response.json();
return {
success: true as const,
data,
};
} catch (e) {
return {
success: false as const,
message: e instanceof Error ? e.message : _('Unknown error'),
};
}
}

View File

@@ -0,0 +1,14 @@
import { ClashAPI, IBaseApiResponse } from '../types';
import { createBaseApiRequest } from './createBaseApiRequest';
import { getClashApiUrl } from '../../helpers';
export async function getClashConfig(): Promise<
IBaseApiResponse<ClashAPI.Config>
> {
return createBaseApiRequest<ClashAPI.Config>(() =>
fetch(`${getClashApiUrl()}/configs`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}),
);
}

View File

@@ -0,0 +1,20 @@
import { ClashAPI, IBaseApiResponse } from '../types';
import { createBaseApiRequest } from './createBaseApiRequest';
import { getClashApiUrl } from '../../helpers';
export async function getClashGroupDelay(
group: string,
url = 'https://www.gstatic.com/generate_204',
timeout = 2000,
): Promise<IBaseApiResponse<ClashAPI.Delays>> {
const endpoint = `${getClashApiUrl()}/group/${group}/delay?url=${encodeURIComponent(
url,
)}&timeout=${timeout}`;
return createBaseApiRequest<ClashAPI.Delays>(() =>
fetch(endpoint, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}),
);
}

View File

@@ -0,0 +1,14 @@
import { ClashAPI, IBaseApiResponse } from '../types';
import { createBaseApiRequest } from './createBaseApiRequest';
import { getClashApiUrl } from '../../helpers';
export async function getClashProxies(): Promise<
IBaseApiResponse<ClashAPI.Proxies>
> {
return createBaseApiRequest<ClashAPI.Proxies>(() =>
fetch(`${getClashApiUrl()}/proxies`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}),
);
}

View File

@@ -0,0 +1,14 @@
import { ClashAPI, IBaseApiResponse } from '../types';
import { createBaseApiRequest } from './createBaseApiRequest';
import { getClashApiUrl } from '../../helpers';
export async function getClashVersion(): Promise<
IBaseApiResponse<ClashAPI.Version>
> {
return createBaseApiRequest<ClashAPI.Version>(() =>
fetch(`${getClashApiUrl()}/version`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}),
);
}

View File

@@ -0,0 +1,7 @@
export * from './createBaseApiRequest';
export * from './getConfig';
export * from './getGroupDelay';
export * from './getProxies';
export * from './getVersion';
export * from './triggerProxySelector';
export * from './triggerLatencyTest';

View File

@@ -0,0 +1,35 @@
import { IBaseApiResponse } from '../types';
import { createBaseApiRequest } from './createBaseApiRequest';
import { getClashApiUrl } from '../../helpers';
export async function triggerLatencyGroupTest(
tag: string,
timeout: number = 5000,
url: string = 'https://www.gstatic.com/generate_204',
): Promise<IBaseApiResponse<void>> {
return createBaseApiRequest<void>(() =>
fetch(
`${getClashApiUrl()}/group/${tag}/delay?url=${encodeURIComponent(url)}&timeout=${timeout}`,
{
method: 'GET',
headers: { 'Content-Type': 'application/json' },
},
),
);
}
export async function triggerLatencyProxyTest(
tag: string,
timeout: number = 2000,
url: string = 'https://www.gstatic.com/generate_204',
): Promise<IBaseApiResponse<void>> {
return createBaseApiRequest<void>(() =>
fetch(
`${getClashApiUrl()}/proxies/${tag}/delay?url=${encodeURIComponent(url)}&timeout=${timeout}`,
{
method: 'GET',
headers: { 'Content-Type': 'application/json' },
},
),
);
}

View File

@@ -0,0 +1,16 @@
import { IBaseApiResponse } from '../types';
import { createBaseApiRequest } from './createBaseApiRequest';
import { getClashApiUrl } from '../../helpers';
export async function triggerProxySelector(
selector: string,
outbound: string,
): Promise<IBaseApiResponse<void>> {
return createBaseApiRequest<void>(() =>
fetch(`${getClashApiUrl()}/proxies/${selector}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: outbound }),
}),
);
}

View File

@@ -0,0 +1,53 @@
export type IBaseApiResponse<T> =
| {
success: true;
data: T;
}
| {
success: false;
message: string;
};
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace ClashAPI {
export interface Version {
meta: boolean;
premium: boolean;
version: string;
}
export interface Config {
port: number;
'socks-port': number;
'redir-port': number;
'tproxy-port': number;
'mixed-port': number;
'allow-lan': boolean;
'bind-address': string;
mode: 'Rule' | 'Global' | 'Direct';
'mode-list': string[];
'log-level': 'debug' | 'info' | 'warn' | 'error';
ipv6: boolean;
tun: null | Record<string, unknown>;
}
export interface ProxyHistoryEntry {
time: string;
delay: number;
}
export interface ProxyBase {
type: string;
name: string;
udp: boolean;
history: ProxyHistoryEntry[];
now?: string;
all?: string[];
}
export interface Proxies {
proxies: Record<string, ProxyBase>;
}
export type Delays = Record<string, number>;
}

View File

@@ -1,29 +0,0 @@
interface CopyToClipboardResponse {
success: boolean;
message: string;
}
export function copyToClipboard(text: string): CopyToClipboardResponse {
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
return {
success: true,
message: 'Copied!',
};
} catch (err) {
const error = err as Error;
return {
success: false,
message: `Failed to copy: ${error.message}`,
};
} finally {
document.body.removeChild(textarea);
}
}

View File

@@ -0,0 +1,11 @@
export function getClashApiUrl(): string {
const { protocol, hostname } = window.location;
return `${protocol}//${hostname}:9090`;
}
export function getClashWsUrl(): string {
const { hostname } = window.location;
return `ws://${hostname}:9090`;
}

View File

@@ -0,0 +1,13 @@
export function getProxyUrlName(url: string) {
try {
const [_link, hash] = url.split('#');
if (!hash) {
return '';
}
return decodeURIComponent(hash);
} catch {
return '';
}
}

View File

@@ -3,5 +3,7 @@ export * from './parseValueList';
export * from './injectGlobalStyles';
export * from './withTimeout';
export * from './executeShellCommand';
export * from './copyToClipboard';
export * from './maskIP';
export * from './getProxyUrlName';
export * from './onMount';
export * from './getClashApiUrl';

View File

@@ -0,0 +1,30 @@
export async function onMount(id: string): Promise<HTMLElement> {
return new Promise((resolve) => {
const el = document.getElementById(id);
if (el && el.offsetParent !== null) {
return resolve(el);
}
const observer = new MutationObserver(() => {
const target = document.getElementById(id);
if (target) {
const io = new IntersectionObserver((entries) => {
const visible = entries.some((e) => e.isIntersecting);
if (visible) {
observer.disconnect();
io.disconnect();
resolve(target);
}
});
io.observe(target);
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
});
}

View File

@@ -0,0 +1,12 @@
// steal from https://github.com/sindresorhus/pretty-bytes/blob/master/index.js
export function prettyBytes(n: number) {
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
if (n < 1000) {
return n + ' B';
}
const exponent = Math.min(Math.floor(Math.log10(n) / 3), UNITS.length - 1);
n = Number((n / Math.pow(1000, exponent)).toPrecision(3));
const unit = UNITS[exponent];
return n + ' ' + unit;
}

View File

@@ -2,7 +2,7 @@ export async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
operationName: string,
timeoutMessage = 'Operation timed out',
timeoutMessage = _('Operation timed out'),
): Promise<T> {
let timeoutId;
const start = performance.now();

View File

@@ -1,3 +1,15 @@
type HtmlTag = keyof HTMLElementTagNameMap;
type HtmlElement<T extends HtmlTag> = HTMLElementTagNameMap[T];
type HtmlAttributes<T extends HtmlTag = 'div'> = Partial<
Omit<HtmlElement<T>, 'style' | 'children'> & {
style?: string | Partial<CSSStyleDeclaration>;
class?: string;
onclick?: (event: MouseEvent) => void;
}
>;
declare global {
const fs: {
exec(
@@ -10,6 +22,19 @@ declare global {
code?: number;
}>;
};
const E: <T extends HtmlTag>(
type: T,
attr?: HtmlAttributes<T> | null,
children?: (Node | string)[] | Node | string,
) => HTMLElementTagNameMap[T];
const uci: {
load: (packages: string | string[]) => Promise<string>;
sections: (conf: string, type?: string, cb?: () => void) => Promise<T>;
};
const _ = (_key: string) => string;
}
export {};

View File

@@ -1,7 +1,10 @@
'use strict';
'require baseclass';
'require fs';
'require uci';
export * from './validators';
export * from './helpers';
export * from './clash';
export * from './podkop';
export * from './constants';

View File

@@ -0,0 +1,3 @@
export * from './methods';
export * from './services';
export * from './tabs';

View File

@@ -0,0 +1,5 @@
import { Podkop } from '../types';
export async function getConfigSections(): Promise<Podkop.ConfigSection[]> {
return uci.load('podkop').then(() => uci.sections('podkop'));
}

View File

@@ -0,0 +1,153 @@
import { Podkop } from '../types';
import { getConfigSections } from './getConfigSections';
import { getClashProxies } from '../../clash';
import { getProxyUrlName } from '../../helpers';
interface IGetDashboardSectionsResponse {
success: boolean;
data: Podkop.OutboundGroup[];
}
export async function getDashboardSections(): Promise<IGetDashboardSectionsResponse> {
const configSections = await getConfigSections();
const clashProxies = await getClashProxies();
if (!clashProxies.success) {
return {
success: false,
data: [],
};
}
const proxies = Object.entries(clashProxies.data.proxies).map(
([key, value]) => ({
code: key,
value,
}),
);
const data = configSections
.filter((section) => section.mode !== 'block')
.map((section) => {
if (section.mode === 'proxy') {
if (section.proxy_config_type === 'url') {
const outbound = proxies.find(
(proxy) => proxy.code === `${section['.name']}-out`,
);
return {
withTagSelect: false,
code: outbound?.code || section['.name'],
displayName: section['.name'],
outbounds: [
{
code: outbound?.code || section['.name'],
displayName:
getProxyUrlName(section.proxy_string) ||
outbound?.value?.name ||
'',
latency: outbound?.value?.history?.[0]?.delay || 0,
type: outbound?.value?.type || '',
selected: true,
},
],
};
}
if (section.proxy_config_type === 'outbound') {
const outbound = proxies.find(
(proxy) => proxy.code === `${section['.name']}-out`,
);
return {
withTagSelect: false,
code: outbound?.code || section['.name'],
displayName: section['.name'],
outbounds: [
{
code: outbound?.code || section['.name'],
displayName:
decodeURIComponent(JSON.parse(section.outbound_json)?.tag) ||
outbound?.value?.name ||
'',
latency: outbound?.value?.history?.[0]?.delay || 0,
type: outbound?.value?.type || '',
selected: true,
},
],
};
}
if (section.proxy_config_type === 'urltest') {
const selector = proxies.find(
(proxy) => proxy.code === `${section['.name']}-out`,
);
const outbound = proxies.find(
(proxy) => proxy.code === `${section['.name']}-urltest-out`,
);
const outbounds = (outbound?.value?.all ?? [])
.map((code) => proxies.find((item) => item.code === code))
.map((item, index) => ({
code: item?.code || '',
displayName:
getProxyUrlName(section.urltest_proxy_links?.[index]) ||
item?.value?.name ||
'',
latency: item?.value?.history?.[0]?.delay || 0,
type: item?.value?.type || '',
selected: selector?.value?.now === item?.code,
}));
return {
withTagSelect: true,
code: selector?.code || section['.name'],
displayName: section['.name'],
outbounds: [
{
code: outbound?.code || '',
displayName: _('Fastest'),
latency: outbound?.value?.history?.[0]?.delay || 0,
type: outbound?.value?.type || '',
selected: selector?.value?.now === outbound?.code,
},
...outbounds,
],
};
}
}
if (section.mode === 'vpn') {
const outbound = proxies.find(
(proxy) => proxy.code === `${section['.name']}-out`,
);
return {
withTagSelect: false,
code: outbound?.code || section['.name'],
displayName: section['.name'],
outbounds: [
{
code: outbound?.code || section['.name'],
displayName: section.interface || outbound?.value?.name || '',
latency: outbound?.value?.history?.[0]?.delay || 0,
type: outbound?.value?.type || '',
selected: true,
},
],
};
}
return {
withTagSelect: false,
code: section['.name'],
displayName: section['.name'],
outbounds: [],
};
});
return {
success: true,
data,
};
}

View File

@@ -0,0 +1,21 @@
import { executeShellCommand } from '../../helpers';
export async function getPodkopStatus(): Promise<{
enabled: number;
status: string;
}> {
const response = await executeShellCommand({
command: '/usr/bin/podkop',
args: ['get_status'],
timeout: 1000,
});
if (response.stdout) {
return JSON.parse(response.stdout.replace(/\n/g, '')) as {
enabled: number;
status: string;
};
}
return { enabled: 0, status: 'unknown' };
}

View File

@@ -0,0 +1,23 @@
import { executeShellCommand } from '../../helpers';
export async function getSingboxStatus(): Promise<{
running: number;
enabled: number;
status: string;
}> {
const response = await executeShellCommand({
command: '/usr/bin/podkop',
args: ['get_sing_box_status'],
timeout: 1000,
});
if (response.stdout) {
return JSON.parse(response.stdout.replace(/\n/g, '')) as {
running: number;
enabled: number;
status: string;
};
}
return { running: 0, enabled: 0, status: 'unknown' };
}

View File

@@ -0,0 +1,4 @@
export * from './getConfigSections';
export * from './getDashboardSections';
export * from './getPodkopStatus';
export * from './getSingboxStatus';

View File

@@ -0,0 +1,13 @@
import { TabServiceInstance } from './tab.service';
import { store } from '../../store';
export function coreService() {
TabServiceInstance.onChange((activeId, tabs) => {
store.set({
tabService: {
current: activeId || '',
all: tabs.map((tab) => tab.id),
},
});
});
}

View File

@@ -0,0 +1,2 @@
export * from './tab.service';
export * from './core.service';

View File

@@ -0,0 +1,92 @@
type TabInfo = {
el: HTMLElement;
id: string;
active: boolean;
};
type TabChangeCallback = (activeId: string | null, allTabs: TabInfo[]) => void;
export class TabService {
private static instance: TabService;
private observer: MutationObserver | null = null;
private callback?: TabChangeCallback;
private lastActiveId: string | null = null;
private constructor() {
this.init();
}
public static getInstance(): TabService {
if (!TabService.instance) {
TabService.instance = new TabService();
}
return TabService.instance;
}
private init() {
this.observer = new MutationObserver(() => this.handleMutations());
this.observer.observe(document.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['class'],
});
// initial check
this.notify();
}
private handleMutations() {
this.notify();
}
private getTabsInfo(): TabInfo[] {
const tabs = Array.from(
document.querySelectorAll<HTMLElement>('.cbi-tab, .cbi-tab-disabled'),
);
return tabs.map((el) => ({
el,
id: el.dataset.tab || '',
active:
el.classList.contains('cbi-tab') &&
!el.classList.contains('cbi-tab-disabled'),
}));
}
private getActiveTabId(): string | null {
const active = document.querySelector<HTMLElement>(
'.cbi-tab:not(.cbi-tab-disabled)',
);
return active?.dataset.tab || null;
}
private notify() {
const tabs = this.getTabsInfo();
const activeId = this.getActiveTabId();
if (activeId !== this.lastActiveId) {
this.lastActiveId = activeId;
this.callback?.(activeId, tabs);
}
}
public onChange(callback: TabChangeCallback) {
this.callback = callback;
this.notify();
}
public getAllTabs(): TabInfo[] {
return this.getTabsInfo();
}
public getActiveTab(): string | null {
return this.getActiveTabId();
}
public disconnect() {
this.observer?.disconnect();
this.observer = null;
}
}
export const TabServiceInstance = TabService.getInstance();

View File

@@ -0,0 +1,2 @@
export * from './renderDashboard';
export * from './initDashboardController';

View File

@@ -0,0 +1,393 @@
import {
getDashboardSections,
getPodkopStatus,
getSingboxStatus,
} from '../../methods';
import { getClashWsUrl, onMount } from '../../../helpers';
import {
triggerLatencyGroupTest,
triggerLatencyProxyTest,
triggerProxySelector,
} from '../../../clash';
import { store, StoreType } from '../../../store';
import { socket } from '../../../socket';
import { prettyBytes } from '../../../helpers/prettyBytes';
import { renderSections } from './renderSections';
import { renderWidget } from './renderWidget';
// Fetchers
async function fetchDashboardSections() {
const prev = store.get().sectionsWidget;
store.set({
sectionsWidget: {
...prev,
failed: false,
},
});
const { data, success } = await getDashboardSections();
store.set({
sectionsWidget: {
loading: false,
failed: !success,
data,
},
});
}
async function fetchServicesInfo() {
const [podkop, singbox] = await Promise.all([
getPodkopStatus(),
getSingboxStatus(),
]);
store.set({
servicesInfoWidget: {
loading: false,
failed: false,
data: { singbox: singbox.running, podkop: podkop.enabled },
},
});
}
async function connectToClashSockets() {
socket.subscribe(
`${getClashWsUrl()}/traffic?token=`,
(msg) => {
const parsedMsg = JSON.parse(msg);
store.set({
bandwidthWidget: {
loading: false,
failed: false,
data: { up: parsedMsg.up, down: parsedMsg.down },
},
});
},
(_err) => {
store.set({
bandwidthWidget: {
loading: false,
failed: true,
data: { up: 0, down: 0 },
},
});
},
);
socket.subscribe(
`${getClashWsUrl()}/connections?token=`,
(msg) => {
const parsedMsg = JSON.parse(msg);
store.set({
trafficTotalWidget: {
loading: false,
failed: false,
data: {
downloadTotal: parsedMsg.downloadTotal,
uploadTotal: parsedMsg.uploadTotal,
},
},
systemInfoWidget: {
loading: false,
failed: false,
data: {
connections: parsedMsg.connections?.length,
memory: parsedMsg.memory,
},
},
});
},
(_err) => {
store.set({
trafficTotalWidget: {
loading: false,
failed: true,
data: { downloadTotal: 0, uploadTotal: 0 },
},
systemInfoWidget: {
loading: false,
failed: true,
data: {
connections: 0,
memory: 0,
},
},
});
},
);
}
// Handlers
async function handleChooseOutbound(selector: string, tag: string) {
await triggerProxySelector(selector, tag);
await fetchDashboardSections();
}
async function handleTestGroupLatency(tag: string) {
await triggerLatencyGroupTest(tag);
await fetchDashboardSections();
}
async function handleTestProxyLatency(tag: string) {
await triggerLatencyProxyTest(tag);
await fetchDashboardSections();
}
function replaceTestLatencyButtonsWithSkeleton() {
document
.querySelectorAll('.dashboard-sections-grid-item-test-latency')
.forEach((el) => {
const newDiv = document.createElement('div');
newDiv.className = 'skeleton';
newDiv.style.width = '99px';
newDiv.style.height = '28px';
el.replaceWith(newDiv);
});
}
// Renderer
async function renderSectionsWidget() {
console.log('renderSectionsWidget');
const sectionsWidget = store.get().sectionsWidget;
const container = document.getElementById('dashboard-sections-grid');
if (sectionsWidget.loading || sectionsWidget.failed) {
const renderedWidget = renderSections({
loading: sectionsWidget.loading,
failed: sectionsWidget.failed,
section: {
code: '',
displayName: '',
outbounds: [],
withTagSelect: false,
},
onTestLatency: () => {},
onChooseOutbound: () => {},
});
return container!.replaceChildren(renderedWidget);
}
const renderedWidgets = sectionsWidget.data.map((section) =>
renderSections({
loading: sectionsWidget.loading,
failed: sectionsWidget.failed,
section,
onTestLatency: (tag) => {
replaceTestLatencyButtonsWithSkeleton();
if (section.withTagSelect) {
return handleTestGroupLatency(tag);
}
return handleTestProxyLatency(tag);
},
onChooseOutbound: (selector, tag) => {
handleChooseOutbound(selector, tag);
},
}),
);
return container!.replaceChildren(...renderedWidgets);
}
async function renderBandwidthWidget() {
console.log('renderBandwidthWidget');
const traffic = store.get().bandwidthWidget;
const container = document.getElementById('dashboard-widget-traffic');
if (traffic.loading || traffic.failed) {
const renderedWidget = renderWidget({
loading: traffic.loading,
failed: traffic.failed,
title: '',
items: [],
});
return container!.replaceChildren(renderedWidget);
}
const renderedWidget = renderWidget({
loading: traffic.loading,
failed: traffic.failed,
title: _('Traffic'),
items: [
{ key: _('Uplink'), value: `${prettyBytes(traffic.data.up)}/s` },
{ key: _('Downlink'), value: `${prettyBytes(traffic.data.down)}/s` },
],
});
container!.replaceChildren(renderedWidget);
}
async function renderTrafficTotalWidget() {
console.log('renderTrafficTotalWidget');
const trafficTotalWidget = store.get().trafficTotalWidget;
const container = document.getElementById('dashboard-widget-traffic-total');
if (trafficTotalWidget.loading || trafficTotalWidget.failed) {
const renderedWidget = renderWidget({
loading: trafficTotalWidget.loading,
failed: trafficTotalWidget.failed,
title: '',
items: [],
});
return container!.replaceChildren(renderedWidget);
}
const renderedWidget = renderWidget({
loading: trafficTotalWidget.loading,
failed: trafficTotalWidget.failed,
title: _('Traffic Total'),
items: [
{
key: _('Uplink'),
value: String(prettyBytes(trafficTotalWidget.data.uploadTotal)),
},
{
key: _('Downlink'),
value: String(prettyBytes(trafficTotalWidget.data.downloadTotal)),
},
],
});
container!.replaceChildren(renderedWidget);
}
async function renderSystemInfoWidget() {
console.log('renderSystemInfoWidget');
const systemInfoWidget = store.get().systemInfoWidget;
const container = document.getElementById('dashboard-widget-system-info');
if (systemInfoWidget.loading || systemInfoWidget.failed) {
const renderedWidget = renderWidget({
loading: systemInfoWidget.loading,
failed: systemInfoWidget.failed,
title: '',
items: [],
});
return container!.replaceChildren(renderedWidget);
}
const renderedWidget = renderWidget({
loading: systemInfoWidget.loading,
failed: systemInfoWidget.failed,
title: _('System info'),
items: [
{
key: _('Active Connections'),
value: String(systemInfoWidget.data.connections),
},
{
key: _('Memory Usage'),
value: String(prettyBytes(systemInfoWidget.data.memory)),
},
],
});
container!.replaceChildren(renderedWidget);
}
async function renderServicesInfoWidget() {
console.log('renderServicesInfoWidget');
const servicesInfoWidget = store.get().servicesInfoWidget;
const container = document.getElementById('dashboard-widget-service-info');
if (servicesInfoWidget.loading || servicesInfoWidget.failed) {
const renderedWidget = renderWidget({
loading: servicesInfoWidget.loading,
failed: servicesInfoWidget.failed,
title: '',
items: [],
});
return container!.replaceChildren(renderedWidget);
}
const renderedWidget = renderWidget({
loading: servicesInfoWidget.loading,
failed: servicesInfoWidget.failed,
title: _('Services info'),
items: [
{
key: _('Podkop'),
value: servicesInfoWidget.data.podkop
? _('✔ Enabled')
: _('✘ Disabled'),
attributes: {
class: servicesInfoWidget.data.podkop
? 'pdk_dashboard-page__widgets-section__item__row--success'
: 'pdk_dashboard-page__widgets-section__item__row--error',
},
},
{
key: _('Sing-box'),
value: servicesInfoWidget.data.singbox
? _('✔ Running')
: _('✘ Stopped'),
attributes: {
class: servicesInfoWidget.data.singbox
? 'pdk_dashboard-page__widgets-section__item__row--success'
: 'pdk_dashboard-page__widgets-section__item__row--error',
},
},
],
});
container!.replaceChildren(renderedWidget);
}
async function onStoreUpdate(
next: StoreType,
prev: StoreType,
diff: Partial<StoreType>,
) {
if (diff.sectionsWidget) {
renderSectionsWidget();
}
if (diff.bandwidthWidget) {
renderBandwidthWidget();
}
if (diff.trafficTotalWidget) {
renderTrafficTotalWidget();
}
if (diff.systemInfoWidget) {
renderSystemInfoWidget();
}
if (diff.servicesInfoWidget) {
renderServicesInfoWidget();
}
}
export async function initDashboardController(): Promise<void> {
onMount('dashboard-status').then(() => {
// Remove old listener
store.unsubscribe(onStoreUpdate);
// Clear store
store.reset();
// Add new listener
store.subscribe(onStoreUpdate);
// Initial sections fetch
fetchDashboardSections();
fetchServicesInfo();
connectToClashSockets();
});
}

View File

@@ -0,0 +1,54 @@
import { renderSections } from './renderSections';
import { renderWidget } from './renderWidget';
export function renderDashboard() {
return E(
'div',
{
id: 'dashboard-status',
class: 'pdk_dashboard-page',
},
[
// Widgets section
E('div', { class: 'pdk_dashboard-page__widgets-section' }, [
E(
'div',
{ id: 'dashboard-widget-traffic' },
renderWidget({ loading: true, failed: false, title: '', items: [] }),
),
E(
'div',
{ id: 'dashboard-widget-traffic-total' },
renderWidget({ loading: true, failed: false, title: '', items: [] }),
),
E(
'div',
{ id: 'dashboard-widget-system-info' },
renderWidget({ loading: true, failed: false, title: '', items: [] }),
),
E(
'div',
{ id: 'dashboard-widget-service-info' },
renderWidget({ loading: true, failed: false, title: '', items: [] }),
),
]),
// All outbounds
E(
'div',
{ id: 'dashboard-sections-grid' },
renderSections({
loading: true,
failed: false,
section: {
code: '',
displayName: '',
outbounds: [],
withTagSelect: false,
},
onTestLatency: () => {},
onChooseOutbound: () => {},
}),
),
],
);
}

View File

@@ -0,0 +1,125 @@
import { Podkop } from '../../types';
interface IRenderSectionsProps {
loading: boolean;
failed: boolean;
section: Podkop.OutboundGroup;
onTestLatency: (tag: string) => void;
onChooseOutbound: (selector: string, tag: string) => void;
}
function renderFailedState() {
return E(
'div',
{
class: 'pdk_dashboard-page__outbound-section centered',
style: 'height: 127px',
},
E('span', {}, _('Dashboard currently unavailable')),
);
}
function renderLoadingState() {
return E('div', {
id: 'dashboard-sections-grid-skeleton',
class: 'pdk_dashboard-page__outbound-section skeleton',
style: 'height: 127px',
});
}
export function renderDefaultState({
section,
onChooseOutbound,
onTestLatency,
}: IRenderSectionsProps) {
function testLatency() {
if (section.withTagSelect) {
return onTestLatency(section.code);
}
if (section.outbounds.length) {
return onTestLatency(section.outbounds[0].code);
}
}
function renderOutbound(outbound: Podkop.Outbound) {
function getLatencyClass() {
if (!outbound.latency) {
return 'pdk_dashboard-page__outbound-grid__item__latency--empty';
}
if (outbound.latency < 200) {
return 'pdk_dashboard-page__outbound-grid__item__latency--green';
}
if (outbound.latency < 400) {
return 'pdk_dashboard-page__outbound-grid__item__latency--yellow';
}
return 'pdk_dashboard-page__outbound-grid__item__latency--red';
}
return E(
'div',
{
class: `pdk_dashboard-page__outbound-grid__item ${outbound.selected ? 'pdk_dashboard-page__outbound-grid__item--active' : ''} ${section.withTagSelect ? 'pdk_dashboard-page__outbound-grid__item--selectable' : ''}`,
click: () =>
section.withTagSelect &&
onChooseOutbound(section.code, outbound.code),
},
[
E('b', {}, outbound.displayName),
E('div', { class: 'pdk_dashboard-page__outbound-grid__item__footer' }, [
E(
'div',
{ class: 'pdk_dashboard-page__outbound-grid__item__type' },
outbound.type,
),
E(
'div',
{ class: getLatencyClass() },
outbound.latency ? `${outbound.latency}ms` : 'N/A',
),
]),
],
);
}
return E('div', { class: 'pdk_dashboard-page__outbound-section' }, [
// Title with test latency
E('div', { class: 'pdk_dashboard-page__outbound-section__title-section' }, [
E(
'div',
{
class: 'pdk_dashboard-page__outbound-section__title-section__title',
},
section.displayName,
),
E(
'button',
{
class: 'btn dashboard-sections-grid-item-test-latency',
click: () => testLatency(),
},
'Test latency',
),
]),
E(
'div',
{ class: 'pdk_dashboard-page__outbound-grid' },
section.outbounds.map((outbound) => renderOutbound(outbound)),
),
]);
}
export function renderSections(props: IRenderSectionsProps) {
if (props.failed) {
return renderFailedState();
}
if (props.loading) {
return renderLoadingState();
}
return renderDefaultState(props);
}

View File

@@ -0,0 +1,78 @@
interface IRenderWidgetProps {
loading: boolean;
failed: boolean;
title: string;
items: Array<{
key: string;
value: string;
attributes?: {
class?: string;
};
}>;
}
function renderFailedState() {
return E(
'div',
{
id: '',
style: 'height: 78px',
class: 'pdk_dashboard-page__widgets-section__item centered',
},
_('Currently unavailable'),
);
}
function renderLoadingState() {
return E(
'div',
{
id: '',
style: 'height: 78px',
class: 'pdk_dashboard-page__widgets-section__item skeleton',
},
'',
);
}
function renderDefaultState({ title, items }: IRenderWidgetProps) {
return E('div', { class: 'pdk_dashboard-page__widgets-section__item' }, [
E(
'b',
{ class: 'pdk_dashboard-page__widgets-section__item__title' },
title,
),
...items.map((item) =>
E(
'div',
{
class: `pdk_dashboard-page__widgets-section__item__row ${item?.attributes?.class || ''}`,
},
[
E(
'span',
{ class: 'pdk_dashboard-page__widgets-section__item__row__key' },
`${item.key}: `,
),
E(
'span',
{ class: 'pdk_dashboard-page__widgets-section__item__row__value' },
item.value,
),
],
),
),
]);
}
export function renderWidget(props: IRenderWidgetProps) {
if (props.loading) {
return renderLoadingState();
}
if (props.failed) {
return renderFailedState();
}
return renderDefaultState(props);
}

View File

@@ -0,0 +1 @@
export * from './dashboard';

View File

@@ -0,0 +1,56 @@
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace Podkop {
export interface Outbound {
code: string;
displayName: string;
latency: number;
type: string;
selected: boolean;
}
export interface OutboundGroup {
withTagSelect: boolean;
code: string;
displayName: string;
outbounds: Outbound[];
}
export interface ConfigProxyUrlTestSection {
mode: 'proxy';
proxy_config_type: 'urltest';
urltest_proxy_links: string[];
}
export interface ConfigProxyUrlSection {
mode: 'proxy';
proxy_config_type: 'url';
proxy_string: string;
}
export interface ConfigProxyOutboundSection {
mode: 'proxy';
proxy_config_type: 'outbound';
outbound_json: string;
}
export interface ConfigVpnSection {
mode: 'vpn';
interface: string;
}
export interface ConfigBlockSection {
mode: 'block';
}
export type ConfigBaseSection =
| ConfigProxyUrlTestSection
| ConfigProxyUrlSection
| ConfigProxyOutboundSection
| ConfigVpnSection
| ConfigBlockSection;
export type ConfigSection = ConfigBaseSection & {
'.name': string;
'.type': 'main' | 'extra';
};
}

121
fe-app-podkop/src/socket.ts Normal file
View File

@@ -0,0 +1,121 @@
// eslint-disable-next-line
type Listener = (data: any) => void;
type ErrorListener = (error: Event | string) => void;
class SocketManager {
private static instance: SocketManager;
private sockets = new Map<string, WebSocket>();
private listeners = new Map<string, Set<Listener>>();
private connected = new Map<string, boolean>();
private errorListeners = new Map<string, Set<ErrorListener>>();
private constructor() {}
static getInstance(): SocketManager {
if (!SocketManager.instance) {
SocketManager.instance = new SocketManager();
}
return SocketManager.instance;
}
connect(url: string): void {
if (this.sockets.has(url)) return;
const ws = new WebSocket(url);
this.sockets.set(url, ws);
this.connected.set(url, false);
this.listeners.set(url, new Set());
this.errorListeners.set(url, new Set());
ws.addEventListener('open', () => {
this.connected.set(url, true);
console.info(`Connected: ${url}`);
});
ws.addEventListener('message', (event) => {
const handlers = this.listeners.get(url);
if (handlers) {
for (const handler of handlers) {
try {
handler(event.data);
} catch (err) {
console.error(`Handler error for ${url}:`, err);
}
}
}
});
ws.addEventListener('close', () => {
this.connected.set(url, false);
console.warn(`Disconnected: ${url}`);
this.triggerError(url, 'Connection closed');
});
ws.addEventListener('error', (err) => {
console.error(`Socket error for ${url}:`, err);
this.triggerError(url, err);
});
}
subscribe(url: string, listener: Listener, onError?: ErrorListener): void {
if (!this.sockets.has(url)) {
this.connect(url);
}
this.listeners.get(url)?.add(listener);
if (onError) {
this.errorListeners.get(url)?.add(onError);
}
}
unsubscribe(url: string, listener: Listener, onError?: ErrorListener): void {
this.listeners.get(url)?.delete(listener);
if (onError) {
this.errorListeners.get(url)?.delete(onError);
}
}
// eslint-disable-next-line
send(url: string, data: any): void {
const ws = this.sockets.get(url);
if (ws && this.connected.get(url)) {
ws.send(typeof data === 'string' ? data : JSON.stringify(data));
} else {
console.warn(`Cannot send: not connected to ${url}`);
this.triggerError(url, 'Not connected');
}
}
disconnect(url: string): void {
const ws = this.sockets.get(url);
if (ws) {
ws.close();
this.sockets.delete(url);
this.listeners.delete(url);
this.errorListeners.delete(url);
this.connected.delete(url);
}
}
disconnectAll(): void {
for (const url of this.sockets.keys()) {
this.disconnect(url);
}
}
private triggerError(url: string, err: Event | string): void {
const handlers = this.errorListeners.get(url);
if (handlers) {
for (const cb of handlers) {
try {
cb(err);
} catch (e) {
console.error(`Error handler threw for ${url}:`, e);
}
}
}
}
}
export const socket = SocketManager.getInstance();

179
fe-app-podkop/src/store.ts Normal file
View File

@@ -0,0 +1,179 @@
import { Podkop } from './podkop/types';
function jsonStableStringify<T, V>(obj: T): string {
return JSON.stringify(obj, (_, value) => {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return Object.keys(value)
.sort()
.reduce(
(acc, key) => {
acc[key] = value[key];
return acc;
},
{} as Record<string, V>,
);
}
return value;
});
}
function jsonEqual<A, B>(a: A, b: B): boolean {
try {
return jsonStableStringify(a) === jsonStableStringify(b);
} catch {
return false;
}
}
type Listener<T> = (next: T, prev: T, diff: Partial<T>) => void;
// eslint-disable-next-line
class Store<T extends Record<string, any>> {
private value: T;
private readonly initial: T;
private listeners = new Set<Listener<T>>();
private lastHash = '';
constructor(initial: T) {
this.value = initial;
this.initial = structuredClone(initial);
this.lastHash = jsonStableStringify(initial);
}
get(): T {
return this.value;
}
set(next: Partial<T>): void {
const prev = this.value;
const merged = { ...prev, ...next };
if (jsonEqual(prev, merged)) return;
this.value = merged;
this.lastHash = jsonStableStringify(merged);
const diff: Partial<T> = {};
for (const key in merged) {
if (!jsonEqual(merged[key], prev[key])) diff[key] = merged[key];
}
this.listeners.forEach((cb) => cb(this.value, prev, diff));
}
reset(): void {
const prev = this.value;
const next = structuredClone(this.initial);
if (jsonEqual(prev, next)) return;
this.value = next;
this.lastHash = jsonStableStringify(next);
const diff: Partial<T> = {};
for (const key in next) {
if (!jsonEqual(next[key], prev[key])) diff[key] = next[key];
}
this.listeners.forEach((cb) => cb(this.value, prev, diff));
}
subscribe(cb: Listener<T>): () => void {
this.listeners.add(cb);
cb(this.value, this.value, {});
return () => this.listeners.delete(cb);
}
unsubscribe(cb: Listener<T>): void {
this.listeners.delete(cb);
}
patch<K extends keyof T>(key: K, value: T[K]): void {
this.set({ [key]: value } as unknown as Partial<T>);
}
getKey<K extends keyof T>(key: K): T[K] {
return this.value[key];
}
subscribeKey<K extends keyof T>(
key: K,
cb: (value: T[K]) => void,
): () => void {
let prev = this.value[key];
const wrapper: Listener<T> = (val) => {
if (!jsonEqual(val[key], prev)) {
prev = val[key];
cb(val[key]);
}
};
this.listeners.add(wrapper);
return () => this.listeners.delete(wrapper);
}
}
export interface StoreType {
tabService: {
current: string;
all: string[];
};
bandwidthWidget: {
loading: boolean;
failed: boolean;
data: { up: number; down: number };
};
trafficTotalWidget: {
loading: boolean;
failed: boolean;
data: { downloadTotal: number; uploadTotal: number };
};
systemInfoWidget: {
loading: boolean;
failed: boolean;
data: { connections: number; memory: number };
};
servicesInfoWidget: {
loading: boolean;
failed: boolean;
data: { singbox: number; podkop: number };
};
sectionsWidget: {
loading: boolean;
failed: boolean;
data: Podkop.OutboundGroup[];
};
}
const initialStore: StoreType = {
tabService: {
current: '',
all: [],
},
bandwidthWidget: {
loading: true,
failed: false,
data: { up: 0, down: 0 },
},
trafficTotalWidget: {
loading: true,
failed: false,
data: { downloadTotal: 0, uploadTotal: 0 },
},
systemInfoWidget: {
loading: true,
failed: false,
data: { connections: 0, memory: 0 },
},
servicesInfoWidget: {
loading: true,
failed: false,
data: { singbox: 0, podkop: 0 },
},
sectionsWidget: {
loading: true,
failed: false,
data: [],
},
};
export const store = new Store<StoreType>(initialStore);

View File

@@ -23,4 +23,155 @@ export const GlobalStyles = `
#cbi-podkop:has(.cbi-tab-disabled[data-tab="basic"]) #cbi-podkop-extra {
display: none;
}
#cbi-podkop-main-_status > div {
width: 100%;
}
/* Dashboard styles */
.pdk_dashboard-page {
width: 100%;
--dashboard-grid-columns: 4;
}
@media (max-width: 900px) {
.pdk_dashboard-page {
--dashboard-grid-columns: 2;
}
}
.pdk_dashboard-page__widgets-section {
margin-top: 10px;
display: grid;
grid-template-columns: repeat(var(--dashboard-grid-columns), 1fr);
grid-gap: 10px;
}
.pdk_dashboard-page__widgets-section__item {
border: 2px var(--background-color-low, lightgray) solid;
border-radius: 4px;
padding: 10px;
}
.pdk_dashboard-page__widgets-section__item__title {}
.pdk_dashboard-page__widgets-section__item__row {}
.pdk_dashboard-page__widgets-section__item__row--success .pdk_dashboard-page__widgets-section__item__row__value {
color: var(--success-color-medium, green);
}
.pdk_dashboard-page__widgets-section__item__row--error .pdk_dashboard-page__widgets-section__item__row__value {
color: var(--error-color-medium, red);
}
.pdk_dashboard-page__widgets-section__item__row__key {}
.pdk_dashboard-page__widgets-section__item__row__value {}
.pdk_dashboard-page__outbound-section {
margin-top: 10px;
border: 2px var(--background-color-low, lightgray) solid;
border-radius: 4px;
padding: 10px;
}
.pdk_dashboard-page__outbound-section__title-section {
display: flex;
align-items: center;
justify-content: space-between;
}
.pdk_dashboard-page__outbound-section__title-section__title {
color: var(--text-color-high);
font-weight: 700;
}
.pdk_dashboard-page__outbound-grid {
margin-top: 5px;
display: grid;
grid-template-columns: repeat(var(--dashboard-grid-columns), 1fr);
grid-gap: 10px;
}
.pdk_dashboard-page__outbound-grid__item {
border: 2px var(--background-color-low, lightgray) solid;
border-radius: 4px;
padding: 10px;
transition: border 0.2s ease;
}
.pdk_dashboard-page__outbound-grid__item--selectable {
cursor: pointer;
}
.pdk_dashboard-page__outbound-grid__item--selectable:hover {
border-color: var(--primary-color-high, dodgerblue);
}
.pdk_dashboard-page__outbound-grid__item--active {
border-color: var(--success-color-medium, green);
}
.pdk_dashboard-page__outbound-grid__item__footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 10px;
}
.pdk_dashboard-page__outbound-grid__item__type {}
.pdk_dashboard-page__outbound-grid__item__latency--empty {
color: var(--primary-color-low, lightgray);
}
.pdk_dashboard-page__outbound-grid__item__latency--green {
color: var(--success-color-medium, green);
}
.pdk_dashboard-page__outbound-grid__item__latency--yellow {
color: var(--warn-color-medium, orange);
}
.pdk_dashboard-page__outbound-grid__item__latency--red {
color: var(--error-color-medium, red);
}
.centered {
display: flex;
align-items: center;
justify-content: center;
}
/* Skeleton styles*/
.skeleton {
background-color: var(--background-color-low, #e0e0e0);
border-radius: 4px;
position: relative;
overflow: hidden;
}
.skeleton::after {
content: '';
position: absolute;
top: 0;
left: -150%;
width: 150%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.4),
transparent
);
animation: skeleton-shimmer 1.6s infinite;
}
@keyframes skeleton-shimmer {
100% {
left: 150%;
}
}
`;

View File

@@ -4,20 +4,21 @@ import { ValidationResult } from './types';
export function validateDNS(value: string): ValidationResult {
if (!value) {
return { valid: false, message: 'DNS server address cannot be empty' };
return { valid: false, message: _('DNS server address cannot be empty') };
}
if (validateIPV4(value).valid) {
return { valid: true, message: 'Valid' };
return { valid: true, message: _('Valid') };
}
if (validateDomain(value).valid) {
return { valid: true, message: 'Valid' };
return { valid: true, message: _('Valid') };
}
return {
valid: false,
message:
message: _(
'Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH',
),
};
}

View File

@@ -5,7 +5,7 @@ export function validateDomain(domain: string): ValidationResult {
/^(?=.{1,253}(?:\/|$))(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)\.)+(?:[a-zA-Z]{2,}|xn--[a-zA-Z0-9-]{1,59}[a-zA-Z0-9])(?:\/[^\s]*)?$/;
if (!domainRegex.test(domain)) {
return { valid: false, message: 'Invalid domain address' };
return { valid: false, message: _('Invalid domain address') };
}
const hostname = domain.split('/')[0];
@@ -14,8 +14,8 @@ export function validateDomain(domain: string): ValidationResult {
const atLeastOneInvalidPart = parts.some((part) => part.length > 63);
if (atLeastOneInvalidPart) {
return { valid: false, message: 'Invalid domain address' };
return { valid: false, message: _('Invalid domain address') };
}
return { valid: true, message: 'Valid' };
return { valid: true, message: _('Valid') };
}

View File

@@ -5,8 +5,8 @@ export function validateIPV4(ip: string): ValidationResult {
/^(?:(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])$/;
if (ipRegex.test(ip)) {
return { valid: true, message: 'Valid' };
return { valid: true, message: _('Valid') };
}
return { valid: false, message: 'Invalid IP address' };
return { valid: false, message: _('Invalid IP address') };
}

View File

@@ -8,13 +8,14 @@ export function validateOutboundJson(value: string): ValidationResult {
if (!parsed.type || !parsed.server || !parsed.server_port) {
return {
valid: false,
message:
message: _(
'Outbound JSON must contain at least "type", "server" and "server_port" fields',
),
};
}
return { valid: true, message: 'Valid' };
return { valid: true, message: _('Valid') };
} catch {
return { valid: false, message: 'Invalid JSON format' };
return { valid: false, message: _('Invalid JSON format') };
}
}

View File

@@ -4,7 +4,7 @@ export function validatePath(value: string): ValidationResult {
if (!value) {
return {
valid: false,
message: 'Path cannot be empty',
message: _('Path cannot be empty'),
};
}
@@ -19,7 +19,8 @@ export function validatePath(value: string): ValidationResult {
return {
valid: false,
message:
message: _(
'Invalid path format. Path must start with "/" and contain valid characters',
),
};
}

View File

@@ -19,6 +19,6 @@ export function validateProxyUrl(url: string): ValidationResult {
return {
valid: false,
message: 'URL must start with vless:// or ss:// or trojan://',
message: _('URL must start with vless:// or ss:// or trojan://'),
};
}

View File

@@ -5,7 +5,7 @@ export function validateShadowsocksUrl(url: string): ValidationResult {
if (!url.startsWith('ss://')) {
return {
valid: false,
message: 'Invalid Shadowsocks URL: must start with ss://',
message: _('Invalid Shadowsocks URL: must start with ss://'),
};
}
@@ -13,7 +13,7 @@ export function validateShadowsocksUrl(url: string): ValidationResult {
if (!url || /\s/.test(url)) {
return {
valid: false,
message: 'Invalid Shadowsocks URL: must not contain spaces',
message: _('Invalid Shadowsocks URL: must not contain spaces'),
};
}
@@ -24,7 +24,7 @@ export function validateShadowsocksUrl(url: string): ValidationResult {
if (!encryptedPart) {
return {
valid: false,
message: 'Invalid Shadowsocks URL: missing credentials',
message: _('Invalid Shadowsocks URL: missing credentials'),
};
}
@@ -34,16 +34,18 @@ export function validateShadowsocksUrl(url: string): ValidationResult {
if (!decoded.includes(':')) {
return {
valid: false,
message:
message: _(
'Invalid Shadowsocks URL: decoded credentials must contain method:password',
),
};
}
} catch (_e) {
if (!encryptedPart.includes(':') && !encryptedPart.includes('-')) {
return {
valid: false,
message:
message: _(
'Invalid Shadowsocks URL: missing method and password separator ":"',
),
};
}
}
@@ -53,7 +55,7 @@ export function validateShadowsocksUrl(url: string): ValidationResult {
if (!serverPart) {
return {
valid: false,
message: 'Invalid Shadowsocks URL: missing server address',
message: _('Invalid Shadowsocks URL: missing server address'),
};
}
@@ -62,14 +64,17 @@ export function validateShadowsocksUrl(url: string): ValidationResult {
if (!server) {
return {
valid: false,
message: 'Invalid Shadowsocks URL: missing server',
message: _('Invalid Shadowsocks URL: missing server'),
};
}
const port = portAndRest ? portAndRest.split(/[?#]/)[0] : null;
if (!port) {
return { valid: false, message: 'Invalid Shadowsocks URL: missing port' };
return {
valid: false,
message: _('Invalid Shadowsocks URL: missing port'),
};
}
const portNum = parseInt(port, 10);
@@ -77,12 +82,15 @@ export function validateShadowsocksUrl(url: string): ValidationResult {
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
return {
valid: false,
message: 'Invalid port number. Must be between 1 and 65535',
message: _('Invalid port number. Must be between 1 and 65535'),
};
}
} catch (_e) {
return { valid: false, message: 'Invalid Shadowsocks URL: parsing failed' };
return {
valid: false,
message: _('Invalid Shadowsocks URL: parsing failed'),
};
}
return { valid: true, message: 'Valid' };
return { valid: true, message: _('Valid') };
}

View File

@@ -8,14 +8,14 @@ export function validateSubnet(value: string): ValidationResult {
if (!subnetRegex.test(value)) {
return {
valid: false,
message: 'Invalid format. Use X.X.X.X or X.X.X.X/Y',
message: _('Invalid format. Use X.X.X.X or X.X.X.X/Y'),
};
}
const [ip, cidr] = value.split('/');
if (ip === '0.0.0.0') {
return { valid: false, message: 'IP address 0.0.0.0 is not allowed' };
return { valid: false, message: _('IP address 0.0.0.0 is not allowed') };
}
const ipCheck = validateIPV4(ip);
@@ -30,10 +30,10 @@ export function validateSubnet(value: string): ValidationResult {
if (cidrNum < 0 || cidrNum > 32) {
return {
valid: false,
message: 'CIDR must be between 0 and 32',
message: _('CIDR must be between 0 and 32'),
};
}
}
return { valid: true, message: 'Valid' };
return { valid: true, message: _('Valid') };
}

View File

@@ -5,14 +5,14 @@ export function validateTrojanUrl(url: string): ValidationResult {
if (!url.startsWith('trojan://')) {
return {
valid: false,
message: 'Invalid Trojan URL: must start with trojan://',
message: _('Invalid Trojan URL: must start with trojan://'),
};
}
if (!url || /\s/.test(url)) {
return {
valid: false,
message: 'Invalid Trojan URL: must not contain spaces',
message: _('Invalid Trojan URL: must not contain spaces'),
};
}
@@ -22,12 +22,14 @@ export function validateTrojanUrl(url: string): ValidationResult {
if (!parsedUrl.username || !parsedUrl.hostname || !parsedUrl.port) {
return {
valid: false,
message: 'Invalid Trojan URL: must contain username, hostname and port',
message: _(
'Invalid Trojan URL: must contain username, hostname and port',
),
};
}
} catch (_e) {
return { valid: false, message: 'Invalid Trojan URL: parsing failed' };
return { valid: false, message: _('Invalid Trojan URL: parsing failed') };
}
return { valid: true, message: 'Valid' };
return { valid: true, message: _('Valid') };
}

View File

@@ -10,11 +10,11 @@ export function validateUrl(
if (!protocols.includes(parsedUrl.protocol)) {
return {
valid: false,
message: `URL must use one of the following protocols: ${protocols.join(', ')}`,
message: `${_('URL must use one of the following protocols:')} ${protocols.join(', ')}`,
};
}
return { valid: true, message: 'Valid' };
return { valid: true, message: _('Valid') };
} catch (_e) {
return { valid: false, message: 'Invalid URL format' };
return { valid: false, message: _('Invalid URL format') };
}
}

View File

@@ -1,6 +1,5 @@
import { ValidationResult } from './types';
// TODO refactor current validation and add tests
export function validateVlessUrl(url: string): ValidationResult {
try {
const parsedUrl = new URL(url);
@@ -8,27 +7,27 @@ export function validateVlessUrl(url: string): ValidationResult {
if (!url || /\s/.test(url)) {
return {
valid: false,
message: 'Invalid VLESS URL: must not contain spaces',
message: _('Invalid VLESS URL: must not contain spaces'),
};
}
if (parsedUrl.protocol !== 'vless:') {
return {
valid: false,
message: 'Invalid VLESS URL: must start with vless://',
message: _('Invalid VLESS URL: must start with vless://'),
};
}
if (!parsedUrl.username) {
return { valid: false, message: 'Invalid VLESS URL: missing UUID' };
return { valid: false, message: _('Invalid VLESS URL: missing UUID') };
}
if (!parsedUrl.hostname) {
return { valid: false, message: 'Invalid VLESS URL: missing server' };
return { valid: false, message: _('Invalid VLESS URL: missing server') };
}
if (!parsedUrl.port) {
return { valid: false, message: 'Invalid VLESS URL: missing port' };
return { valid: false, message: _('Invalid VLESS URL: missing port') };
}
if (
@@ -38,15 +37,16 @@ export function validateVlessUrl(url: string): ValidationResult {
) {
return {
valid: false,
message:
message: _(
'Invalid VLESS URL: invalid port number. Must be between 1 and 65535',
),
};
}
if (!parsedUrl.search) {
return {
valid: false,
message: 'Invalid VLESS URL: missing query parameters',
message: _('Invalid VLESS URL: missing query parameters'),
};
}
@@ -68,8 +68,9 @@ export function validateVlessUrl(url: string): ValidationResult {
if (!type || !validTypes.includes(type)) {
return {
valid: false,
message:
message: _(
'Invalid VLESS URL: type must be one of tcp, raw, udp, grpc, http, ws',
),
};
}
@@ -79,8 +80,9 @@ export function validateVlessUrl(url: string): ValidationResult {
if (!security || !validSecurities.includes(security)) {
return {
valid: false,
message:
message: _(
'Invalid VLESS URL: security must be one of tls, reality, none',
),
};
}
@@ -88,21 +90,23 @@ export function validateVlessUrl(url: string): ValidationResult {
if (!params.get('pbk')) {
return {
valid: false,
message:
message: _(
'Invalid VLESS URL: missing pbk parameter for reality security',
),
};
}
if (!params.get('fp')) {
return {
valid: false,
message:
message: _(
'Invalid VLESS URL: missing fp parameter for reality security',
),
};
}
}
return { valid: true, message: 'Valid' };
return { valid: true, message: _('Valid') };
} catch (_e) {
return { valid: false, message: 'Invalid VLESS URL: parsing failed' };
return { valid: false, message: _('Invalid VLESS URL: parsing failed') };
}
}

View File

@@ -0,0 +1,2 @@
// tests/setup/global-mocks.ts
globalThis._ = (key: string) => key;

View File

@@ -4,5 +4,6 @@ export default defineConfig({
test: {
globals: true,
environment: 'node',
setupFiles: ['./tests/setup/global-mocks.ts'],
},
});

View File

@@ -0,0 +1,82 @@
import 'dotenv/config';
import chokidar from 'chokidar';
import SFTPClient from 'ssh2-sftp-client';
import path from 'path';
import fs from 'fs';
import { glob } from 'glob';
const sftp = new SFTPClient();
const config = {
host: process.env.SFTP_HOST,
port: Number(process.env.SFTP_PORT || 22),
username: process.env.SFTP_USER,
...(process.env.SFTP_PRIVATE_KEY
? { privateKey: fs.readFileSync(process.env.SFTP_PRIVATE_KEY) }
: { password: process.env.SFTP_PASS }),
};
const localDir = path.resolve(process.env.LOCAL_DIR || './dist');
const remoteDir = process.env.REMOTE_DIR || '/www/luci-static/mypkg';
async function uploadFile(filePath) {
const relativePath = path.relative(localDir, filePath);
const remotePath = path.posix.join(remoteDir, relativePath);
console.log(`Uploading: ${relativePath} -> ${remotePath}`);
try {
await sftp.fastPut(filePath, remotePath);
console.log(`Uploaded: ${relativePath}`);
} catch (err) {
console.error(`Failed: ${relativePath}: ${err.message}`);
}
}
async function deleteFile(filePath) {
const relativePath = path.relative(localDir, filePath);
const remotePath = path.posix.join(remoteDir, relativePath);
console.log(`Removing: ${relativePath}`);
try {
await sftp.delete(remotePath);
console.log(`Removed: ${relativePath}`);
} catch (err) {
console.warn(`Could not delete ${relativePath}: ${err.message}`);
}
}
async function uploadAllFiles() {
console.log('Uploading all files from', localDir);
const files = await glob(`${localDir}/**/*`, { nodir: true });
for (const file of files) {
await uploadFile(file);
}
console.log('Initial upload complete!');
}
async function main() {
await sftp.connect(config);
console.log(`Connected to ${config.host}`);
await uploadAllFiles();
chokidar
.watch(localDir, { ignoreInitial: true })
.on('all', async (event, filePath) => {
if (event === 'add' || event === 'change') {
await uploadFile(filePath);
} else if (event === 'unlink') {
await deleteFile(filePath);
}
});
process.on('SIGINT', async () => {
console.log('Disconnecting...');
await sftp.end();
process.exit();
});
}
main().catch(console.error);

View File

@@ -221,6 +221,18 @@
resolved "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba"
integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==
"@isaacs/balanced-match@^4.0.1":
version "4.0.1"
resolved "https://registry.npmmirror.com/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz#3081dadbc3460661b751e7591d7faea5df39dd29"
integrity sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==
"@isaacs/brace-expansion@^5.0.0":
version "5.0.0"
resolved "https://registry.npmmirror.com/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz#4b3dabab7d8e75a429414a96bd67bf4c1d13e0f3"
integrity sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==
dependencies:
"@isaacs/balanced-match" "^4.0.1"
"@isaacs/cliui@^8.0.2":
version "8.0.2"
resolved "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550"
@@ -628,6 +640,13 @@ argparse@^2.0.1:
resolved "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
asn1@^0.2.6:
version "0.2.6"
resolved "https://registry.npmmirror.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d"
integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==
dependencies:
safer-buffer "~2.1.0"
assertion-error@^2.0.1:
version "2.0.1"
resolved "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7"
@@ -638,6 +657,13 @@ balanced-match@^1.0.0:
resolved "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
bcrypt-pbkdf@^1.0.2:
version "1.0.2"
resolved "https://registry.npmmirror.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e"
integrity sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==
dependencies:
tweetnacl "^0.14.3"
brace-expansion@^1.1.7:
version "1.1.12"
resolved "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843"
@@ -660,6 +686,16 @@ braces@^3.0.3:
dependencies:
fill-range "^7.1.1"
buffer-from@^1.0.0:
version "1.1.2"
resolved "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
buildcheck@~0.0.6:
version "0.0.6"
resolved "https://registry.npmmirror.com/buildcheck/-/buildcheck-0.0.6.tgz#89aa6e417cfd1e2196e3f8fe915eb709d2fe4238"
integrity sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==
bundle-require@^5.1.0:
version "5.1.0"
resolved "https://registry.npmmirror.com/bundle-require/-/bundle-require-5.1.0.tgz#8db66f41950da3d77af1ef3322f4c3e04009faee"
@@ -701,7 +737,7 @@ check-error@^2.1.1:
resolved "https://registry.npmmirror.com/check-error/-/check-error-2.1.1.tgz#87eb876ae71ee388fa0471fe423f494be1d96ccc"
integrity sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==
chokidar@^4.0.3:
chokidar@4.0.3, chokidar@^4.0.3:
version "4.0.3"
resolved "https://registry.npmmirror.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30"
integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==
@@ -730,6 +766,16 @@ concat-map@0.0.1:
resolved "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
concat-stream@^2.0.0:
version "2.0.0"
resolved "https://registry.npmmirror.com/concat-stream/-/concat-stream-2.0.0.tgz#414cf5af790a48c60ab9be4527d56d5e41133cb1"
integrity sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==
dependencies:
buffer-from "^1.0.0"
inherits "^2.0.3"
readable-stream "^3.0.2"
typedarray "^0.0.6"
confbox@^0.1.8:
version "0.1.8"
resolved "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz#820d73d3b3c82d9bd910652c5d4d599ef8ff8b06"
@@ -740,6 +786,14 @@ consola@^3.4.0:
resolved "https://registry.npmmirror.com/consola/-/consola-3.4.2.tgz#5af110145397bb67afdab77013fdc34cae590ea7"
integrity sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==
cpu-features@~0.0.10:
version "0.0.10"
resolved "https://registry.npmmirror.com/cpu-features/-/cpu-features-0.0.10.tgz#9aae536db2710c7254d7ed67cb3cbc7d29ad79c5"
integrity sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==
dependencies:
buildcheck "~0.0.6"
nan "^2.19.0"
cross-spawn@^7.0.6:
version "7.0.6"
resolved "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
@@ -766,6 +820,11 @@ deep-is@^0.1.3:
resolved "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
dotenv@17.2.3:
version "17.2.3"
resolved "https://registry.npmmirror.com/dotenv/-/dotenv-17.2.3.tgz#ad995d6997f639b11065f419a22fabf567cdb9a2"
integrity sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==
eastasianwidth@^0.2.0:
version "0.2.0"
resolved "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
@@ -1014,7 +1073,7 @@ flatted@^3.2.9:
resolved "https://registry.npmmirror.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358"
integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==
foreground-child@^3.1.0:
foreground-child@^3.1.0, foreground-child@^3.3.1:
version "3.3.1"
resolved "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f"
integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==
@@ -1041,6 +1100,18 @@ glob-parent@^6.0.2:
dependencies:
is-glob "^4.0.3"
glob@11.0.3:
version "11.0.3"
resolved "https://registry.npmmirror.com/glob/-/glob-11.0.3.tgz#9d8087e6d72ddb3c4707b1d2778f80ea3eaefcd6"
integrity sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==
dependencies:
foreground-child "^3.3.1"
jackspeak "^4.1.1"
minimatch "^10.0.3"
minipass "^7.1.2"
package-json-from-dist "^1.0.0"
path-scurry "^2.0.0"
glob@^10.3.10:
version "10.4.5"
resolved "https://registry.npmmirror.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956"
@@ -1091,6 +1162,11 @@ imurmurhash@^0.1.4:
resolved "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==
inherits@^2.0.3:
version "2.0.4"
resolved "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
is-extglob@^2.1.1:
version "2.1.1"
resolved "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
@@ -1127,6 +1203,13 @@ jackspeak@^3.1.2:
optionalDependencies:
"@pkgjs/parseargs" "^0.11.0"
jackspeak@^4.1.1:
version "4.1.1"
resolved "https://registry.npmmirror.com/jackspeak/-/jackspeak-4.1.1.tgz#96876030f450502047fc7e8c7fcf8ce8124e43ae"
integrity sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==
dependencies:
"@isaacs/cliui" "^8.0.2"
joycon@^3.1.1:
version "3.1.1"
resolved "https://registry.npmmirror.com/joycon/-/joycon-3.1.1.tgz#bce8596d6ae808f8b68168f5fc69280996894f03"
@@ -1216,6 +1299,11 @@ lru-cache@^10.2.0:
resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119"
integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
lru-cache@^11.0.0:
version "11.2.2"
resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.2.2.tgz#40fd37edffcfae4b2940379c0722dc6eeaa75f24"
integrity sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==
magic-string@^0.30.17:
version "0.30.19"
resolved "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.19.tgz#cebe9f104e565602e5d2098c5f2e79a77cc86da9"
@@ -1236,6 +1324,13 @@ micromatch@^4.0.8:
braces "^3.0.3"
picomatch "^2.3.1"
minimatch@^10.0.3:
version "10.0.3"
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-10.0.3.tgz#cf7a0314a16c4d9ab73a7730a0e8e3c3502d47aa"
integrity sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==
dependencies:
"@isaacs/brace-expansion" "^5.0.0"
minimatch@^3.1.2:
version "3.1.2"
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
@@ -1279,6 +1374,11 @@ mz@^2.7.0:
object-assign "^4.0.1"
thenify-all "^1.0.0"
nan@^2.19.0, nan@^2.23.0:
version "2.23.0"
resolved "https://registry.npmmirror.com/nan/-/nan-2.23.0.tgz#24aa4ddffcc37613a2d2935b97683c1ec96093c6"
integrity sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==
nanoid@^3.3.11:
version "3.3.11"
resolved "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
@@ -1350,6 +1450,14 @@ path-scurry@^1.11.1:
lru-cache "^10.2.0"
minipass "^5.0.0 || ^6.0.2 || ^7.0.0"
path-scurry@^2.0.0:
version "2.0.0"
resolved "https://registry.npmmirror.com/path-scurry/-/path-scurry-2.0.0.tgz#9f052289f23ad8bf9397a2a0425e7b8615c58580"
integrity sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==
dependencies:
lru-cache "^11.0.0"
minipass "^7.1.2"
pathe@^2.0.1, pathe@^2.0.3:
version "2.0.3"
resolved "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716"
@@ -1425,6 +1533,15 @@ queue-microtask@^1.2.2:
resolved "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
readable-stream@^3.0.2:
version "3.6.2"
resolved "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967"
integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==
dependencies:
inherits "^2.0.3"
string_decoder "^1.1.1"
util-deprecate "^1.0.1"
readdirp@^4.0.1:
version "4.1.2"
resolved "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d"
@@ -1483,6 +1600,16 @@ run-parallel@^1.1.9:
dependencies:
queue-microtask "^1.2.2"
safe-buffer@~5.2.0:
version "5.2.1"
resolved "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
safer-buffer@~2.1.0:
version "2.1.2"
resolved "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
semver@^7.6.0:
version "7.7.2"
resolved "https://registry.npmmirror.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58"
@@ -1522,6 +1649,25 @@ source-map@0.8.0-beta.0:
dependencies:
whatwg-url "^7.0.0"
ssh2-sftp-client@12.0.1:
version "12.0.1"
resolved "https://registry.npmmirror.com/ssh2-sftp-client/-/ssh2-sftp-client-12.0.1.tgz#926764878954dbed85f6f9233ce7980bfc94fdd4"
integrity sha512-ICJ1L2PmBel2Q2ctbyxzTFZCPKSHYYD6s2TFZv7NXmZDrDNGk8lHBb/SK2WgXLMXNANH78qoumeJzxlWZqSqWg==
dependencies:
concat-stream "^2.0.0"
ssh2 "^1.16.0"
ssh2@^1.16.0:
version "1.17.0"
resolved "https://registry.npmmirror.com/ssh2/-/ssh2-1.17.0.tgz#dc686e8e3abdbd4ad95d46fa139615903c12258c"
integrity sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==
dependencies:
asn1 "^0.2.6"
bcrypt-pbkdf "^1.0.2"
optionalDependencies:
cpu-features "~0.0.10"
nan "^2.23.0"
stackback@0.0.2:
version "0.0.2"
resolved "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b"
@@ -1559,6 +1705,13 @@ string-width@^5.0.1, string-width@^5.1.2:
emoji-regex "^9.2.2"
strip-ansi "^7.0.1"
string_decoder@^1.1.1:
version "1.3.0"
resolved "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
dependencies:
safe-buffer "~5.2.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
@@ -1711,6 +1864,11 @@ tsup@8.5.0:
tinyglobby "^0.2.11"
tree-kill "^1.2.2"
tweetnacl@^0.14.3:
version "0.14.5"
resolved "https://registry.npmmirror.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==
type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0"
resolved "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
@@ -1718,6 +1876,11 @@ type-check@^0.4.0, type-check@~0.4.0:
dependencies:
prelude-ls "^1.2.1"
typedarray@^0.0.6:
version "0.0.6"
resolved "https://registry.npmmirror.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==
typescript-eslint@8.45.0:
version "8.45.0"
resolved "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.45.0.tgz#98ab164234dc04c112747ec0a4ae29a94efe123b"
@@ -1745,6 +1908,11 @@ uri-js@^4.2.2:
dependencies:
punycode "^2.1.0"
util-deprecate@^1.0.1:
version "1.0.2"
resolved "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
vite-node@3.2.4:
version "3.2.4"
resolved "https://registry.npmmirror.com/vite-node/-/vite-node-3.2.4.tgz#f3676d94c4af1e76898c162c92728bca65f7bb07"

View File

@@ -88,7 +88,7 @@ function createAdditionalSection(mainSection) {
return true;
}
return _(validation.message);
return validation.message;
};
o = mainSection.taboption(
@@ -113,7 +113,7 @@ function createAdditionalSection(mainSection) {
return true;
}
return _(validation.message);
return validation.message;
};
o = mainSection.taboption(
@@ -342,7 +342,7 @@ function createAdditionalSection(mainSection) {
return true;
}
return _(validation.message);
return validation.message;
};
o = mainSection.taboption(

View File

@@ -12,11 +12,11 @@ function createConfigSection(section) {
let o = s.tab('basic', _('Basic Settings'));
o = s.taboption(
'basic',
form.ListValue,
'mode',
_('Connection Type'),
_('Select between VPN and Proxy connection methods for traffic routing'),
'basic',
form.ListValue,
'mode',
_('Connection Type'),
_('Select between VPN and Proxy connection methods for traffic routing'),
);
o.value('proxy', 'Proxy');
o.value('vpn', 'VPN');
@@ -24,11 +24,11 @@ function createConfigSection(section) {
o.ucisection = s.section;
o = s.taboption(
'basic',
form.ListValue,
'proxy_config_type',
_('Configuration Type'),
_('Select how to configure the proxy'),
'basic',
form.ListValue,
'proxy_config_type',
_('Configuration Type'),
_('Select how to configure the proxy'),
);
o.value('url', _('Connection URL'));
o.value('outbound', _('Outbound Config'));
@@ -38,11 +38,11 @@ function createConfigSection(section) {
o.ucisection = s.section;
o = s.taboption(
'basic',
form.TextValue,
'proxy_string',
_('Proxy Configuration URL'),
'',
'basic',
form.TextValue,
'proxy_string',
_('Proxy Configuration URL'),
'',
);
o.depends('proxy_config_type', 'url');
o.rows = 5;
@@ -52,7 +52,7 @@ function createConfigSection(section) {
o.ucisection = s.section;
o.sectionDescriptions = new Map();
o.placeholder =
'vless://uuid@server:port?type=tcp&security=tls#main\n// backup ss://method:pass@server:port\n// backup2 vless://uuid@server:port?type=grpc&security=reality#alt\n// backup3 trojan://04agAQapcl@127.0.0.1:33641?type=tcp&security=none#trojan-tcp-none';
'vless://uuid@server:port?type=tcp&security=tls#main\n// backup ss://method:pass@server:port\n// backup2 vless://uuid@server:port?type=grpc&security=reality#alt\n// backup3 trojan://04agAQapcl@127.0.0.1:33641?type=tcp&security=none#trojan-tcp-none';
o.renderWidget = function (section_id, option_index, cfgvalue) {
const original = form.TextValue.prototype.renderWidget.apply(this, [
@@ -66,9 +66,9 @@ function createConfigSection(section) {
if (cfgvalue) {
try {
const activeConfig = cfgvalue
.split('\n')
.map((line) => line.trim())
.find((line) => line && !line.startsWith('//'));
.split('\n')
.map((line) => line.trim())
.find((line) => line && !line.startsWith('//'));
if (activeConfig) {
if (activeConfig.includes('#')) {
@@ -76,24 +76,24 @@ function createConfigSection(section) {
if (label && label.trim()) {
const decodedLabel = decodeURIComponent(label);
const descDiv = E(
'div',
{ class: 'cbi-value-description' },
_('Current config: ') + decodedLabel,
'div',
{ class: 'cbi-value-description' },
_('Current config: ') + decodedLabel,
);
container.appendChild(descDiv);
} else {
const descDiv = E(
'div',
{ class: 'cbi-value-description' },
_('Config without description'),
'div',
{ class: 'cbi-value-description' },
_('Config without description'),
);
container.appendChild(descDiv);
}
} else {
const descDiv = E(
'div',
{ class: 'cbi-value-description' },
_('Config without description'),
'div',
{ class: 'cbi-value-description' },
_('Config without description'),
);
container.appendChild(descDiv);
}
@@ -101,19 +101,19 @@ function createConfigSection(section) {
} catch (e) {
console.error('Error parsing config label:', e);
const descDiv = E(
'div',
{ class: 'cbi-value-description' },
_('Config without description'),
'div',
{ class: 'cbi-value-description' },
_('Config without description'),
);
container.appendChild(descDiv);
}
} else {
const defaultDesc = E(
'div',
{ class: 'cbi-value-description' },
_(
'Enter connection string starting with vless:// or ss:// for proxy configuration. Add comments with // for backup configs',
),
'div',
{ class: 'cbi-value-description' },
_(
'Enter connection string starting with vless:// or ss:// for proxy configuration. Add comments with // for backup configs',
),
);
container.appendChild(defaultDesc);
}
@@ -129,20 +129,20 @@ function createConfigSection(section) {
try {
const activeConfigs = value
.split('\n')
.map((line) => line.trim())
.filter((line) => !line.startsWith('//'))
.filter(Boolean);
.split('\n')
.map((line) => line.trim())
.filter((line) => !line.startsWith('//'))
.filter(Boolean);
if (!activeConfigs.length) {
return _(
'No active configuration found. One configuration is required.',
'No active configuration found. One configuration is required.',
);
}
if (activeConfigs.length > 1) {
return _(
'Multiply active configurations found. Please leave one configuration.',
'Multiply active configurations found. Please leave one configuration.',
);
}
@@ -152,18 +152,18 @@ function createConfigSection(section) {
return true;
}
return _(validation.message);
return validation.message;
} catch (e) {
return `${_('Invalid URL format:')} ${e?.message}`;
}
};
o = s.taboption(
'basic',
form.TextValue,
'outbound_json',
_('Outbound Configuration'),
_('Enter complete outbound configuration in JSON format'),
'basic',
form.TextValue,
'outbound_json',
_('Outbound Configuration'),
_('Enter complete outbound configuration in JSON format'),
);
o.depends('proxy_config_type', 'outbound');
o.rows = 10;
@@ -180,14 +180,14 @@ function createConfigSection(section) {
return true;
}
return _(validation.message);
return validation.message;
};
o = s.taboption(
'basic',
form.DynamicList,
'urltest_proxy_links',
_('URLTest Proxy Links'),
'basic',
form.DynamicList,
'urltest_proxy_links',
_('URLTest Proxy Links'),
);
o.depends('proxy_config_type', 'urltest');
o.placeholder = 'vless://, ss://, trojan:// links';
@@ -204,15 +204,15 @@ function createConfigSection(section) {
return true;
}
return _(validation.message);
return validation.message;
};
o = s.taboption(
'basic',
form.Flag,
'ss_uot',
_('Shadowsocks UDP over TCP'),
_('Apply for SS2022'),
'basic',
form.Flag,
'ss_uot',
_('Shadowsocks UDP over TCP'),
_('Apply for SS2022'),
);
o.default = '0';
o.depends('mode', 'proxy');
@@ -220,11 +220,11 @@ function createConfigSection(section) {
o.ucisection = s.section;
o = s.taboption(
'basic',
widgets.DeviceSelect,
'interface',
_('Network Interface'),
_('Select network interface for VPN connection'),
'basic',
widgets.DeviceSelect,
'interface',
_('Network Interface'),
_('Select network interface for VPN connection'),
);
o.depends('mode', 'vpn');
o.ucisection = s.section;
@@ -262,17 +262,17 @@ function createConfigSection(section) {
// Reject wireless-related devices
const isWireless =
type === 'wifi' || type === 'wireless' || type.includes('wlan');
type === 'wifi' || type === 'wireless' || type.includes('wlan');
return !isWireless;
};
o = s.taboption(
'basic',
form.Flag,
'domain_resolver_enabled',
_('Domain Resolver'),
_('Enable built-in DNS resolver for domains handled by this section'),
'basic',
form.Flag,
'domain_resolver_enabled',
_('Domain Resolver'),
_('Enable built-in DNS resolver for domains handled by this section'),
);
o.default = '0';
o.rmempty = false;
@@ -280,11 +280,11 @@ function createConfigSection(section) {
o.ucisection = s.section;
o = s.taboption(
'basic',
form.ListValue,
'domain_resolver_dns_type',
_('DNS Protocol Type'),
_('Select the DNS protocol type for the domain resolver'),
'basic',
form.ListValue,
'domain_resolver_dns_type',
_('DNS Protocol Type'),
_('Select the DNS protocol type for the domain resolver'),
);
o.value('doh', _('DNS over HTTPS (DoH)'));
o.value('dot', _('DNS over TLS (DoT)'));
@@ -295,11 +295,11 @@ function createConfigSection(section) {
o.ucisection = s.section;
o = s.taboption(
'basic',
form.Value,
'domain_resolver_dns_server',
_('DNS Server'),
_('Select or enter DNS server address'),
'basic',
form.Value,
'domain_resolver_dns_server',
_('DNS Server'),
_('Select or enter DNS server address'),
);
Object.entries(main.DNS_SERVER_OPTIONS).forEach(([key, label]) => {
o.value(key, _(label));
@@ -315,25 +315,25 @@ function createConfigSection(section) {
return true;
}
return _(validation.message);
return validation.message;
};
o = s.taboption(
'basic',
form.Flag,
'community_lists_enabled',
_('Community Lists'),
'basic',
form.Flag,
'community_lists_enabled',
_('Community Lists'),
);
o.default = '0';
o.rmempty = false;
o.ucisection = s.section;
o = s.taboption(
'basic',
form.DynamicList,
'community_lists',
_('Service List'),
_('Select predefined service for routing') +
'basic',
form.DynamicList,
'community_lists',
_('Service List'),
_('Select predefined service for routing') +
' <a href="https://github.com/itdoginfo/allow-domains" target="_blank">github.com/itdoginfo/allow-domains</a>',
);
o.placeholder = 'Service list';
@@ -357,50 +357,50 @@ function createConfigSection(section) {
let notifications = [];
const selectedRegionalOptions = main.REGIONAL_OPTIONS.filter((opt) =>
newValues.includes(opt),
newValues.includes(opt),
);
if (selectedRegionalOptions.length > 1) {
const lastSelected =
selectedRegionalOptions[selectedRegionalOptions.length - 1];
selectedRegionalOptions[selectedRegionalOptions.length - 1];
const removedRegions = selectedRegionalOptions.slice(0, -1);
newValues = newValues.filter(
(v) => v === lastSelected || !main.REGIONAL_OPTIONS.includes(v),
(v) => v === lastSelected || !main.REGIONAL_OPTIONS.includes(v),
);
notifications.push(
E('p', { class: 'alert-message warning' }, [
E('strong', {}, _('Regional options cannot be used together')),
E('br'),
_(
'Warning: %s cannot be used together with %s. Previous selections have been removed.',
).format(removedRegions.join(', '), lastSelected),
]),
E('p', { class: 'alert-message warning' }, [
E('strong', {}, _('Regional options cannot be used together')),
E('br'),
_(
'Warning: %s cannot be used together with %s. Previous selections have been removed.',
).format(removedRegions.join(', '), lastSelected),
]),
);
}
if (newValues.includes('russia_inside')) {
const removedServices = newValues.filter(
(v) => !main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v),
(v) => !main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v),
);
if (removedServices.length > 0) {
newValues = newValues.filter((v) =>
main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v),
main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v),
);
notifications.push(
E('p', { class: 'alert-message warning' }, [
E('strong', {}, _('Russia inside restrictions')),
E('br'),
_(
'Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection.',
).format(
main.ALLOWED_WITH_RUSSIA_INSIDE.map(
(key) => main.DOMAIN_LIST_OPTIONS[key],
)
.filter((label) => label !== 'Russia inside')
.join(', '),
removedServices.join(', '),
),
]),
E('p', { class: 'alert-message warning' }, [
E('strong', {}, _('Russia inside restrictions')),
E('br'),
_(
'Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection.',
).format(
main.ALLOWED_WITH_RUSSIA_INSIDE.map(
(key) => main.DOMAIN_LIST_OPTIONS[key],
)
.filter((label) => label !== 'Russia inside')
.join(', '),
removedServices.join(', '),
),
]),
);
}
}
@@ -410,7 +410,7 @@ function createConfigSection(section) {
}
notifications.forEach((notification) =>
ui.addNotification(null, notification),
ui.addNotification(null, notification),
);
lastValues = newValues;
} catch (e) {
@@ -421,11 +421,11 @@ function createConfigSection(section) {
};
o = s.taboption(
'basic',
form.ListValue,
'user_domain_list_type',
_('User Domain List Type'),
_('Select how to add your custom domains'),
'basic',
form.ListValue,
'user_domain_list_type',
_('User Domain List Type'),
_('Select how to add your custom domains'),
);
o.value('disabled', _('Disabled'));
o.value('dynamic', _('Dynamic List'));
@@ -435,13 +435,13 @@ function createConfigSection(section) {
o.ucisection = s.section;
o = s.taboption(
'basic',
form.DynamicList,
'user_domains',
_('User Domains'),
_(
'Enter domain names without protocols (example: sub.example.com or example.com)',
),
'basic',
form.DynamicList,
'user_domains',
_('User Domains'),
_(
'Enter domain names without protocols (example: sub.example.com or example.com)',
),
);
o.placeholder = 'Domains list';
o.depends('user_domain_list_type', 'dynamic');
@@ -459,20 +459,20 @@ function createConfigSection(section) {
return true;
}
return _(validation.message);
return validation.message;
};
o = s.taboption(
'basic',
form.TextValue,
'user_domains_text',
_('User Domains List'),
_(
'Enter domain names separated by comma, space or newline. You can add comments after //',
),
'basic',
form.TextValue,
'user_domains_text',
_('User Domains List'),
_(
'Enter domain names separated by comma, space or newline. You can add comments after //',
),
);
o.placeholder =
'example.com, sub.example.com\n// Social networks\ndomain.com test.com // personal domains';
'example.com, sub.example.com\n// Social networks\ndomain.com test.com // personal domains';
o.depends('user_domain_list_type', 'text');
o.rows = 8;
o.rmempty = false;
@@ -487,7 +487,7 @@ function createConfigSection(section) {
if (!domains.length) {
return _(
'At least one valid domain must be specified. Comments-only content is not allowed.',
'At least one valid domain must be specified. Comments-only content is not allowed.',
);
}
@@ -495,8 +495,8 @@ function createConfigSection(section) {
if (!valid) {
const errors = results
.filter((validation) => !validation.valid) // Leave only failed validations
.map((validation) => _(`${validation.value}: ${validation.message}`)); // Collect validation errors
.filter((validation) => !validation.valid) // Leave only failed validations
.map((validation) => _(`${validation.value}: ${validation.message}`)); // Collect validation errors
return [_('Validation errors:'), ...errors].join('\n');
}
@@ -505,22 +505,22 @@ function createConfigSection(section) {
};
o = s.taboption(
'basic',
form.Flag,
'local_domain_lists_enabled',
_('Local Domain Lists'),
_('Use the list from the router filesystem'),
'basic',
form.Flag,
'local_domain_lists_enabled',
_('Local Domain Lists'),
_('Use the list from the router filesystem'),
);
o.default = '0';
o.rmempty = false;
o.ucisection = s.section;
o = s.taboption(
'basic',
form.DynamicList,
'local_domain_lists',
_('Local Domain List Paths'),
_('Enter the list file path'),
'basic',
form.DynamicList,
'local_domain_lists',
_('Local Domain List Paths'),
_('Enter the list file path'),
);
o.placeholder = '/path/file.lst';
o.depends('local_domain_lists_enabled', '1');
@@ -538,26 +538,26 @@ function createConfigSection(section) {
return true;
}
return _(validation.message);
return validation.message;
};
o = s.taboption(
'basic',
form.Flag,
'remote_domain_lists_enabled',
_('Remote Domain Lists'),
_('Download and use domain lists from remote URLs'),
'basic',
form.Flag,
'remote_domain_lists_enabled',
_('Remote Domain Lists'),
_('Download and use domain lists from remote URLs'),
);
o.default = '0';
o.rmempty = false;
o.ucisection = s.section;
o = s.taboption(
'basic',
form.DynamicList,
'remote_domain_lists',
_('Remote Domain URLs'),
_('Enter full URLs starting with http:// or https://'),
'basic',
form.DynamicList,
'remote_domain_lists',
_('Remote Domain URLs'),
_('Enter full URLs starting with http:// or https://'),
);
o.placeholder = 'URL';
o.depends('remote_domain_lists_enabled', '1');
@@ -575,26 +575,26 @@ function createConfigSection(section) {
return true;
}
return _(validation.message);
return validation.message;
};
o = s.taboption(
'basic',
form.Flag,
'local_subnet_lists_enabled',
_('Local Subnet Lists'),
_('Use the list from the router filesystem'),
'basic',
form.Flag,
'local_subnet_lists_enabled',
_('Local Subnet Lists'),
_('Use the list from the router filesystem'),
);
o.default = '0';
o.rmempty = false;
o.ucisection = s.section;
o = s.taboption(
'basic',
form.DynamicList,
'local_subnet_lists',
_('Local Subnet List Paths'),
_('Enter the list file path'),
'basic',
form.DynamicList,
'local_subnet_lists',
_('Local Subnet List Paths'),
_('Enter the list file path'),
);
o.placeholder = '/path/file.lst';
o.depends('local_subnet_lists_enabled', '1');
@@ -612,15 +612,15 @@ function createConfigSection(section) {
return true;
}
return _(validation.message);
return validation.message;
};
o = s.taboption(
'basic',
form.ListValue,
'user_subnet_list_type',
_('User Subnet List Type'),
_('Select how to add your custom subnets'),
'basic',
form.ListValue,
'user_subnet_list_type',
_('User Subnet List Type'),
_('Select how to add your custom subnets'),
);
o.value('disabled', _('Disabled'));
o.value('dynamic', _('Dynamic List'));
@@ -630,13 +630,13 @@ function createConfigSection(section) {
o.ucisection = s.section;
o = s.taboption(
'basic',
form.DynamicList,
'user_subnets',
_('User Subnets'),
_(
'Enter subnets in CIDR notation (example: 103.21.244.0/22) or single IP addresses',
),
'basic',
form.DynamicList,
'user_subnets',
_('User Subnets'),
_(
'Enter subnets in CIDR notation (example: 103.21.244.0/22) or single IP addresses',
),
);
o.placeholder = 'IP or subnet';
o.depends('user_subnet_list_type', 'dynamic');
@@ -654,20 +654,20 @@ function createConfigSection(section) {
return true;
}
return _(validation.message);
return validation.message;
};
o = s.taboption(
'basic',
form.TextValue,
'user_subnets_text',
_('User Subnets List'),
_(
'Enter subnets in CIDR notation or single IP addresses, separated by comma, space or newline. You can add comments after //',
),
'basic',
form.TextValue,
'user_subnets_text',
_('User Subnets List'),
_(
'Enter subnets in CIDR notation or single IP addresses, separated by comma, space or newline. You can add comments after //',
),
);
o.placeholder =
'103.21.244.0/22\n// Google DNS\n8.8.8.8\n1.1.1.1/32, 9.9.9.9 // Cloudflare and Quad9';
'103.21.244.0/22\n// Google DNS\n8.8.8.8\n1.1.1.1/32, 9.9.9.9 // Cloudflare and Quad9';
o.depends('user_subnet_list_type', 'text');
o.rows = 10;
o.rmempty = false;
@@ -682,7 +682,7 @@ function createConfigSection(section) {
if (!subnets.length) {
return _(
'At least one valid subnet or IP must be specified. Comments-only content is not allowed.',
'At least one valid subnet or IP must be specified. Comments-only content is not allowed.',
);
}
@@ -690,8 +690,8 @@ function createConfigSection(section) {
if (!valid) {
const errors = results
.filter((validation) => !validation.valid) // Leave only failed validations
.map((validation) => _(`${validation.value}: ${validation.message}`)); // Collect validation errors
.filter((validation) => !validation.valid) // Leave only failed validations
.map((validation) => _(`${validation.value}: ${validation.message}`)); // Collect validation errors
return [_('Validation errors:'), ...errors].join('\n');
}
@@ -700,22 +700,22 @@ function createConfigSection(section) {
};
o = s.taboption(
'basic',
form.Flag,
'remote_subnet_lists_enabled',
_('Remote Subnet Lists'),
_('Download and use subnet lists from remote URLs'),
'basic',
form.Flag,
'remote_subnet_lists_enabled',
_('Remote Subnet Lists'),
_('Download and use subnet lists from remote URLs'),
);
o.default = '0';
o.rmempty = false;
o.ucisection = s.section;
o = s.taboption(
'basic',
form.DynamicList,
'remote_subnet_lists',
_('Remote Subnet URLs'),
_('Enter full URLs starting with http:// or https://'),
'basic',
form.DynamicList,
'remote_subnet_lists',
_('Remote Subnet URLs'),
_('Enter full URLs starting with http:// or https://'),
);
o.placeholder = 'URL';
o.depends('remote_subnet_lists_enabled', '1');
@@ -733,28 +733,28 @@ function createConfigSection(section) {
return true;
}
return _(validation.message);
return validation.message;
};
o = s.taboption(
'basic',
form.Flag,
'all_traffic_from_ip_enabled',
_('IP for full redirection'),
_(
'Specify local IP addresses whose traffic will always use the configured route',
),
'basic',
form.Flag,
'all_traffic_from_ip_enabled',
_('IP for full redirection'),
_(
'Specify local IP addresses whose traffic will always use the configured route',
),
);
o.default = '0';
o.rmempty = false;
o.ucisection = s.section;
o = s.taboption(
'basic',
form.DynamicList,
'all_traffic_ip',
_('Local IPs'),
_('Enter valid IPv4 addresses'),
'basic',
form.DynamicList,
'all_traffic_ip',
_('Local IPs'),
_('Enter valid IPv4 addresses'),
);
o.placeholder = 'IP';
o.depends('all_traffic_from_ip_enabled', '1');
@@ -772,7 +772,7 @@ function createConfigSection(section) {
return true;
}
return _(validation.message);
return validation.message;
};
}

View File

@@ -0,0 +1,26 @@
'use strict';
'require baseclass';
'require form';
'require ui';
'require uci';
'require fs';
'require view.podkop.utils as utils';
'require view.podkop.main as main';
function createDashboardSection(mainSection) {
let o = mainSection.tab('dashboard', _('Dashboard'));
o = mainSection.taboption('dashboard', form.DummyValue, '_status');
o.rawhtml = true;
o.cfgvalue = () => {
main.initDashboardController();
return main.renderDashboard();
};
}
const EntryPoint = {
createDashboardSection,
};
return baseclass.extend(EntryPoint);

View File

@@ -5,66 +5,78 @@
'require view.podkop.configSection as configSection';
'require view.podkop.diagnosticTab as diagnosticTab';
'require view.podkop.additionalTab as additionalTab';
'require view.podkop.dashboardTab as dashboardTab';
'require view.podkop.utils as utils';
'require view.podkop.main as main';
const EntryNode = {
async render() {
main.injectGlobalStyles();
async render() {
main.injectGlobalStyles();
const podkopFormMap = new form.Map('podkop', '', null, ['main', 'extra']);
const podkopFormMap = new form.Map('podkop', '', null, ['main', 'extra']);
// Main Section
const mainSection = podkopFormMap.section(form.TypedSection, 'main');
mainSection.anonymous = true;
configSection.createConfigSection(mainSection);
// Main Section
const mainSection = podkopFormMap.section(form.TypedSection, 'main');
mainSection.anonymous = true;
// Additional Settings Tab (main section)
additionalTab.createAdditionalSection(mainSection);
configSection.createConfigSection(mainSection);
// Diagnostics Tab (main section)
diagnosticTab.createDiagnosticsSection(mainSection);
const podkopFormMapPromise = podkopFormMap.render().then(node => {
// Set up diagnostics event handlers
diagnosticTab.setupDiagnosticsEventHandlers(node);
// Additional Settings Tab (main section)
additionalTab.createAdditionalSection(mainSection);
// Start critical error polling for all tabs
// Diagnostics Tab (main section)
diagnosticTab.createDiagnosticsSection(mainSection);
const podkopFormMapPromise = podkopFormMap.render().then((node) => {
// Set up diagnostics event handlers
diagnosticTab.setupDiagnosticsEventHandlers(node);
// Start critical error polling for all tabs
utils.startErrorPolling();
// Add event listener to keep error polling active when switching tabs
const tabs = node.querySelectorAll('.cbi-tabmenu');
if (tabs.length > 0) {
tabs[0].addEventListener('click', function (e) {
const tab = e.target.closest('.cbi-tab');
if (tab) {
// Ensure error polling continues when switching tabs
utils.startErrorPolling();
// Add event listener to keep error polling active when switching tabs
const tabs = node.querySelectorAll('.cbi-tabmenu');
if (tabs.length > 0) {
tabs[0].addEventListener('click', function (e) {
const tab = e.target.closest('.cbi-tab');
if (tab) {
// Ensure error polling continues when switching tabs
utils.startErrorPolling();
}
});
}
// Add visibility change handler to manage error polling
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
utils.stopErrorPolling();
} else {
utils.startErrorPolling();
}
});
return node;
}
});
}
// Extra Section
const extraSection = podkopFormMap.section(form.TypedSection, 'extra', _('Extra configurations'));
extraSection.anonymous = false;
extraSection.addremove = true;
extraSection.addbtntitle = _('Add Section');
extraSection.multiple = true;
configSection.createConfigSection(extraSection);
// Add visibility change handler to manage error polling
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
utils.stopErrorPolling();
} else {
utils.startErrorPolling();
}
});
return podkopFormMapPromise;
}
}
return node;
});
// Extra Section
const extraSection = podkopFormMap.section(
form.TypedSection,
'extra',
_('Extra configurations'),
);
extraSection.anonymous = false;
extraSection.addremove = true;
extraSection.addbtntitle = _('Add Section');
extraSection.multiple = true;
configSection.createConfigSection(extraSection);
// Initial dashboard render
dashboardTab.createDashboardSection(mainSection);
// Inject core service
main.coreService();
return podkopFormMapPromise;
},
};
return view.extend(EntryNode);

View File

@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: PODKOP\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-02 19:37+0500\n"
"PO-Revision-Date: 2025-09-30 15:18+0500\n"
"POT-Creation-Date: 2025-10-07 16:55+0300\n"
"PO-Revision-Date: 2025-10-07 23:45+0300\n"
"Last-Translator: Automatically generated\n"
"Language-Team: none\n"
"Language: ru\n"
@@ -17,171 +17,6 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
msgid "Additional Settings"
msgstr "Дополнительные настройки"
msgid "Yacd enable"
msgstr "Включить Yacd"
msgid "Exclude NTP"
msgstr "Исключить NTP"
msgid "Allows you to exclude NTP protocol traffic from the tunnel"
msgstr "Позволяет исключить направление трафика NTP-протокола в туннель"
msgid "QUIC disable"
msgstr "Отключить QUIC"
msgid "For issues with the video stream"
msgstr "Для проблем с видеопотоком"
msgid "List Update Frequency"
msgstr "Частота обновления списков"
msgid "Select how often the lists will be updated"
msgstr "Выберите как часто будут обновляться списки"
msgid "DNS Protocol Type"
msgstr "Тип DNS протокола"
msgid "Select DNS protocol to use"
msgstr "Выберите протокол DNS"
msgid "DNS over HTTPS (DoH)"
msgstr "DNS через HTTPS (DoH)"
msgid "DNS over TLS (DoT)"
msgstr "DNS через TLS (DoT)"
msgid "UDP (Unprotected DNS)"
msgstr "UDP (Незащищённый DNS)"
msgid "DNS Server"
msgstr "DNS-сервер"
msgid "Select or enter DNS server address"
msgstr "Выберите или введите адрес DNS-сервера"
msgid "DNS server address cannot be empty"
msgstr "Адрес DNS-сервера не может быть пустым"
msgid "Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH"
msgstr "Неверный формат DNS-сервера. Примеры: 8.8.8.8 или dns.example.com или dns.example.com/nicedns для DoH"
msgid "Bootstrap DNS server"
msgstr "Bootstrap DNS-сервер"
msgid "The DNS server used to look up the IP address of an upstream DNS server"
msgstr "DNS-сервер, используемый для поиска IP-адреса вышестоящего DNS-сервера"
msgid "Invalid DNS server format. Example: 8.8.8.8"
msgstr "Неверный формат DNS-сервера. Пример: 8.8.8.8"
msgid "DNS Rewrite TTL"
msgstr "Перезапись TTL для DNS"
msgid "Time in seconds for DNS record caching (default: 60)"
msgstr "Время в секундах для кэширования DNS записей (по умолчанию: 60)"
msgid "TTL value cannot be empty"
msgstr "Значение TTL не может быть пустым"
msgid "TTL must be a positive number"
msgstr "TTL должно быть положительным числом"
msgid "Config File Path"
msgstr "Путь к файлу конфигурации"
msgid "Select path for sing-box config file. Change this ONLY if you know what you are doing"
msgstr "Выберите путь к файлу конфигурации sing-box. Изменяйте это, ТОЛЬКО если вы знаете, что делаете"
msgid "Cache File Path"
msgstr "Путь к файлу кэша"
msgid "Select or enter path for sing-box cache file. Change this ONLY if you know what you are doing"
msgstr "Выберите или введите путь к файлу кеша sing-box. Изменяйте это, ТОЛЬКО если вы знаете, что делаете"
msgid "Cache file path cannot be empty"
msgstr "Путь к файлу кэша не может быть пустым"
msgid "Path must be absolute (start with /)"
msgstr "Путь должен быть абсолютным (начинаться с /)"
msgid "Path must end with cache.db"
msgstr "Путь должен заканчиваться на cache.db"
msgid "Path must contain at least one directory (like /tmp/cache.db)"
msgstr "Путь должен содержать хотя бы одну директорию (например /tmp/cache.db)"
msgid "Source Network Interface"
msgstr "Сетевой интерфейс источника"
msgid "Select the network interface from which the traffic will originate"
msgstr "Выберите сетевой интерфейс, с которого будет исходить трафик"
msgid "Interface monitoring"
msgstr "Мониторинг интерфейсов"
msgid "Interface monitoring for bad WAN"
msgstr "Мониторинг интерфейсов для плохого WAN"
msgid "Interface for monitoring"
msgstr "Интерфейс для мониторинга"
msgid "Select the WAN interfaces to be monitored"
msgstr "Выберите WAN интерфейсы для мониторинга"
msgid "Interface Monitoring Delay"
msgstr "Задержка при мониторинге интерфейсов"
msgid "Delay in milliseconds before reloading podkop after interface UP"
msgstr "Задержка в миллисекундах перед перезагрузкой podkop после поднятия интерфейса"
msgid "Delay value cannot be empty"
msgstr "Значение задержки не может быть пустым"
msgid "Dont touch my DHCP!"
msgstr "Не трогать мой DHCP!"
msgid "Podkop will not change the DHCP config"
msgstr "Podkop не будет изменять конфигурацию DHCP"
msgid "Proxy download of lists"
msgstr "Загрузка списков через прокси"
msgid "Downloading all lists via main Proxy/VPN"
msgstr "Загрузка всех списков через основной прокси/VPN"
msgid "IP for exclusion"
msgstr "IP для исключения"
msgid "Specify local IP addresses that will never use the configured route"
msgstr "Укажите локальные IP-адреса, которые никогда не будут использовать настроенный маршрут"
msgid "Local IPs"
msgstr "Локальные IP адреса"
msgid "Enter valid IPv4 addresses"
msgstr "Введите действительные IPv4-адреса"
msgid "Invalid IP format. Use format: X.X.X.X (like 192.168.1.1)"
msgstr "Неверный формат IP. Используйте формат: X.X.X.X (например: 192.168.1.1)"
msgid "IP address parts must be between 0 and 255"
msgstr "Части IP-адреса должны быть между 0 и 255"
msgid "Mixed enable"
msgstr "Включить смешанный режим"
msgid "Browser port: 2080"
msgstr "Порт браузера: 2080"
msgid "URL must use one of the following protocols: "
msgstr "URL должен использовать один из следующих протоколов: "
msgid "Invalid URL format"
msgstr "Неверный формат URL"
msgid "Basic Settings"
msgstr "Основные настройки"
@@ -216,71 +51,18 @@ msgid "Config without description"
msgstr "Конфигурация без описания"
msgid ""
"Enter connection string starting with vless:// or ss:// for proxy configuration. Add comments with // for backup "
"configs"
"Enter connection string starting with vless:// or ss:// for proxy configuration. Add comments with // for backup configs"
msgstr ""
"Введите строку подключения, начинающуюся с vless:// или ss:// для настройки прокси. Добавляйте комментарии с // для "
"сохранения других конфигураций"
"Введите строку подключения, начинающуюся с vless:// или ss:// для настройки прокси. Добавляйте комментарии с // для резервных конфигураций"
msgid "No active configuration found. At least one non-commented line is required."
msgid "No active configuration found. One configuration is required."
msgstr "Активная конфигурация не найдена. Требуется хотя бы одна незакомментированная строка."
msgid "URL must start with vless:// or ss://"
msgstr "URL должен начинаться с vless:// или ss://"
msgid "Multiply active configurations found. Please leave one configuration."
msgstr "Найдено несколько активных конфигураций. Оставьте только одну."
msgid "Invalid Shadowsocks URL format: missing method and password separator \":\""
msgstr "Неверный формат URL Shadowsocks: отсутствует разделитель метода и пароля \":\""
msgid "Invalid Shadowsocks URL format"
msgstr "Неверный формат URL Shadowsocks"
msgid "Invalid Shadowsocks URL: missing server address"
msgstr "Неверный URL Shadowsocks: отсутствует адрес сервера"
msgid "Invalid Shadowsocks URL: missing server"
msgstr "Неверный URL Shadowsocks: отсутствует сервер"
msgid "Invalid Shadowsocks URL: missing port"
msgstr "Неверный URL Shadowsocks: отсутствует порт"
msgid "Invalid port number. Must be between 1 and 65535"
msgstr "Неверный номер порта. Должен быть между 1 и 65535"
msgid "Invalid Shadowsocks URL: missing or invalid server/port format"
msgstr "Неверный URL Shadowsocks: отсутствует или неверный формат сервера/порта"
msgid "Invalid VLESS URL: missing UUID"
msgstr "Неверный URL VLESS: отсутствует UUID"
msgid "Invalid VLESS URL: missing server address"
msgstr "Неверный URL VLESS: отсутствует адрес сервера"
msgid "Invalid VLESS URL: missing server"
msgstr "Неверный URL VLESS: отсутствует сервер"
msgid "Invalid VLESS URL: missing port"
msgstr "Неверный URL VLESS: отсутствует порт"
msgid "Invalid VLESS URL: missing or invalid server/port format"
msgstr "Неверный URL VLESS: отсутствует или неверный формат сервера/порта"
msgid "Invalid VLESS URL: missing query parameters"
msgstr "Неверный URL VLESS: отсутствуют параметры запроса"
msgid "Invalid VLESS URL: type must be one of tcp, raw, udp, grpc, http, ws"
msgstr "Неверный URL VLESS: тип должен быть одним из tcp, raw, udp, grpc, http, ws"
msgid "Invalid VLESS URL: security must be one of tls, reality, none"
msgstr "Неверный URL VLESS: security должен быть одним из tls, reality, none"
msgid "Invalid VLESS URL: missing pbk parameter for reality security"
msgstr "Неверный URL VLESS: отсутствует параметр pbk для security reality"
msgid "Invalid VLESS URL: missing fp parameter for reality security"
msgstr "Неверный URL VLESS: отсутствует параметр fp для security reality"
msgid "Invalid URL format: "
msgstr "Неверный формат URL: "
msgid "Invalid URL format:"
msgstr "Неверный формат URL:"
msgid "Outbound Configuration"
msgstr "Конфигурация исходящего соединения"
@@ -288,12 +70,6 @@ msgstr "Конфигурация исходящего соединения"
msgid "Enter complete outbound configuration in JSON format"
msgstr "Введите полную конфигурацию исходящего соединения в формате JSON"
msgid "JSON must contain at least type, server and server_port fields"
msgstr "JSON должен содержать как минимум поля type, server и server_port"
msgid "Invalid JSON format"
msgstr "Неверный формат JSON"
msgid "URLTest Proxy Links"
msgstr "Ссылки прокси для URLTest"
@@ -315,8 +91,26 @@ msgstr "Резолвер доменов"
msgid "Enable built-in DNS resolver for domains handled by this section"
msgstr "Включить встроенный DNS-резолвер для доменов, обрабатываемых в этом разделе"
msgid "DNS Protocol Type"
msgstr "Тип протокола DNS"
msgid "Select the DNS protocol type for the domain resolver"
msgstr "Выберите протокол DNS для резолвера доменов"
msgstr "Выберите тип протокола DNS для резолвера доменов"
msgid "DNS over HTTPS (DoH)"
msgstr "DNS через HTTPS (DoH)"
msgid "DNS over TLS (DoT)"
msgstr "DNS через TLS (DoT)"
msgid "UDP (Unprotected DNS)"
msgstr "UDP (Незащищённый DNS)"
msgid "DNS Server"
msgstr "DNS-сервер"
msgid "Select or enter DNS server address"
msgstr "Выберите или введите адрес DNS-сервера"
msgid "Community Lists"
msgstr "Списки сообщества"
@@ -328,21 +122,16 @@ msgid "Select predefined service for routing"
msgstr "Выберите предустановленные сервисы для маршрутизации"
msgid "Regional options cannot be used together"
msgstr "Нельзя использовать несколько региональных опций"
msgstr "Нельзя использовать несколько региональных опций одновременно"
#, javascript-format
msgid "Warning: %s cannot be used together with %s. Previous selections have been removed."
msgstr "Предупреждение: %s нельзя использовать вместе с %s. Предыдущие варианты были удалены."
msgid "Russia inside restrictions"
msgstr "Ограничения Russia inside"
#, javascript-format
msgid ""
"Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection."
msgstr ""
"Внимание: \"Russia inside\" может использоваться только с %s. %s уже находится в \"Russia inside\" и был удален из "
"выбора."
msgid "Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection."
msgstr "Внимание: «Russia inside» может использоваться только с %s. %s уже находится в «Russia inside» и был удалён из выбора."
msgid "User Domain List Type"
msgstr "Тип пользовательского списка доменов"
@@ -363,25 +152,19 @@ msgid "User Domains"
msgstr "Пользовательские домены"
msgid "Enter domain names without protocols (example: sub.example.com or example.com)"
msgstr "Введите доменные имена без указания протоколов (например: sub.example.com или example.com)"
msgid "Invalid domain format. Enter domain without protocol (example: sub.example.com or ru)"
msgstr "Введите имена доменов без протоколов (пример: sub.example.com или example.com)"
msgstr "Введите доменные имена без протоколов (например: sub.example.com или example.com)"
msgid "User Domains List"
msgstr "Список пользовательских доменов"
msgid "Enter domain names separated by comma, space or newline. You can add comments after //"
msgstr ""
"Введите имена доменов, разделяя их запятой, пробелом или с новой строки. Вы можете добавлять комментарии после //"
#, javascript-format
msgid "Invalid domain format: %s. Enter domain without protocol"
msgstr "Неверный формат домена: %s. Введите домен без протокола"
msgstr "Введите домены через запятую, пробел или с новой строки. Можно добавлять комментарии после //"
msgid "At least one valid domain must be specified. Comments-only content is not allowed."
msgstr ""
"Должен быть указан хотя бы один действительный домен. Содержимое, состоящее только из комментариев, не допускается."
msgstr "Необходимо указать хотя бы один действительный домен. Содержимое только из комментариев не допускается."
msgid "Validation errors:"
msgstr "Ошибки валидации:"
msgid "Local Domain Lists"
msgstr "Локальные списки доменов"
@@ -395,17 +178,14 @@ msgstr "Пути к локальным спискам доменов"
msgid "Enter the list file path"
msgstr "Введите путь к файлу списка"
msgid "Invalid path format. Path must start with \"/\" and contain valid characters"
msgstr "Неверный формат пути. Путь должен начинаться с \"/\" и содержать допустимые символы"
msgid "Remote Domain Lists"
msgstr "Удаленные списки доменов"
msgstr "Удалённые списки доменов"
msgid "Download and use domain lists from remote URLs"
msgstr "Загрузка и использование списков доменов с удаленных URL"
msgstr "Загружать и использовать списки доменов с удалённых URL"
msgid "Remote Domain URLs"
msgstr "URL удаленных доменов"
msgstr "URL удалённых доменов"
msgid "Enter full URLs starting with http:// or https://"
msgstr "Введите полные URL, начинающиеся с http:// или https://"
@@ -423,58 +203,31 @@ msgid "Select how to add your custom subnets"
msgstr "Выберите способ добавления пользовательских подсетей"
msgid "Text List (comma/space/newline separated)"
msgstr "Текстовый список (разделенный запятыми/пробелами/новыми строками)"
msgstr "Текстовый список (через запятую, пробел или новую строку)"
msgid "User Subnets"
msgstr "Пользовательские подсети"
msgid "Enter subnets in CIDR notation (example: 103.21.244.0/22) or single IP addresses"
msgstr "Введите подсети в нотации CIDR (пример: 103.21.244.0/22) или отдельные IP-адреса"
msgid "Invalid format. Use format: X.X.X.X or X.X.X.X/Y"
msgstr "Неверный формат. Используйте формат: X.X.X.X или X.X.X.X/Y"
msgid "IP address 0.0.0.0 is not allowed"
msgstr "IP адрес не может быть 0.0.0.0"
msgid "CIDR must be between 0 and 32"
msgstr "CIDR должен быть между 0 и 32"
msgstr "Введите подсети в нотации CIDR (например: 103.21.244.0/22) или отдельные IP-адреса"
msgid "User Subnets List"
msgstr "Список пользовательских подсетей"
msgid ""
"Enter subnets in CIDR notation or single IP addresses, separated by comma, space or newline. You can add comments "
"after //"
msgstr ""
"Введите подсети в нотации CIDR или отдельные IP-адреса, разделенные запятой, пробелом или новой строкой. Вы можете "
"добавлять комментарии после //"
#, javascript-format
msgid "Invalid format: %s. Use format: X.X.X.X or X.X.X.X/Y"
msgstr "Неверный формат: %s. Используйте формат: X.X.X.X или X.X.X.X/Y"
#, javascript-format
msgid "IP parts must be between 0 and 255 in: %s"
msgstr "Части IP-адреса должны быть между 0 и 255 в: %s"
#, javascript-format
msgid "CIDR must be between 0 and 32 in: %s"
msgstr "CIDR должен быть между 0 и 32 в: %s"
msgid "Enter subnets in CIDR notation or single IP addresses, separated by comma, space or newline. You can add comments after //"
msgstr "Введите подсети в нотации CIDR или IP-адреса через запятую, пробел или новую строку. Можно добавлять комментарии после //"
msgid "At least one valid subnet or IP must be specified. Comments-only content is not allowed."
msgstr ""
"Должна быть указана хотя бы одна действительная подсеть или IP. Содержимое, состоящее только из комментариев, не "
"допускается."
msgstr "Необходимо указать хотя бы одну действительную подсеть или IP. Только комментарии недопустимы."
msgid "Remote Subnet Lists"
msgstr "Удаленные списки подсетей"
msgstr "Удалённые списки подсетей"
msgid "Download and use subnet lists from remote URLs"
msgstr "Загрузка и использование списков подсетей с удаленных URL"
msgstr "Загружать и использовать списки подсетей с удалённых URL"
msgid "Remote Subnet URLs"
msgstr "URL удаленных подсетей"
msgstr "URL удалённых подсетей"
msgid "IP for full redirection"
msgstr "IP для полного перенаправления"
@@ -482,21 +235,219 @@ msgstr "IP для полного перенаправления"
msgid "Specify local IP addresses whose traffic will always use the configured route"
msgstr "Укажите локальные IP-адреса, трафик которых всегда будет использовать настроенный маршрут"
msgid "Local IPs"
msgstr "Локальные IP-адреса"
msgid "Enter valid IPv4 addresses"
msgstr "Введите действительные IPv4-адреса"
msgid "Extra configurations"
msgstr "Дополнительные конфигурации"
msgid "Add Section"
msgstr "Добавить раздел"
msgid "Dashboard"
msgstr "Дашборд"
msgid "Valid"
msgstr "Валидно"
msgid "Invalid IP address"
msgstr "Неверный IP-адрес"
msgid "Invalid domain address"
msgstr "Неверный домен"
msgid "DNS server address cannot be empty"
msgstr "Адрес DNS-сервера не может быть пустым"
msgid "Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH"
msgstr "Неверный формат DNS-сервера. Примеры: 8.8.8.8, dns.example.com или dns.example.com/nicedns для DoH"
msgid "URL must use one of the following protocols:"
msgstr "URL должен использовать один из следующих протоколов:"
msgid "Invalid URL format"
msgstr "Неверный формат URL"
msgid "Path cannot be empty"
msgstr "Путь не может быть пустым"
msgid "Invalid path format. Path must start with \"/\" and contain valid characters"
msgstr "Неверный формат пути. Путь должен начинаться с \"/\" и содержать допустимые символы"
msgid "Invalid format. Use X.X.X.X or X.X.X.X/Y"
msgstr "Неверный формат. Используйте X.X.X.X или X.X.X.X/Y"
msgid "IP address 0.0.0.0 is not allowed"
msgstr "IP-адрес 0.0.0.0 не допускается"
msgid "CIDR must be between 0 and 32"
msgstr "CIDR должен быть между 0 и 32"
msgid "Invalid Shadowsocks URL: must start with ss://"
msgstr "Неверный URL Shadowsocks: должен начинаться с ss://"
msgid "Invalid Shadowsocks URL: must not contain spaces"
msgstr "Неверный URL Shadowsocks: не должен содержать пробелов"
msgid "Invalid Shadowsocks URL: missing credentials"
msgstr "Неверный URL Shadowsocks: отсутствуют учетные данные"
msgid "Invalid Shadowsocks URL: decoded credentials must contain method:password"
msgstr "Неверный URL Shadowsocks: декодированные данные должны содержать method:password"
msgid "Invalid Shadowsocks URL: missing method and password separator \":\""
msgstr "Неверный URL Shadowsocks: отсутствует разделитель метода и пароля \":\""
msgid "Invalid Shadowsocks URL: missing server address"
msgstr "Неверный URL Shadowsocks: отсутствует адрес сервера"
msgid "Invalid Shadowsocks URL: missing server"
msgstr "Неверный URL Shadowsocks: отсутствует сервер"
msgid "Invalid Shadowsocks URL: missing port"
msgstr "Неверный URL Shadowsocks: отсутствует порт"
msgid "Invalid port number. Must be between 1 and 65535"
msgstr "Неверный номер порта. Допустимо от 1 до 65535"
msgid "Invalid Shadowsocks URL: parsing failed"
msgstr "Неверный URL Shadowsocks: ошибка разбора"
msgid "Invalid VLESS URL: must not contain spaces"
msgstr "Неверный URL VLESS: не должен содержать пробелов"
msgid "Invalid VLESS URL: must start with vless://"
msgstr "Неверный URL VLESS: должен начинаться с vless://"
msgid "Invalid VLESS URL: missing UUID"
msgstr "Неверный URL VLESS: отсутствует UUID"
msgid "Invalid VLESS URL: missing server"
msgstr "Неверный URL VLESS: отсутствует сервер"
msgid "Invalid VLESS URL: missing port"
msgstr "Неверный URL VLESS: отсутствует порт"
msgid "Invalid VLESS URL: invalid port number. Must be between 1 and 65535"
msgstr "Неверный URL VLESS: недопустимый порт (165535)"
msgid "Invalid VLESS URL: missing query parameters"
msgstr "Неверный URL VLESS: отсутствуют параметры запроса"
msgid "Invalid VLESS URL: type must be one of tcp, raw, udp, grpc, http, ws"
msgstr "Неверный URL VLESS: тип должен быть tcp, raw, udp, grpc, http или ws"
msgid "Invalid VLESS URL: security must be one of tls, reality, none"
msgstr "Неверный URL VLESS: параметр security должен быть tls, reality или none"
msgid "Invalid VLESS URL: missing pbk parameter for reality security"
msgstr "Неверный URL VLESS: отсутствует параметр pbk для security=reality"
msgid "Invalid VLESS URL: missing fp parameter for reality security"
msgstr "Неверный URL VLESS: отсутствует параметр fp для security=reality"
msgid "Invalid VLESS URL: parsing failed"
msgstr "Неверный URL VLESS: ошибка разбора"
msgid "Outbound JSON must contain at least \"type\", \"server\" and \"server_port\" fields"
msgstr "JSON должен содержать поля \"type\", \"server\" и \"server_port\""
msgid "Invalid JSON format"
msgstr "Неверный формат JSON"
msgid "Invalid Trojan URL: must start with trojan://"
msgstr "Неверный URL Trojan: должен начинаться с trojan://"
msgid "Invalid Trojan URL: must not contain spaces"
msgstr "Неверный URL Trojan: не должен содержать пробелов"
msgid "Invalid Trojan URL: must contain username, hostname and port"
msgstr "Неверный URL Trojan: должен содержать имя пользователя, хост и порт"
msgid "Invalid Trojan URL: parsing failed"
msgstr "Неверный URL Trojan: ошибка разбора"
msgid "URL must start with vless:// or ss:// or trojan://"
msgstr "URL должен начинаться с vless://, ss:// или trojan://"
msgid "Operation timed out"
msgstr "Время ожидания истекло"
msgid "HTTP error"
msgstr "Ошибка HTTP"
msgid "Unknown error"
msgstr "Неизвестная ошибка"
msgid "Fastest"
msgstr "Самый быстрый"
msgid "Dashboard currently unavailable"
msgstr "Дашборд сейчас недоступен"
msgid "Currently unavailable"
msgstr "Временно недоступно"
msgid "Traffic"
msgstr "Трафик"
msgid "Uplink"
msgstr "Исходящий"
msgid "Downlink"
msgstr "Входящий"
msgid "Traffic Total"
msgstr "Всего трафика"
msgid "System info"
msgstr "Системная информация"
msgid "Active Connections"
msgstr "Активные соединения"
msgid "Memory Usage"
msgstr "Использование памяти"
msgid "Services info"
msgstr "Информация о сервисах"
msgid "Podkop"
msgstr "Podkop"
msgid "✔ Enabled"
msgstr "✔ Включено"
msgid "✘ Disabled"
msgstr "✘ Отключено"
msgid "Sing-box"
msgstr "Sing-box"
msgid "✔ Running"
msgstr "✔ Работает"
msgid "✘ Stopped"
msgstr "✘ Остановлен"
msgid "Copied!"
msgstr "Скопировано!"
msgid "Failed to copy: "
msgstr "Не удалось скопировать: "
msgid "Loading..."
msgstr "Загрузка..."
msgid "Copy to Clipboard"
msgstr "Копировать в буфер обмена"
msgstr "Копировать в буфер"
msgid "Close"
msgstr "Закрыть"
msgid "Loading..."
msgstr "Загрузка..."
msgid "No output"
msgstr "Нет вывода"
@@ -507,7 +458,7 @@ msgid "FakeIP is not working in browser"
msgstr "FakeIP не работает в браузере"
msgid "Check DNS server on current device (PC, phone)"
msgstr "Проверьте DNS сервер на текущем устройстве (ПК, телефон)"
msgstr "Проверьте DNS-сервер на текущем устройстве (ПК, телефон)"
msgid "Its must be router!"
msgstr "Это должен быть роутер!"
@@ -522,7 +473,7 @@ msgid "Proxy IP: "
msgstr "Прокси IP: "
msgid "Proxy is not working - same IP for both domains"
msgstr "Прокси не работает - одинаковый IP для обоих доменов"
msgstr "Прокси не работает одинаковый IP для обоих доменов"
msgid "IP: "
msgstr "IP: "

File diff suppressed because it is too large Load Diff

View File

@@ -1123,15 +1123,16 @@ sing_box_configure_experimental() {
config_get cache_file "main" "cache_path" "/tmp/sing-box/cache.db"
config=$(sing_box_cm_configure_cache_file "$config" true "$cache_file" true)
local yacd_enabled
local yacd_enabled external_controller_ui
config_get_bool yacd_enabled "main" "yacd" 0
log "Configuring Clash API"
if [ "$yacd_enabled" -eq 1 ]; then
log "Configuring Clash API (yacd)"
local external_controller="0.0.0.0:9090"
log "YACD is enabled, enabling Clash API with downloadable YACD" "debug"
local external_controller_ui="ui"
config=$(sing_box_cm_configure_clash_api "$config" "$external_controller" "$external_controller_ui")
config=$(sing_box_cm_configure_clash_api "$config" "$SB_CLASH_API_CONTROLLER" "$external_controller_ui")
else
log "Clash API (yacd) is disabled, skipping configuration."
log "YACD is disabled, enabling Clash API in online mode" "debug"
config=$(sing_box_cm_configure_clash_api "$config" "$SB_CLASH_API_CONTROLLER")
fi
}

View File

@@ -48,6 +48,8 @@ SB_DIRECT_OUTBOUND_TAG="direct-out"
SB_MAIN_OUTBOUND_TAG="main-out"
# Route
SB_REJECT_RULE_TAG="reject-rule-tag"
# Experimental
SB_CLASH_API_CONTROLLER="0.0.0.0:9090"
## Lists
GITHUB_RAW_URL="https://raw.githubusercontent.com/itdoginfo/allow-domains/main"

View File

@@ -1335,8 +1335,8 @@ sing_box_cm_configure_cache_file() {
# Configure the experimental clash_api section of a sing-box JSON configuration.
# Arguments:
# config: JSON configuration (string)
# external_controller: string, URL or path for the external controller
# external_ui: string, URL or path for the external UI
# external_controller: API listening address; Clash API will be disabled if empty
# external_ui: Optional path to static web resources to serve at http://{{external-controller}}/ui
# Outputs:
# Writes updated JSON configuration to stdout
# Example:
@@ -1352,8 +1352,8 @@ sing_box_cm_configure_clash_api() {
--arg external_ui "$external_ui" \
'.experimental.clash_api = {
external_controller: $external_controller,
external_ui: $external_ui
}'
}
+ (if $external_ui != "" then { external_ui: $external_ui } else {} end)'
}
#######################################