diff --git a/fe-app-podkop/eslint.config.js b/fe-app-podkop/eslint.config.js index e30b66d..859f377 100644 --- a/fe-app-podkop/eslint.config.js +++ b/fe-app-podkop/eslint.config.js @@ -7,11 +7,20 @@ export default [ js.configs.recommended, ...tseslint.configs.recommended, { - ignores: ['dist', 'node_modules'], + ignores: ['node_modules'], }, { rules: { - 'no-console': 'warn', + 'no-console': 'off', + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], }, }, prettier, diff --git a/fe-app-podkop/package.json b/fe-app-podkop/package.json index 59d91b0..92b4f18 100644 --- a/fe-app-podkop/package.json +++ b/fe-app-podkop/package.json @@ -9,7 +9,8 @@ "lint:fix": "eslint src --ext .ts,.tsx --fix", "build": "tsup src/main.ts", "dev": "tsup src/main.ts --watch", - "test": "vitest" + "test": "vitest", + "ci": "yarn format && yarn lint --max-warnings=0 && yarn test --run && yarn build" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "8.45.0", diff --git a/fe-app-podkop/src/helpers/copyToClipboard.ts b/fe-app-podkop/src/helpers/copyToClipboard.ts new file mode 100644 index 0000000..154f4c5 --- /dev/null +++ b/fe-app-podkop/src/helpers/copyToClipboard.ts @@ -0,0 +1,29 @@ +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); + } +} diff --git a/fe-app-podkop/src/helpers/executeShellCommand.ts b/fe-app-podkop/src/helpers/executeShellCommand.ts new file mode 100644 index 0000000..4fc1547 --- /dev/null +++ b/fe-app-podkop/src/helpers/executeShellCommand.ts @@ -0,0 +1,32 @@ +import { COMMAND_TIMEOUT } from '../constants'; +import { withTimeout } from './withTimeout'; + +interface ExecuteShellCommandParams { + command: string; + args: string[]; + timeout?: number; +} + +interface ExecuteShellCommandResponse { + stdout: string; + stderr: string; + code?: number; +} + +export async function executeShellCommand({ + command, + args, + timeout = COMMAND_TIMEOUT, +}: ExecuteShellCommandParams): Promise { + try { + return withTimeout( + fs.exec(command, args), + timeout, + [command, ...args].join(' '), + ); + } catch (err) { + const error = err as Error; + + return { stdout: '', stderr: error?.message, code: 0 }; + } +} diff --git a/fe-app-podkop/src/helpers/index.ts b/fe-app-podkop/src/helpers/index.ts index 073c58a..5569d6e 100644 --- a/fe-app-podkop/src/helpers/index.ts +++ b/fe-app-podkop/src/helpers/index.ts @@ -1,3 +1,7 @@ export * from './getBaseUrl'; export * from './parseValueList'; export * from './injectGlobalStyles'; +export * from './withTimeout'; +export * from './executeShellCommand'; +export * from './copyToClipboard'; +export * from './maskIP'; diff --git a/fe-app-podkop/src/helpers/maskIP.ts b/fe-app-podkop/src/helpers/maskIP.ts new file mode 100644 index 0000000..572b5de --- /dev/null +++ b/fe-app-podkop/src/helpers/maskIP.ts @@ -0,0 +1,5 @@ +export function maskIP(ip: string = ''): string { + const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; + + return ip.replace(ipv4Regex, (_match, _p1, _p2, _p3, p4) => `XX.XX.XX.${p4}`); +} diff --git a/fe-app-podkop/src/helpers/tests/maskIp.test.js b/fe-app-podkop/src/helpers/tests/maskIp.test.js new file mode 100644 index 0000000..f9b030c --- /dev/null +++ b/fe-app-podkop/src/helpers/tests/maskIp.test.js @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { maskIP } from '../maskIP'; + +export const validIPs = [ + ['Standard private IP', '192.168.0.1', 'XX.XX.XX.1'], + ['Public IP', '8.8.8.8', 'XX.XX.XX.8'], + ['Mixed digits', '10.0.255.99', 'XX.XX.XX.99'], + ['Edge values', '255.255.255.255', 'XX.XX.XX.255'], + ['Zeros', '0.0.0.0', 'XX.XX.XX.0'], +]; + +export const invalidIPs = [ + ['Empty string', '', ''], + ['Missing octets', '192.168.1', '192.168.1'], + ['Extra octets', '1.2.3.4.5', '1.2.3.4.5'], + ['Letters inside', 'abc.def.ghi.jkl', 'abc.def.ghi.jkl'], + ['Spaces inside', '1. 2.3.4', '1. 2.3.4'], + ['Just dots', '...', '...'], + ['IP with port', '127.0.0.1:8080', '127.0.0.1:8080'], + ['IP with text', 'ip=192.168.0.1', 'ip=192.168.0.1'], +]; + +describe('maskIP', () => { + describe.each(validIPs)('Valid IPv4: %s', (_desc, ip, expected) => { + it(`masks "${ip}" → "${expected}"`, () => { + expect(maskIP(ip)).toBe(expected); + }); + }); + + describe.each(invalidIPs)( + 'Invalid or malformed IP: %s', + (_desc, ip, expected) => { + it(`returns original string for "${ip}"`, () => { + expect(maskIP(ip)).toBe(expected); + }); + }, + ); + + it('defaults to empty string if no param passed', () => { + expect(maskIP()).toBe(''); + }); +}); diff --git a/fe-app-podkop/src/helpers/withTimeout.ts b/fe-app-podkop/src/helpers/withTimeout.ts new file mode 100644 index 0000000..4475a55 --- /dev/null +++ b/fe-app-podkop/src/helpers/withTimeout.ts @@ -0,0 +1,21 @@ +export async function withTimeout( + promise: Promise, + timeoutMs: number, + operationName: string, + timeoutMessage = 'Operation timed out', +): Promise { + let timeoutId; + const start = performance.now(); + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); + }); + + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + clearTimeout(timeoutId); + const elapsed = performance.now() - start; + console.log(`[${operationName}] Execution time: ${elapsed.toFixed(2)} ms`); + } +} diff --git a/fe-app-podkop/src/luci.d.ts b/fe-app-podkop/src/luci.d.ts new file mode 100644 index 0000000..2b942de --- /dev/null +++ b/fe-app-podkop/src/luci.d.ts @@ -0,0 +1,15 @@ +declare global { + const fs: { + exec( + command: string, + args?: string[], + env?: Record, + ): Promise<{ + stdout: string; + stderr: string; + code?: number; + }>; + }; +} + +export {}; diff --git a/fe-app-podkop/src/main.ts b/fe-app-podkop/src/main.ts index 138d3fc..f3656c5 100644 --- a/fe-app-podkop/src/main.ts +++ b/fe-app-podkop/src/main.ts @@ -1,5 +1,6 @@ 'use strict'; 'require baseclass'; +'require fs'; export * from './validators'; export * from './helpers'; diff --git a/fe-app-podkop/src/validators/validateShadowsocksUrl.ts b/fe-app-podkop/src/validators/validateShadowsocksUrl.ts index b52a624..ca9e40e 100644 --- a/fe-app-podkop/src/validators/validateShadowsocksUrl.ts +++ b/fe-app-podkop/src/validators/validateShadowsocksUrl.ts @@ -31,7 +31,7 @@ export function validateShadowsocksUrl(url: string): ValidationResult { 'Invalid Shadowsocks URL: decoded credentials must contain method:password', }; } - } catch (e) { + } catch (_e) { if (!encryptedPart.includes(':') && !encryptedPart.includes('-')) { return { valid: false, @@ -73,7 +73,7 @@ export function validateShadowsocksUrl(url: string): ValidationResult { message: 'Invalid port number. Must be between 1 and 65535', }; } - } catch (e) { + } catch (_e) { return { valid: false, message: 'Invalid Shadowsocks URL: parsing failed' }; } diff --git a/fe-app-podkop/src/validators/validateTrojanUrl.ts b/fe-app-podkop/src/validators/validateTrojanUrl.ts index 03f53cd..49b85b6 100644 --- a/fe-app-podkop/src/validators/validateTrojanUrl.ts +++ b/fe-app-podkop/src/validators/validateTrojanUrl.ts @@ -18,7 +18,7 @@ export function validateTrojanUrl(url: string): ValidationResult { message: 'Invalid Trojan URL: must contain username, hostname and port', }; } - } catch (e) { + } catch (_e) { return { valid: false, message: 'Invalid Trojan URL: parsing failed' }; } diff --git a/fe-app-podkop/src/validators/validateUrl.ts b/fe-app-podkop/src/validators/validateUrl.ts index 57e883a..5b6d522 100644 --- a/fe-app-podkop/src/validators/validateUrl.ts +++ b/fe-app-podkop/src/validators/validateUrl.ts @@ -14,7 +14,7 @@ export function validateUrl( }; } return { valid: true, message: 'Valid' }; - } catch (e) { + } catch (_e) { return { valid: false, message: 'Invalid URL format' }; } } diff --git a/fe-app-podkop/src/validators/validateVlessUrl.ts b/fe-app-podkop/src/validators/validateVlessUrl.ts index 56aeb09..22189ab 100644 --- a/fe-app-podkop/src/validators/validateVlessUrl.ts +++ b/fe-app-podkop/src/validators/validateVlessUrl.ts @@ -94,7 +94,7 @@ export function validateVlessUrl(url: string): ValidationResult { }; } } - } catch (e) { + } catch (_e) { return { valid: false, message: 'Invalid VLESS URL: parsing failed' }; } diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index 130c9fb..b3a46ff 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -1,6 +1,7 @@ // This file is autogenerated, please don't change manually "use strict"; "require baseclass"; +"require fs"; // src/validators/validateIp.ts function validateIPV4(ip) { @@ -54,7 +55,7 @@ function validateUrl(url, protocols = ["http:", "https:"]) { }; } return { valid: true, message: "Valid" }; - } catch (e) { + } catch (_e) { return { valid: false, message: "Invalid URL format" }; } } @@ -143,7 +144,7 @@ function validateShadowsocksUrl(url) { message: "Invalid Shadowsocks URL: decoded credentials must contain method:password" }; } - } catch (e) { + } catch (_e) { if (!encryptedPart.includes(":") && !encryptedPart.includes("-")) { return { valid: false, @@ -176,7 +177,7 @@ function validateShadowsocksUrl(url) { message: "Invalid port number. Must be between 1 and 65535" }; } - } catch (e) { + } catch (_e) { return { valid: false, message: "Invalid Shadowsocks URL: parsing failed" }; } return { valid: true, message: "Valid" }; @@ -255,7 +256,7 @@ function validateVlessUrl(url) { }; } } - } catch (e) { + } catch (_e) { return { valid: false, message: "Invalid VLESS URL: parsing failed" }; } return { valid: true, message: "Valid" }; @@ -293,7 +294,7 @@ function validateTrojanUrl(url) { message: "Invalid Trojan URL: must contain username, hostname and port" }; } - } catch (e) { + } catch (_e) { return { valid: false, message: "Invalid Trojan URL: parsing failed" }; } return { valid: true, message: "Valid" }; @@ -366,6 +367,22 @@ function injectGlobalStyles() { ); } +// src/helpers/withTimeout.ts +async function withTimeout(promise, timeoutMs, operationName, timeoutMessage = "Operation timed out") { + let timeoutId; + const start = performance.now(); + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); + }); + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + clearTimeout(timeoutId); + const elapsed = performance.now() - start; + console.log(`[${operationName}] Execution time: ${elapsed.toFixed(2)} ms`); + } +} + // src/constants.ts var STATUS_COLORS = { SUCCESS: "#4caf50", @@ -475,6 +492,53 @@ var COMMAND_SCHEDULING = { P10_PRIORITY: 1900 // Lowest priority }; + +// src/helpers/executeShellCommand.ts +async function executeShellCommand({ + command, + args, + timeout = COMMAND_TIMEOUT +}) { + try { + return withTimeout( + fs.exec(command, args), + timeout, + [command, ...args].join(" ") + ); + } catch (err) { + const error = err; + return { stdout: "", stderr: error?.message, code: 0 }; + } +} + +// src/helpers/copyToClipboard.ts +function copyToClipboard(text) { + 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; + return { + success: false, + message: `Failed to copy: ${error.message}` + }; + } finally { + document.body.removeChild(textarea); + } +} + +// src/helpers/maskIP.ts +function maskIP(ip = "") { + const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; + return ip.replace(ipv4Regex, (_match, _p1, _p2, _p3, p4) => `XX.XX.XX.${p4}`); +} return baseclass.extend({ ALLOWED_WITH_RUSSIA_INSIDE, BOOTSTRAP_DNS_SERVER_OPTIONS, @@ -494,8 +558,11 @@ return baseclass.extend({ STATUS_COLORS, UPDATE_INTERVAL_OPTIONS, bulkValidate, + copyToClipboard, + executeShellCommand, getBaseUrl, injectGlobalStyles, + maskIP, parseValueList, validateDNS, validateDomain, @@ -507,5 +574,6 @@ return baseclass.extend({ validateSubnet, validateTrojanUrl, validateUrl, - validateVlessUrl + validateVlessUrl, + withTimeout });