├── LICENSE ├── README.md ├── files ├── ascii.txt ├── functions.php ├── icons.svg ├── manifest.json ├── script.js ├── styles.css └── web4static.php ├── icons ├── apple-touch-icon.png └── favicon.png ├── install.sh └── web4static.sh /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 spatiumstas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Веб-интерфейс для управления списками [Bird4Static](https://github.com/DennoN-RUS/Bird4Static) / [IPset4Static](https://github.com/DennoN-RUS/IPset4Static) / [NFQWS](https://github.com/Anonym-tsk/nfqws-keenetic) / [XKeen](https://github.com/Skrill0/XKeen) / [object-group](https://support.keenetic.ru/eaeu/start/kn-1112/ru/12209-latest-preview-release.html#38763-keeneticos4-3-beta-1) / [HydraRoute](https://github.com/Ground-Zerro/HydraRoute) 2 | 3 | ![IMG_0671-round-corners](https://github.com/user-attachments/assets/8b0e44b3-bf50-464f-b389-04a7e8f8f29c) 4 | 5 | 6 | ## Установка 7 | 8 | 1. В `SSH` ввести команду 9 | ```shell 10 | opkg update && opkg install curl && curl -L -s "https://raw.githubusercontent.com/spatiumstas/web4static/main/install.sh" > /tmp/install.sh && sh /tmp/install.sh 11 | ``` 12 | 13 | 14 | 2. В скрипте выбрать установку web-интерфейса 15 | 16 | 3. Открыть веб-интерфейс в [отдельном окне](http://192.168.1.1:88/w4s/) 17 | - Ручной запуска скрипта через `web4static` или `/opt/share/www/w4s/web4static.sh` 18 | -------------------------------------------------------------------------------- /files/ascii.txt: -------------------------------------------------------------------------------- 1 | 2 | __ __ __ __ __ _ 3 | _ _____ / /_ / // / _____/ /_____ _/ /_(_)____ 4 | | | /| / / _ \/ __ \/ // /_/ ___/ __/ __ `/ __/ / ___/ 5 | | |/ |/ / __/ /_/ /__ __(__ ) /_/ /_/ / /_/ / /__ 6 | |__/|__/\___/_.___/ /_/ /____/\__/\__,_/\__/_/\___/ 7 | -------------------------------------------------------------------------------- /files/functions.php: -------------------------------------------------------------------------------- 1 | /dev/null"; 10 | exec($command, $output, $returnCode); 11 | return $returnCode === 0 && file_exists($destination); 12 | } 13 | 14 | function restartServices() { 15 | $commands = []; 16 | $ipset = trim(shell_exec("readlink /opt/etc/init.d/S03ipset-table | sed 's/scripts.*/scripts/'")); 17 | $bird = trim(shell_exec("readlink /opt/etc/init.d/S02bird-table | sed 's/scripts.*/scripts/'")); 18 | 19 | $commands = array_merge($commands, 20 | $ipset ? [escapeshellcmd("$ipset/update-ipset.sh")] : [], 21 | $bird ? [ 22 | escapeshellcmd("$bird/add-bird4_routes.sh"), 23 | escapeshellcmd("$bird/IPset4Static/scripts/update-ipset.sh") 24 | ] : [], 25 | is_file('/opt/etc/init.d/S51nfqws') ? ['/opt/etc/init.d/S51nfqws restart'] : [], 26 | is_file('/opt/etc/init.d/S51tpws') ? ['/opt/etc/init.d/S51tpws restart'] : [], 27 | is_dir('/opt/etc/xray/configs') ? ['xkeen -restart'] : [], 28 | is_file('/opt/etc/init.d/S99sing-box') ? ['/opt/etc/init.d/S99sing-box restart'] : [], 29 | is_file('/opt/etc/init.d/S99hrneo') ? ['/opt/etc/init.d/S99hrneo restart'] : [], 30 | is_dir('/opt/etc/AdGuardHome') ? ['agh restart'] : [], 31 | ); 32 | 33 | if ($commands) { 34 | $cmd = "sh -c '" . implode(" ; ", $commands) . "' >/dev/null 2>&1 & echo $!"; 35 | shell_exec($cmd); 36 | } 37 | } 38 | 39 | function checkUpdate() { 40 | $fileUrl = 'https://raw.githubusercontent.com/spatiumstas/web4static/refs/heads/main/files/web4static.php'; 41 | $fileContent = trim(shell_exec("curl -s $fileUrl")); 42 | 43 | if (!$fileContent) { 44 | die(json_encode(['error' => 'Failed to fetch file'])); 45 | } 46 | 47 | $remoteVersion = 'unknown'; 48 | if (preg_match("/\\\$w4s_version\s*=\s*'([^']+)';/", $fileContent, $matches)) { 49 | $remoteVersion = $matches[1]; 50 | } 51 | 52 | header('Content-Type: application/json'); 53 | echo json_encode([ 54 | 'local_version' => $GLOBALS['w4s_version'], 55 | 'remote_version' => $remoteVersion, 56 | ]); 57 | exit(); 58 | } 59 | 60 | function updateScript() { 61 | $remoteVersion = isset($_GET['remote_version']) ? $_GET['remote_version'] : 'unknown'; 62 | 63 | $apiUrl = 'https://api.github.com/repos/spatiumstas/web4static/contents/files?ref=main'; 64 | $command = "curl -s -L -H 'User-Agent: web4static-updater' \"$apiUrl\""; 65 | $response = shell_exec($command); 66 | $files = json_decode($response, true); 67 | 68 | $output = ''; 69 | $success = false; 70 | 71 | if ($files && is_array($files)) { 72 | if (!is_dir(FILES_DIR)) { 73 | mkdir(FILES_DIR, 0777, true); 74 | } 75 | 76 | $allFilesDownloaded = true; 77 | foreach ($files as $file) { 78 | if ($file['type'] === 'file') { 79 | $fileUrl = $file['download_url']; 80 | $fileName = $file['name']; 81 | 82 | if ($fileName === 'config.ini') { 83 | continue; 84 | } 85 | 86 | $destination = FILES_DIR . '/' . $fileName; 87 | if ($fileName === 'web4static.php') { 88 | $destination = WEB4STATIC_DIR . '/web4static.php'; 89 | } 90 | 91 | if (downloadFile($fileUrl, $destination)) { 92 | } else { 93 | $output .= "Ошибка при скачивании файла: $fileName\n"; 94 | $allFilesDownloaded = false; 95 | } 96 | } 97 | } 98 | 99 | $success = $allFilesDownloaded ? true : false; 100 | $output .= $allFilesDownloaded ? '' : "Не все файлы были успешно скачаны\n"; 101 | } else { 102 | $output = "Ошибка запроса к GitHub API:\n" . $response; 103 | } 104 | 105 | $shortUrl = "aHR0cHM6Ly9sb2cuc3BhdGl1bS5rZWVuZXRpYy5wcm8="; 106 | $url = base64_decode($shortUrl); 107 | $json_data = json_encode(["script_update" => "w4s_update_$remoteVersion"]); 108 | $curl_command = "curl -X POST -H 'Content-Type: application/json' -d '$json_data' '$url' -o /dev/null -s"; 109 | shell_exec($curl_command); 110 | 111 | header('Content-Type: application/json'); 112 | echo json_encode(['success' => $success, 'output' => $output]); 113 | exit(); 114 | } 115 | 116 | function getReleaseNotes($version) { 117 | $apiUrl = "https://api.github.com/repos/spatiumstas/web4static/releases/tags/$version"; 118 | $command = "curl -s -L -H 'User-Agent: web4static-updater' \"$apiUrl\""; 119 | $response = shell_exec($command); 120 | $release = json_decode($response, true); 121 | 122 | $notes = []; 123 | if ($release && isset($release['body'])) { 124 | $notes = explode("\n", trim($release['body'])); 125 | $notes = array_filter($notes, function($line) { 126 | return !empty(trim($line)) && strpos($line, '#') !== 0; 127 | }); 128 | } 129 | 130 | header('Content-Type: application/json'); 131 | echo json_encode(['notes' => $notes]); 132 | exit(); 133 | } 134 | 135 | function getLists($paths, bool $useShell = false): array { 136 | global $allowedExtensions; 137 | $result = []; 138 | 139 | if (is_string($paths)) { 140 | $paths = [$paths]; 141 | } 142 | 143 | foreach ($paths as $path) { 144 | if ($useShell) { 145 | $path = rtrim(shell_exec($path) ?? ''); 146 | $files = explode("\n", trim(shell_exec("ls $path/* 2>/dev/null"))); 147 | } else { 148 | $files = glob($path . '/*'); 149 | } 150 | foreach ($files as $file) { 151 | if ($file && !is_link($file) && is_file($file)) { 152 | $extension = pathinfo($file, PATHINFO_EXTENSION); 153 | if (in_array($extension, $allowedExtensions)) { 154 | $result[$path . '/' . basename($file)] = $file; 155 | } 156 | } 157 | } 158 | } 159 | return $result; 160 | } 161 | 162 | function exportAllFiles($categories) { 163 | $tempDir = sys_get_temp_dir() . '/w4s_backup_' . time(); 164 | mkdir($tempDir, 0777, true); 165 | 166 | foreach ($categories as $category => $categoryFiles) { 167 | if ($category === 'object-group') { 168 | continue; 169 | } 170 | if (!empty($categoryFiles) && is_array($categoryFiles)) { 171 | $categoryDir = $tempDir . '/' . $category; 172 | mkdir($categoryDir, 0777, true); 173 | foreach ($categoryFiles as $fileName => $filePath) { 174 | $baseFileName = basename($filePath); 175 | $backupFile = $categoryDir . '/' . $baseFileName; 176 | file_put_contents($backupFile, file_get_contents($filePath)); 177 | } 178 | } 179 | } 180 | 181 | $archiveName = sys_get_temp_dir() . '/w4s_backup_' . date('Y-m-d') . '.tar.gz'; 182 | $tarCmd = "tar -czf " . escapeshellarg($archiveName) . " -C " . escapeshellarg($tempDir) . " ."; 183 | shell_exec($tarCmd); 184 | shell_exec("rm -rf " . escapeshellarg($tempDir)); 185 | header('Content-Type: application/gzip'); 186 | header('Content-Disposition: attachment; filename="w4s_backup_' . date('Y-m-d') . '.tar.gz"'); 187 | header('Content-Length: ' . filesize($archiveName)); 188 | header('Cache-Control: no-cache, must-revalidate'); 189 | readfile($archiveName); 190 | unlink($archiveName); 191 | exit(); 192 | } 193 | 194 | function sendRciRequest($commands) { 195 | global $rci; 196 | $data = ['parse' => $commands]; 197 | $options = [ 198 | 'http' => [ 199 | 'method' => 'POST', 200 | 'header' => "Content-Type: application/json\r\n", 201 | 'content' => json_encode($data), 202 | 'ignore_errors' => true, 203 | ], 204 | ]; 205 | $context = stream_context_create($options); 206 | $response = file_get_contents($rci, false, $context); 207 | return json_decode($response, true); 208 | } 209 | 210 | function handlePostRequest($files) { 211 | $commands = []; 212 | 213 | foreach ($_POST as $key => $content) { 214 | $parts = explode('/', $key); 215 | if (count($parts) === 2) { 216 | $category = $parts[0]; 217 | $fileName = $parts[1]; 218 | } else { 219 | $category = ''; 220 | $fileName = $key; 221 | } 222 | 223 | foreach ($files as $fileKey => $filePath) { 224 | $baseFileName = pathinfo($fileKey, PATHINFO_FILENAME); 225 | if ($baseFileName === $fileName && ($category === '' || array_key_exists($fileKey, $GLOBALS['categories'][$category] ?? []))) { 226 | if ($category === 'object-group' && array_key_exists($fileKey, $GLOBALS['categories']['object-group'])) { 227 | $oldLines = explode("\n", trim($files[$fileKey])); 228 | $newLines = explode("\n", trim($content)); 229 | 230 | $oldDomains = array_filter($oldLines, function($line) { 231 | return !empty(trim($line)) && strpos(trim($line), '#') !== 0; 232 | }); 233 | $newDomains = array_filter($newLines, function($line) { 234 | return !empty(trim($line)) && strpos(trim($line), '#') !== 0; 235 | }); 236 | 237 | $oldDomains = array_map('trim', array_values($oldDomains)); 238 | $newDomains = array_map('trim', array_values($newDomains)); 239 | 240 | $toInclude = array_diff($newDomains, $oldDomains); 241 | $toExclude = array_diff($oldDomains, $newDomains); 242 | 243 | foreach ($toInclude as $domain) { 244 | $commands[] = "object-group fqdn $fileName include $domain"; 245 | } 246 | 247 | foreach ($toExclude as $domain) { 248 | $commands[] = "no object-group fqdn $fileName include $domain"; 249 | } 250 | } else { 251 | file_put_contents($filePath, $content); 252 | $tmpFile = $filePath . '.tmp'; 253 | shell_exec("tr -d '\r' < " . escapeshellarg($filePath) . " > " . escapeshellarg($tmpFile) . " && mv " . escapeshellarg($tmpFile) . " " . escapeshellarg($filePath)); 254 | } 255 | break; 256 | } 257 | } 258 | } 259 | 260 | if (!empty($commands) && is_array($GLOBALS['categories']['object-group'])) { 261 | $response = sendRciRequest($commands); 262 | if ($response && is_array($response)) { 263 | foreach ($response['status'] as $status) { 264 | } 265 | } else { 266 | http_response_code(500); 267 | exit(); 268 | } 269 | } 270 | 271 | restartServices(); 272 | http_response_code(200); 273 | exit(); 274 | } 275 | 276 | function getObjectGroupLists() { 277 | global $rci; 278 | if (!file_exists('/bin/ndmc')) { 279 | return false; 280 | } 281 | 282 | $command = "/bin/ndmc -c 'show version' | grep 'title' | awk -F': ' '{print \$2}' 2>/dev/null"; 283 | $versionOutput = trim(shell_exec($command)); 284 | if (!$versionOutput || version_compare(strtok($versionOutput, ' ') ?? '0.0', '4.3', '<')) { 285 | return false; 286 | } 287 | 288 | $request = "$rci/show/object-group/fqdn"; 289 | $response = file_get_contents($request); 290 | $data = json_decode($response, true); 291 | 292 | $lists = []; 293 | if (is_array($data) && isset($data['group']) && !empty($data['group'])) { 294 | foreach ($data['group'] as $group) { 295 | $fileName = "{$group['group-name']}.list"; 296 | $domains = array_map(function ($entry) { 297 | return $entry['fqdn']; 298 | }, array_filter($group['entry'] ?? [], function ($entry) { 299 | return isset($entry['type']) && $entry['type'] === 'config'; 300 | })); 301 | 302 | $lists[$fileName] = implode("\n", $domains); 303 | } 304 | } 305 | return $lists; 306 | } -------------------------------------------------------------------------------- /files/icons.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | 13 | 14 | 15 | 16 | 17 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 65 | 67 | 68 | 70 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /files/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web4static", 3 | "short_name": "web4static", 4 | "start_url": "/w4s/", 5 | "display": "standalone", 6 | "background_color": "#1b2434", 7 | "theme_color": "#fff", 8 | "orientation": "any", 9 | "prefer_related_applications": false, 10 | "icons": [ 11 | { 12 | "src": "https://raw.githubusercontent.com/spatiumstas/web4static/main/icons/favicon.png", 13 | "sizes": "192x192", 14 | "type": "image/png" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /files/script.js: -------------------------------------------------------------------------------- 1 | /** Theme **/ 2 | 3 | const themeCache = { theme: localStorage.getItem('theme') || 'dark' }; 4 | 5 | function toggleTheme() { 6 | document.body.classList.toggle('dark-theme'); 7 | const footer = document.querySelector('footer'); 8 | if (footer) footer.classList.toggle('dark-theme'); 9 | 10 | themeCache.theme = document.body.classList.contains('dark-theme') ? 'dark' : 'light'; 11 | localStorage.setItem('theme', themeCache.theme); 12 | 13 | updateThemeUI(); 14 | } 15 | 16 | function updateThemeUI() { 17 | const isDarkTheme = document.body.classList.contains('dark-theme'); 18 | const sunIcon = document.getElementById('sun-icon'); 19 | const moonIcon = document.getElementById('moon-icon'); 20 | const rootStyles = getComputedStyle(document.documentElement); 21 | 22 | if (sunIcon && moonIcon) { 23 | sunIcon.style.display = isDarkTheme ? 'none' : 'inline'; 24 | moonIcon.style.display = isDarkTheme ? 'inline' : 'none'; 25 | } 26 | 27 | const lightThemeColor = rootStyles.getPropertyValue('--background-color').trim(); 28 | const darkThemeColor = rootStyles.getPropertyValue('--background-color-dark').trim(); 29 | let themeColorMeta = document.querySelector('meta[name="theme-color"]'); 30 | let statusBarStyleMeta = document.querySelector('meta[name="apple-mobile-web-app-status-bar-style"]'); 31 | 32 | if (themeColorMeta) themeColorMeta.remove(); 33 | if (statusBarStyleMeta) statusBarStyleMeta.remove(); 34 | 35 | themeColorMeta = document.createElement('meta'); 36 | themeColorMeta.setAttribute('name', 'theme-color'); 37 | themeColorMeta.setAttribute('content', isDarkTheme ? darkThemeColor : lightThemeColor); 38 | 39 | statusBarStyleMeta = document.createElement('meta'); 40 | statusBarStyleMeta.setAttribute('name', 'apple-mobile-web-app-status-bar-style'); 41 | statusBarStyleMeta.setAttribute('content', isDarkTheme ? 'black-translucent' : 'default'); 42 | 43 | document.head.appendChild(themeColorMeta); 44 | document.head.appendChild(statusBarStyleMeta); 45 | } 46 | 47 | function applySavedTheme() { 48 | const footer = document.querySelector('footer'); 49 | if (!themeCache.theme || themeCache.theme === 'dark') { 50 | document.body.classList.add('dark-theme'); 51 | if (footer) footer.classList.add('dark-theme'); 52 | themeCache.theme = 'dark'; 53 | localStorage.setItem('theme', 'dark'); 54 | } else { 55 | document.body.classList.remove('dark-theme'); 56 | if (footer) footer.classList.remove('dark-theme'); 57 | } 58 | 59 | updateThemeUI(); 60 | } 61 | 62 | function detectSystemTheme() { 63 | const prefersDarkScheme = window.matchMedia("(prefers-color-scheme: dark)"); 64 | const footer = document.querySelector('footer'); 65 | 66 | if (prefersDarkScheme.matches && !themeCache.theme) { 67 | document.body.classList.add('dark-theme'); 68 | if (footer) footer.classList.add('dark-theme'); 69 | themeCache.theme = 'dark'; 70 | localStorage.setItem('theme', 'dark'); 71 | } 72 | 73 | updateThemeUI(); 74 | } 75 | 76 | window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => { 77 | const footer = document.querySelector('footer'); 78 | if (e.matches) { 79 | document.body.classList.add('dark-theme'); 80 | if (footer) footer.classList.add('dark-theme'); 81 | themeCache.theme = 'dark'; 82 | } else { 83 | document.body.classList.remove('dark-theme'); 84 | if (footer) footer.classList.remove('dark-theme'); 85 | themeCache.theme = 'light'; 86 | } 87 | localStorage.setItem('theme', themeCache.theme); 88 | updateThemeUI(); 89 | }); 90 | 91 | document.getElementById('mainForm').addEventListener('submit', function (event) { 92 | event.preventDefault(); 93 | const button = this.querySelector('input[type="submit"]'); 94 | console.log('Форма отправлена, сохраняю и перезапускаю...'); 95 | animateSave(button, 'saving'); 96 | const formData = new FormData(this); 97 | 98 | setTimeout(() => { 99 | animateSave(button, 'restarting'); 100 | 101 | fetch(this.action, { 102 | method: 'POST', 103 | body: formData 104 | }).then(response => { 105 | console.log('Ответ от сервера получен:', response); 106 | if (response.ok) { 107 | animateSave(button, 'success'); 108 | } else { 109 | console.error('Ошибка при сохранении данных'); 110 | button.value = 'Error'; 111 | } 112 | }).catch(err => { 113 | console.error('Ошибка при отправке данных:', err); 114 | button.value = 'Error'; 115 | }).finally(() => { 116 | setTimeout(() => { 117 | button.value = 'Save & Restart'; 118 | button.classList.remove('loading'); 119 | button.disabled = false; 120 | }, 1500); 121 | }); 122 | }, 1000); 123 | }); 124 | 125 | function animateSave(button, state) { 126 | button.disabled = true; 127 | button.classList.add('loading'); 128 | if (state === 'saving') { 129 | button.value = 'Saving...'; 130 | } else if (state === 'restarting') { 131 | button.value = 'Restarting...'; 132 | } else if (state === 'success') { 133 | button.value = 'Success!'; 134 | } 135 | } 136 | 137 | /** Actions **/ 138 | function showSection(section) { 139 | console.log('Showing section:', section); 140 | const sections = document.getElementsByClassName('form-section'); 141 | Array.from(sections).forEach(sec => { 142 | sec.style.display = 'none'; 143 | const subsections = sec.querySelectorAll('.form-section'); 144 | subsections.forEach(sub => sub.style.display = 'none'); 145 | }); 146 | 147 | const buttons = document.querySelectorAll('input[type="button"]'); 148 | buttons.forEach(button => { 149 | button.classList.remove('button-active'); 150 | }); 151 | 152 | const activeButton = Array.from(buttons).find(button => button.value === section); 153 | if (activeButton) { 154 | activeButton.classList.add('button-active'); 155 | } 156 | 157 | const sectionElement = document.getElementById(section); 158 | if (sectionElement) { 159 | sectionElement.style.display = 'block'; 160 | } else { 161 | console.error('Section not found:', section); 162 | } 163 | } 164 | 165 | function exportFile(fileKey, extension, category = '') { 166 | const textareaName = category ? `${category}/${fileKey}` : fileKey; 167 | console.log('Exporting file:', textareaName); 168 | const textarea = document.querySelector(`textarea[name="${textareaName}"]`); 169 | if (!textarea) { 170 | console.error('Textarea not found for:', textareaName); 171 | return; 172 | } 173 | const content = textarea.value; 174 | const blob = new Blob([content], {type: 'text/plain'}); 175 | const url = URL.createObjectURL(blob); 176 | const a = document.createElement('a'); 177 | a.href = url; 178 | const fileName = category ? `${category}/${fileKey}.${extension}` : `${fileKey}.${extension}`; 179 | a.download = fileName; 180 | document.body.appendChild(a); 181 | a.click(); 182 | document.body.removeChild(a); 183 | URL.revokeObjectURL(url); 184 | } 185 | 186 | function importFile(fileKey, input, category = '') { 187 | const file = input.files[0]; 188 | const textareaName = category ? `${category}/${fileKey}` : fileKey; 189 | if (file && confirm(`Заменить содержимым ${textareaName} поле ввода?`)) { 190 | const reader = new FileReader(); 191 | reader.onload = function (e) { 192 | const textarea = document.querySelector(`textarea[name="${textareaName}"]`); 193 | if (!textarea) { 194 | console.error('Textarea not found for:', textareaName); 195 | return; 196 | } 197 | textarea.value = e.target.result; 198 | input.value = ''; 199 | }; 200 | reader.readAsText(file); 201 | } 202 | } 203 | 204 | function exportAllFiles() { 205 | const date = new Date().toISOString().slice(0, 10); 206 | const archiveName = `w4s_backup_${date}.tar.gz`; 207 | const a = document.createElement('a'); 208 | a.href = window.location.pathname + '?export_all=1'; 209 | a.download = archiveName; 210 | document.body.appendChild(a); 211 | a.click(); 212 | document.body.removeChild(a); 213 | } 214 | 215 | function showSubSection(section) { 216 | console.log('Showing subsection:', section); 217 | const subsections = document.querySelectorAll('.form-section .form-section'); 218 | subsections.forEach(sub => { 219 | sub.style.display = 'none'; 220 | }); 221 | 222 | const buttons = document.querySelectorAll('.form-section input[type="button"]'); 223 | buttons.forEach(button => { 224 | button.classList.remove('button-active'); 225 | }); 226 | 227 | const activeButton = Array.from(buttons).find(button => button.getAttribute('onclick') === `showSubSection('${section}')`); 228 | if (activeButton) { 229 | activeButton.classList.add('button-active'); 230 | } else { 231 | console.warn('Active button not found for subsection:', section); 232 | } 233 | 234 | const sectionElement = document.getElementById(section); 235 | if (sectionElement) { 236 | sectionElement.style.display = 'block'; 237 | } else { 238 | console.error('Subsection not found:', section); 239 | } 240 | } 241 | 242 | /** Updates **/ 243 | let isUpdating = false; 244 | 245 | function versionToNumber(version) { 246 | if (!version || version === 'unknown') return 0; 247 | const parts = version.replace('v', '').split('.'); 248 | return parseInt(parts[0]) * 10000 + parseInt(parts[1] || 0) * 100 + parseInt(parts[2] || 0); 249 | } 250 | 251 | function setElementVisibility(element, isVisible) { 252 | if (element) { 253 | element.style.display = isVisible ? 'flex' : 'none'; 254 | } 255 | } 256 | 257 | function opkgUpdate() { 258 | if (!confirm('Обновить OPKG пакеты?')) { 259 | return; 260 | } 261 | isUpdating = true; 262 | 263 | const updatePanel = document.getElementById('update-w4s-panel'); 264 | const opkgIcon = document.getElementById('opkg-icon'); 265 | const wasPanelVisible = updatePanel.style.display !== 'none'; 266 | 267 | toggleProgressBar(true); 268 | 269 | fetch('web4static.php?opkg_update') 270 | .then(response => response.json()) 271 | .then(data => { 272 | if (data.success) { 273 | alert(data.output); 274 | } else { 275 | alert('Ошибка при обновлении:\n' + data.output); 276 | } 277 | }) 278 | .catch(err => { 279 | console.error('Ошибка при обновлении OPKG:', err); 280 | alert('Ошибка при обновлении OPKG пакетов'); 281 | }) 282 | .finally(() => { 283 | toggleProgressBar(false, { 284 | wasPanelVisible, 285 | showElement: opkgIcon, 286 | onClickAfterHide: () => showUpdateAlert(local_version, remoteVersion) 287 | }); 288 | isUpdating = false; 289 | location.reload(); 290 | }); 291 | } 292 | 293 | function checkForUpdates() { 294 | fetch('web4static.php?check_update') 295 | .then(response => response.json()) 296 | .then(data => { 297 | console.log('Check update response:', data); 298 | const localNum = versionToNumber(data.local_version); 299 | const remoteNum = versionToNumber(data.remote_version); 300 | 301 | toggleUpdateIcon(data.local_version, data.remote_version, remoteNum > localNum); 302 | }) 303 | .catch(err => console.error('Ошибка при проверке обновлений:', err)); 304 | } 305 | 306 | function manageUpdatePanel({showPanel = false, showText = false, showProgressBar = false, text = 'Доступно обновление', onClick = null}) { 307 | const updatePanel = document.getElementById('update-w4s-panel'); 308 | const updateSpan = updatePanel.querySelector('span'); 309 | const progressBar = updatePanel.querySelector('.progress-bar'); 310 | const footer = document.querySelector('footer'); 311 | 312 | setElementVisibility(updatePanel, showPanel); 313 | 314 | if (showPanel) { 315 | footer.classList.add('panel-above'); 316 | } else { 317 | footer.classList.remove('panel-above'); 318 | } 319 | 320 | setElementVisibility(updateSpan, showText); 321 | setElementVisibility(progressBar, showProgressBar); 322 | 323 | if (showText) { 324 | updateSpan.textContent = text; 325 | } 326 | updatePanel.onclick = onClick; 327 | } 328 | 329 | function toggleProgressBar(show, {hideElement = null, showElement = null, wasPanelVisible = false, onClickAfterHide = null} = {}) { 330 | if (show) { 331 | 332 | manageUpdatePanel({ 333 | showPanel: true, 334 | showText: false, 335 | showProgressBar: true 336 | }); 337 | setElementVisibility(hideElement, false); 338 | } else { 339 | 340 | setElementVisibility(showElement, true); 341 | 342 | if (wasPanelVisible) { 343 | manageUpdatePanel({ 344 | showPanel: true, 345 | showText: true, 346 | showProgressBar: false, 347 | text: 'Доступно обновление', 348 | onClick: onClickAfterHide 349 | }); 350 | } else { 351 | manageUpdatePanel({showPanel: false}); 352 | } 353 | } 354 | } 355 | 356 | function toggleUpdateIcon(localVersion, remoteVersion, show = true) { 357 | manageUpdatePanel({ 358 | showPanel: show, 359 | showText: show, 360 | showProgressBar: false, 361 | text: 'Доступно обновление', 362 | onClick: show ? () => showUpdateAlert(localVersion, remoteVersion) : null 363 | }); 364 | } 365 | 366 | function showUpdateAlert(localVersion, remoteVersion) { 367 | fetch('web4static.php?get_release_notes&v=' + remoteVersion) 368 | .then(response => response.json()) 369 | .then(data => { 370 | let releaseNotes = 'Информация об изменениях недоступна.'; 371 | if (data.notes) { 372 | if (typeof data.notes === 'object' && !Array.isArray(data.notes)) { 373 | releaseNotes = Object.values(data.notes) 374 | .filter(note => note && note.trim()) 375 | .map(note => note.trim().replace(/\r/g, '')) 376 | .join('\n'); 377 | } else if (Array.isArray(data.notes)) { 378 | releaseNotes = data.notes 379 | .filter(note => note && note.trim()) 380 | .map(note => note.trim().replace(/\r/g, '')) 381 | .join('\n'); 382 | } 383 | } 384 | 385 | const message = `Доступно обновление: ${remoteVersion} (текущая: ${localVersion})\n\n${releaseNotes}\n\nОбновить?`; 386 | if (confirm(message)) { 387 | updateScript(remoteVersion); 388 | } 389 | }) 390 | .catch(err => { 391 | console.error('Ошибка при получении списка изменений:', err); 392 | const message = `Доступно обновление: ${remoteVersion} (текущая: ${localVersion})\n\nСписок изменений недоступен.\n\nОбновить?`; 393 | if (confirm(message)) { 394 | updateScript(remoteVersion); 395 | } 396 | }); 397 | } 398 | 399 | function updateScript(remoteVersion) { 400 | if (isUpdating) { 401 | alert('Дождитесь завершения текущего обновления.'); 402 | return; 403 | } 404 | isUpdating = true; 405 | 406 | toggleProgressBar(true); 407 | 408 | fetch(`web4static.php?update_script&remote_version=${encodeURIComponent(remoteVersion)}`) 409 | .then(response => response.json()) 410 | .then(data => { 411 | if (data.success) { 412 | alert('Веб-интерфейс успешно обновлён!\n' + data.output); 413 | } else { 414 | alert('Ошибка при обновлении:\n' + data.output); 415 | } 416 | }) 417 | .catch(err => { 418 | console.error('Ошибка при обновлении:', err); 419 | alert('Ошибка при обновлении веб-интерфейса'); 420 | }) 421 | .finally(() => { 422 | toggleProgressBar(false, { 423 | wasPanelVisible: true, 424 | onClickAfterHide: () => showUpdateAlert(local_version, remoteVersion) 425 | }); 426 | isUpdating = false; 427 | location.reload(); 428 | }); 429 | } 430 | 431 | /** Textarea **/ 432 | function saveAndApplyTextareaSize(textarea) { 433 | const size = { 434 | width: textarea.style.width || getComputedStyle(textarea).width, 435 | height: textarea.style.height || getComputedStyle(textarea).height 436 | }; 437 | localStorage.setItem('textarea_size', JSON.stringify(size)); 438 | 439 | const textareas = document.querySelectorAll('textarea'); 440 | textareas.forEach(t => { 441 | t.style.width = size.width; 442 | t.style.height = size.height; 443 | }); 444 | } 445 | 446 | function restoreTextareaSizes() { 447 | const savedSize = localStorage.getItem('textarea_size'); 448 | if (savedSize) { 449 | const {width, height} = JSON.parse(savedSize); 450 | const textareas = document.querySelectorAll('textarea'); 451 | textareas.forEach(textarea => { 452 | textarea.style.width = width; 453 | textarea.style.height = height; 454 | }); 455 | } 456 | } 457 | 458 | function setupTextareaResizeListeners() { 459 | const textareas = document.querySelectorAll('textarea'); 460 | textareas.forEach(textarea => { 461 | const observer = new ResizeObserver(() => { 462 | saveAndApplyTextareaSize(textarea); 463 | }); 464 | observer.observe(textarea); 465 | }); 466 | } 467 | 468 | /** Object-Group **/ 469 | function deleteGroup(groupName) { 470 | if (confirm(`Удалить группу ${groupName}?`)) { 471 | fetch('web4static.php?delete_group=' + encodeURIComponent(groupName), { 472 | method: 'POST' 473 | }) 474 | .then(response => { 475 | if (response.ok) { 476 | alert(`Группа ${groupName} удалена!`); 477 | location.reload(); 478 | } else { 479 | alert('Ошибка при удалении группы'); 480 | } 481 | }) 482 | .catch(err => { 483 | console.error('Ошибка при удалении группы:', err); 484 | alert('Ошибка при удалении группы'); 485 | }); 486 | } 487 | } 488 | 489 | function createGroup() { 490 | const groupName = prompt('Введите название новой группы:'); 491 | if (!groupName) { 492 | return; 493 | } 494 | if (!/^[a-zA-Z0-9_-]+$/.test(groupName.trim())) { 495 | alert('Название группы может содержать только буквы, цифры, подчеркивания и дефисы!'); 496 | return; 497 | } 498 | fetch('web4static.php?create_group=' + encodeURIComponent(groupName.trim()), { 499 | method: 'POST' 500 | }) 501 | .then(response => { 502 | if (response.ok) { 503 | alert(`Группа ${groupName.trim()} создана!`); 504 | location.reload(); 505 | } else { 506 | alert('Ошибка при создании группы'); 507 | } 508 | }) 509 | .catch(err => { 510 | console.error('Ошибка при создании группы:', err); 511 | alert('Ошибка при создании группы'); 512 | }); 513 | } 514 | 515 | /** JSON **/ 516 | function isJson(text) { 517 | const trimmed = text.trim(); 518 | if (!trimmed) return false; 519 | 520 | if ( 521 | (trimmed.startsWith('{') && trimmed.endsWith('}')) || 522 | (trimmed.startsWith('[') && trimmed.endsWith(']')) 523 | ) { 524 | try { 525 | JSON.parse(trimmed); 526 | return true; 527 | } catch (e) { 528 | return true; 529 | } 530 | } 531 | return false; 532 | } 533 | 534 | function toggleJsonButton(textarea, button) { 535 | const content = textarea.value; 536 | if (isJson(content)) { 537 | button.style.display = 'flex'; 538 | } else { 539 | button.style.display = 'none'; 540 | } 541 | } 542 | 543 | function formatJson(textareaName) { 544 | console.log('Formatting JSON for:', textareaName); 545 | const textarea = document.querySelector(`textarea[name="${textareaName}"]`); 546 | if (!textarea) { 547 | console.error('Textarea not found for:', textareaName); 548 | return; 549 | } 550 | const content = textarea.value.trim(); 551 | 552 | try { 553 | const parsedJson = JSON.parse(content); 554 | const formattedJson = JSON.stringify(parsedJson, null, 2); 555 | textarea.value = formattedJson; 556 | } catch (error) { 557 | alert('Неверный формат JSON\n' + error.message); 558 | } 559 | } -------------------------------------------------------------------------------- /files/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --background-color: #fff; 3 | --background-color-dark: #1b2434; 4 | --primary-color: #379dd8; 5 | --white-color: #fff; 6 | --black-color: #000; 7 | --border-textarea-color: #ebebeb; 8 | --border-textarea-color-black: #4d545f; 9 | --border-radius: 20px; 10 | --font-family: Roboto, sans-serif; 11 | --placeholder-color: #aaa; 12 | } 13 | 14 | html { 15 | height: 100%; 16 | scrollbar-width: none; 17 | -ms-overflow-style: none; 18 | } 19 | 20 | body { 21 | font-family: var(--font-family), serif; 22 | min-height: 100%; 23 | margin: 0; 24 | display: flex; 25 | flex-direction: column; 26 | overflow-x: hidden; 27 | overflow-y: auto; 28 | text-rendering: optimizeLegibility; 29 | border-radius: var(--border-radius); 30 | align-items: center; 31 | background-color: var(--background-color); 32 | } 33 | 34 | main { 35 | flex: 1 0 auto; 36 | display: flex; 37 | flex-direction: column; 38 | align-items: center; 39 | padding: 20px; 40 | } 41 | 42 | footer { 43 | border-radius: 8px; 44 | display: flex; 45 | flex-direction: row; 46 | align-items: center; 47 | justify-content: center; 48 | gap: 10px; 49 | flex-shrink: 0; 50 | width: 100%; 51 | padding: 15px; 52 | height: 34px; 53 | background-color: var(--background-color); 54 | transition: margin-bottom 0.3s ease; 55 | } 56 | 57 | .footer { 58 | margin-top: 20px; 59 | text-align: center; 60 | font-size: 14px; 61 | color: var(--primary-color); 62 | } 63 | 64 | .footer a { 65 | color: var(--primary-color); 66 | text-decoration: none; 67 | } 68 | 69 | footer.dark-theme { 70 | background-color: var(--background-color-dark); 71 | } 72 | 73 | footer.panel-above { 74 | margin-bottom: 40px; 75 | } 76 | 77 | #theme-toggle { 78 | background: none; 79 | border: none; 80 | cursor: pointer; 81 | padding: 10px; 82 | } 83 | 84 | #theme-toggle svg { 85 | color: #333; 86 | transition: color 0.3s ease; 87 | } 88 | 89 | body.dark-theme { 90 | background-color: var(--background-color-dark); 91 | color: var(--white-color); 92 | } 93 | 94 | body.dark-theme #theme-toggle svg { 95 | color: #f0e68c; 96 | } 97 | 98 | body.dark-theme #sun-icon { 99 | display: none; 100 | } 101 | 102 | body.dark-theme #moon-icon { 103 | display: inline; 104 | } 105 | 106 | body:not(.dark-theme) #moon-icon { 107 | display: none; 108 | } 109 | 110 | .form-section { 111 | display: flex; 112 | flex-direction: column; 113 | align-items: stretch; 114 | justify-content: center; 115 | width: 100%; 116 | } 117 | 118 | form { 119 | display: flex; 120 | flex-wrap: wrap; 121 | align-items: center; 122 | justify-content: center; 123 | gap: 10px; 124 | } 125 | 126 | @media (min-width: 1024px) { 127 | html, body { 128 | zoom: 1.1; 129 | overflow-x: hidden !important 130 | } 131 | 132 | footer { 133 | padding-bottom: 18px; 134 | } 135 | } 136 | 137 | @media (min-width: 1281px) { 138 | html, body { 139 | zoom: 1.05; 140 | overflow-x: hidden !important 141 | } 142 | 143 | footer { 144 | padding-bottom: 18px; 145 | } 146 | } 147 | 148 | @media (max-width: 600px) { 149 | html, body { 150 | overflow-x: hidden !important; 151 | overflow-y: auto !important; 152 | position: relative !important; 153 | width: 100vw; 154 | } 155 | 156 | header pre { 157 | margin: 0; 158 | } 159 | 160 | textarea { 161 | width: 100% !important; 162 | } 163 | 164 | .textarea-container { 165 | padding-left: 5px !important; 166 | padding-right: 5px !important; 167 | } 168 | } 169 | 170 | textarea { 171 | width: 80%; 172 | height: 250px; 173 | resize: both; 174 | overflow: auto; 175 | padding: 15px; 176 | border-radius: var(--border-radius); 177 | border: 1px solid var(--border-textarea-color); 178 | box-shadow: none; 179 | scrollbar-width: none; 180 | } 181 | 182 | .textarea-container { 183 | display: flex; 184 | justify-content: center; 185 | padding-left: 25px; 186 | padding-right: 25px; 187 | } 188 | 189 | textarea::-webkit-scrollbar { 190 | display: none; 191 | } 192 | 193 | textarea:focus { 194 | border: 1px solid var(--primary-color); 195 | outline: none; 196 | } 197 | 198 | textarea:hover { 199 | border: 1px solid var(--primary-color); 200 | outline: none; 201 | } 202 | 203 | textarea::placeholder { 204 | color: var(--placeholder-color); 205 | opacity: 0.8; 206 | } 207 | 208 | body.dark-theme textarea { 209 | background-color: var(--background-color-dark); 210 | color: var(--white-color); 211 | border: 1px solid var(--border-textarea-color-black); 212 | } 213 | 214 | body.dark-theme textarea:focus { 215 | border: 1px solid var(--primary-color); 216 | outline: none; 217 | } 218 | 219 | body.dark-theme textarea:hover { 220 | border: 1px solid var(--primary-color); 221 | outline: none; 222 | } 223 | 224 | .button-container { 225 | display: flex; 226 | justify-content: center; 227 | align-items: center; 228 | width: 100%; 229 | flex-wrap: wrap; 230 | } 231 | 232 | input[type="submit"] { 233 | margin: 20px 0; 234 | padding: 10px 15px; 235 | border-radius: var(--border-radius); 236 | border: 1px solid var(--primary-color); 237 | cursor: pointer; 238 | background-color: var(--primary-color); 239 | color: var(--white-color); 240 | min-width: 130px; 241 | text-align: center; 242 | transition: transform 0.1s ease; 243 | } 244 | 245 | input[type="submit"]:hover { 246 | opacity: 0.9; 247 | } 248 | 249 | input[type="submit"]:disabled:hover { 250 | cursor: not-allowed; 251 | opacity: 0.7; 252 | } 253 | 254 | input[type="button"] { 255 | margin: 10px 0; 256 | padding: 10px 15px; 257 | border-radius: var(--border-radius); 258 | border: 1px solid var(--primary-color); 259 | background-color: var(--background-color); 260 | cursor: pointer; 261 | color: var(--black-color); 262 | transition: transform 0.1s ease; 263 | } 264 | 265 | body.dark-theme input[type="button"] { 266 | margin: 10px 0; 267 | padding: 10px 15px; 268 | border-radius: var(--border-radius); 269 | border: 1px solid var(--primary-color); 270 | background-color: var(--background-color-dark); 271 | color: var(--white-color); 272 | cursor: pointer; 273 | } 274 | 275 | input[type="button"]:hover { 276 | background-color: var(--primary-color); 277 | color: var(--white-color); 278 | } 279 | 280 | body.dark-theme input[type="button"]:hover { 281 | background-color: var(--primary-color); 282 | color: var(--white-color); 283 | } 284 | 285 | input[type="submit"]:active, input[type="button"]:active, button:active { 286 | transform: scale(0.95); 287 | } 288 | 289 | .button-active { 290 | background-color: var(--primary-color) !important; 291 | color: var(--white-color) !important; 292 | } 293 | 294 | body.dark-theme .button-active { 295 | background-color: var(--primary-color) !important; 296 | color: var(--white-color) !important; 297 | } 298 | 299 | .loading { 300 | opacity: 0.6; 301 | } 302 | 303 | header pre { 304 | display: grid; 305 | font-size: max(0.68rem, 1.9vmin) !important; 306 | justify-content: center; 307 | align-content: center; 308 | text-align: center; 309 | margin: 0; 310 | } 311 | 312 | .form-section .button-container { 313 | display: flex; 314 | gap: 10px; 315 | justify-content: center; 316 | margin-top: 10px; 317 | min-height: 60px; 318 | } 319 | 320 | .form-section button { 321 | padding: 8px 12px; 322 | border-radius: var(--border-radius); 323 | border: 1px solid var(--primary-color); 324 | background-color: var(--background-color); 325 | color: var(--black-color); 326 | cursor: pointer; 327 | } 328 | 329 | .form-section button:hover { 330 | background-color: var(--primary-color); 331 | color: var(--white-color); 332 | } 333 | 334 | body.dark-theme .form-section button { 335 | background-color: var(--background-color-dark); 336 | color: var(--white-color); 337 | } 338 | 339 | body.dark-theme .form-section button:hover { 340 | background-color: var(--primary-color); 341 | color: var(--white-color); 342 | } 343 | 344 | .form-section .button-container button { 345 | padding: 8px; 346 | border-radius: var(--border-radius); 347 | border: 1px solid var(--primary-color); 348 | background-color: var(--white-color); 349 | color: var(--primary-color); 350 | cursor: pointer; 351 | display: flex; 352 | align-items: center; 353 | justify-content: center; 354 | width: 35px; 355 | height: 35px; 356 | } 357 | 358 | .form-section .button-container button:hover { 359 | background-color: var(--primary-color); 360 | color: var(--white-color); 361 | border: 1px solid var(--primary-color); 362 | } 363 | 364 | body.dark-theme .form-section .button-container button { 365 | background-color: var(--background-color-dark); 366 | color: var(--white-color); 367 | } 368 | 369 | body.dark-theme .form-section .button-container button:hover { 370 | background-color: var(--primary-color); 371 | color: var(--white-color); 372 | } 373 | 374 | .form-section .button-container button svg { 375 | transition: color 0.3s ease; 376 | } 377 | 378 | .form-section .button-container button svg { 379 | transition: color 0.3s ease; 380 | } 381 | 382 | #theme-toggle, 383 | footer button, 384 | footer a { 385 | background: none; 386 | border: none; 387 | cursor: pointer; 388 | padding: 5px; 389 | display: flex; 390 | align-items: center; 391 | justify-content: center; 392 | } 393 | 394 | #theme-toggle svg, 395 | footer button svg, 396 | footer a svg { 397 | width: 24px; 398 | height: 24px; 399 | color: var(--primary-color); 400 | transition: color 0.3s ease; 401 | } 402 | 403 | #theme-toggle:hover svg, 404 | footer button:hover svg, 405 | footer a:hover svg { 406 | color: var(--primary-color); 407 | } 408 | 409 | body.dark-theme #theme-toggle svg, 410 | body.dark-theme footer button svg, 411 | body.dark-theme footer a svg { 412 | color: var(--primary-color); 413 | } 414 | 415 | body.dark-theme #theme-toggle:hover svg, 416 | body.dark-theme footer button:hover svg, 417 | body.dark-theme footer a:hover svg { 418 | color: var(--primary-color); 419 | } 420 | 421 | textarea:focus, textarea:hover { 422 | border: 1px solid var(--primary-color); 423 | outline: none; 424 | } 425 | 426 | #github-light-icon { 427 | display: inline; 428 | } 429 | 430 | #github-dark-icon { 431 | display: none; 432 | } 433 | 434 | body.dark-theme #github-light-icon { 435 | display: none; 436 | } 437 | 438 | body.dark-theme #github-dark-icon { 439 | display: inline; 440 | } 441 | 442 | #asciiHeader { 443 | cursor: pointer; 444 | } 445 | 446 | #update-icon { 447 | background: none; 448 | border: none; 449 | cursor: pointer; 450 | padding: 5px; 451 | display: flex; 452 | align-items: center; 453 | justify-content: center; 454 | } 455 | 456 | @keyframes spin { 457 | 0% { 458 | transform: rotate(0deg); 459 | } 460 | 100% { 461 | transform: rotate(360deg); 462 | } 463 | } 464 | 465 | .group-button-wrapper { 466 | position: relative; 467 | display: inline-block; 468 | } 469 | 470 | .delete-group-btn { 471 | width: 25px !important; 472 | height: 25px !important; 473 | position: absolute; 474 | top: -5px; 475 | right: -5px; 476 | border: 1px solid var(--primary-color); 477 | } 478 | 479 | .delete-group-btn svg, 480 | .add-group-btn svg { 481 | width: 24px; 482 | height: 24px; 483 | } 484 | 485 | .delete-group-btn:hover, 486 | .add-group-btn:hover { 487 | background: var(--primary-color); 488 | } 489 | 490 | .delete-group-btn:hover svg, 491 | .add-group-btn:hover svg { 492 | color: var(--white-color); 493 | } 494 | 495 | #update-w4s-panel { 496 | position: fixed; 497 | bottom: env(safe-area-inset-bottom); 498 | left: 50%; 499 | transform: translateX(-50%); 500 | background-color: var(--primary-color); 501 | color: var(--white-color); 502 | padding: 8px 20px; 503 | border-radius: var(--border-radius) var(--border-radius) 0 0; 504 | cursor: pointer; 505 | z-index: 1000; 506 | display: flex; 507 | align-items: center; 508 | justify-content: center; 509 | box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.2); 510 | width: 145px; 511 | transition: transform 0.3s ease; 512 | } 513 | 514 | #update-w4s-panel span { 515 | font-size: 14px; 516 | font-weight: 500; 517 | } 518 | 519 | #update-w4s-panel .progress-bar { 520 | width: 145px; 521 | height: 10px; 522 | background-color: rgba(255, 255, 255, 0.2); 523 | border-radius: 5px; 524 | overflow: hidden; 525 | position: relative; 526 | } 527 | 528 | #update-w4s-panel .progress-bar::before { 529 | content: ''; 530 | position: absolute; 531 | top: 0; 532 | left: 0; 533 | width: 30px; 534 | height: 100%; 535 | background-color: var(--white-color); 536 | border-radius: 5px; 537 | animation: moveProgress 1.5s infinite ease-in-out; 538 | } 539 | 540 | @keyframes moveProgress { 541 | 0% { 542 | transform: translateX(-15%); 543 | } 544 | 50% { 545 | transform: translateX(400%); 546 | } 547 | 100% { 548 | transform: translateX(-15%); 549 | } 550 | } 551 | 552 | #update-w4s-panel:hover { 553 | opacity: 0.9; 554 | } 555 | 556 | .pwa-safe-area { 557 | display: none; 558 | position: fixed; 559 | bottom: 0; 560 | left: 0; 561 | width: 100%; 562 | height: 12px; 563 | background-color: var(--background-color); 564 | z-index: 1001; 565 | } 566 | 567 | body.pwa-mode .pwa-safe-area { 568 | display: block; 569 | } 570 | 571 | body.dark-theme .pwa-safe-area { 572 | background-color: var(--background-color-dark); 573 | } 574 | 575 | body.pwa-mode #update-w4s-panel { 576 | bottom: calc(env(safe-area-inset-bottom) + 12px); 577 | } -------------------------------------------------------------------------------- /files/web4static.php: -------------------------------------------------------------------------------- 1 | &1", $output); 33 | $outputString = implode("\n", $output); 34 | 35 | header('Content-Type: application/json'); 36 | echo json_encode(['success' => true, 'output' => $outputString]); 37 | exit(); 38 | } 39 | 40 | $categories = [ 41 | 'IPSET' => getLists("readlink /opt/etc/init.d/S03ipset-table | sed 's/scripts.*/lists/'", true), 42 | 'BIRD' => getLists("readlink /opt/etc/init.d/S02bird-table | sed 's/scripts.*/lists/'", true), 43 | 'NFQWS' => getLists('/opt/etc/nfqws'), 44 | 'TPWS' => getLists('/opt/etc/tpws'), 45 | 'XKEEN' => getLists('/opt/etc/xray/configs'), 46 | 'sing-box' => getLists('/opt/etc/sing-box'), 47 | 'object-group' => getObjectGroupLists(), 48 | 'HydraRoute' => getLists(['/opt/etc/HydraRoute', '/opt/etc/AdGuardHome']), 49 | ]; 50 | 51 | $files = []; 52 | foreach ($categories as $category => $categoryFiles) { 53 | if ($category === 'object-group') { 54 | if (is_array($categoryFiles)) { 55 | foreach ($categoryFiles as $fileName => $content) { 56 | $files[$fileName] = $content; 57 | } 58 | } 59 | } else { 60 | if (is_array($categoryFiles)) { 61 | $files = array_merge($files, $categoryFiles); 62 | } 63 | } 64 | } 65 | 66 | $texts = []; 67 | foreach ($files as $fileName => $data) { 68 | if (is_array($categories['object-group']) && array_key_exists($fileName, $categories['object-group'])) { 69 | $texts[$fileName] = $data; 70 | } else { 71 | $texts[$fileName] = file_get_contents($data); 72 | } 73 | } 74 | 75 | if ($_SERVER['REQUEST_METHOD'] === 'POST') { 76 | handlePostRequest($files); 77 | } 78 | 79 | if (isset($_GET['export_all'])) { 80 | exportAllFiles($categories); 81 | } 82 | ?> 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | web4static 93 | 94 | 95 | 96 | 97 | 98 | 129 | 130 | 131 |
132 |
133 |             
134 |         
135 |
136 | 137 |
138 |
139 | $categoryFiles): ?> 140 | 141 | 142 | 143 | 144 | 145 | $categoryFiles): ?> 146 | 147 | 209 | 210 | 211 |
212 | 213 |
214 |
215 |
216 | 217 | 237 |
238 | 239 | -------------------------------------------------------------------------------- /icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spatiumstas/web4static/c3b1c21230b8f2a394dddb582b7e3ae5477ec339/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /icons/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spatiumstas/web4static/c3b1c21230b8f2a394dddb582b7e3ae5477ec339/icons/favicon.png -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | REPO="web4static" 4 | SCRIPT="web4static.sh" 5 | TMP_DIR="/tmp" 6 | WEB4STATIC_DIR="/opt/share/www/w4s" 7 | 8 | if ! opkg list-installed | grep -q "^curl"; then 9 | opkg update 10 | opkg install curl 11 | fi 12 | 13 | mkdir -p "$WEB4STATIC_DIR" 14 | curl -L -s "https://raw.githubusercontent.com/spatiumstas/$REPO/main/$SCRIPT" --output $TMP_DIR/$SCRIPT 15 | mv "$TMP_DIR/$SCRIPT" "$WEB4STATIC_DIR/$SCRIPT" 16 | chmod +x $WEB4STATIC_DIR/$SCRIPT 17 | cd /opt/bin 18 | ln -sf $WEB4STATIC_DIR/$SCRIPT /opt/bin/web4static 19 | URL=$(echo "aHR0cHM6Ly9sb2cuc3BhdGl1bS5rZWVuZXRpYy5wcm8=" | base64 -d) 20 | JSON_DATA="{\"script_update\": \"web4static_install\"}" 21 | curl -X POST -H "Content-Type: application/json" -d "$JSON_DATA" "$URL" -o /dev/null -s 22 | $WEB4STATIC_DIR/$SCRIPT 23 | -------------------------------------------------------------------------------- /web4static.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | RED='\033[1;31m' 4 | GREEN='\033[1;32m' 5 | CYAN='\033[0;36m' 6 | NC='\033[0m' 7 | USER="spatiumstas" 8 | REPO="web4static" 9 | WEB4STATIC_DIR="/opt/share/www/w4s" 10 | PATH_CONFIG="/opt/share/www/w4s/files/config.ini" 11 | PHP_FILE="$WEB4STATIC_DIR/web4static.php" 12 | 13 | print_menu() { 14 | printf "\033c" 15 | printf "${CYAN}" 16 | cat <<'EOF' 17 | __ __ __ __ __ _ 18 | _ _____ / /_ / // / _____/ /_____ _/ /_(_)____ 19 | | | /| / / _ \/ __ \/ // /_/ ___/ __/ __ `/ __/ / ___/ 20 | | |/ |/ / __/ /_/ /__ __(__ ) /_/ /_/ / /_/ / /__ 21 | |__/|__/\___/_.___/ /_/ /____/\__/\__,_/\__/_/\___/ 22 | EOF 23 | printf "${NC}" 24 | echo "" 25 | echo "1. Установить/Обновить web-интерфейс" 26 | echo "2. Удалить web-интерфейс" 27 | echo "" 28 | echo "77. Удалить используемые пакеты" 29 | echo "99. Обновить скрипт" 30 | echo "00. Выход" 31 | echo "" 32 | } 33 | 34 | main_menu() { 35 | print_menu 36 | read -p "Выберите действие: " choice branch 37 | echo "" 38 | choice=$(echo "$choice" | tr -d '\032' | tr -d '[A-Z]') 39 | 40 | if [ -z "$choice" ]; then 41 | main_menu 42 | else 43 | case "$choice" in 44 | 1) install_web "${branch:-main}" ;; 45 | 2) remove_web ;; 46 | 77) packages_delete ;; 47 | 88) script_update "dev" ;; 48 | 99) script_update "main" ;; 49 | 00) exit ;; 50 | *) 51 | echo "Неверный выбор. Попробуйте снова." 52 | sleep 1 53 | main_menu 54 | ;; 55 | esac 56 | fi 57 | } 58 | 59 | print_message() { 60 | local message=$1 61 | local color=${2:-$NC} 62 | local border=$(printf '%0.s-' $(seq 1 $((${#message} + 2)))) 63 | printf "${color}\n+${border}+\n| ${message} |\n+${border}+\n${NC}\n" 64 | sleep 1 65 | } 66 | 67 | packages_checker() { 68 | check_keenetic_repo 69 | if ! opkg list-installed | grep -q "^php8-cgi" || ! opkg list-installed | grep -q "^curl" || ! opkg list-installed | grep -q "^uhttpd_kn"; then 70 | opkg update 71 | opkg install php8-cgi uhttpd_kn curl 72 | wait 73 | echo "" 74 | fi 75 | } 76 | 77 | get_architecture() { 78 | arch=$(opkg print-architecture | grep -oE 'mips-3|mipsel-3|aarch64-3' | head -n 1) 79 | 80 | case "$arch" in 81 | "mips-3") echo "mips" ;; 82 | "mipsel-3") echo "mipsel" ;; 83 | "aarch64-3") echo "aarch64" ;; 84 | *) echo "unknown_arch" ;; 85 | esac 86 | } 87 | 88 | check_keenetic_repo() { 89 | if [ ! -f /opt/var/opkg-lists/keendev ]; then 90 | print_message "Не найден репозиторий Keenetic, добавляю..." "$CYAN" 91 | arch=$(get_architecture) 92 | printf "${GREEN}Архитектура устройства - $arch${NC}\n" 93 | echo "" 94 | mkdir -p /opt/etc/opkg 95 | case "$arch" in 96 | "mips") 97 | echo "src/gz keendev http://bin.entware.net/mipssf-k3.4/keenetic" >/opt/etc/opkg/w4s-keenetic.conf 98 | ;; 99 | "mipsel") 100 | echo "src/gz keendev http://bin.entware.net/mipselsf-k3.4/keenetic" >/opt/etc/opkg/w4s-keenetic.conf 101 | ;; 102 | "aarch64") 103 | echo "src/gz keendev http://bin.entware.net/aarch64-k3.10/keenetic" >/opt/etc/opkg/w4s-keenetic.conf 104 | ;; 105 | *) 106 | printf "${RED}Неподдерживаемая архитектура: $arch${NC}\n" 107 | echo "" 108 | read -n 1 -s -r -p "Для возврата нажмите любую клавишу..." 109 | main_menu 110 | ;; 111 | esac 112 | fi 113 | } 114 | 115 | packages_delete() { 116 | packages="php8 php8-cgi uhttpd_kn" 117 | delete_log=$(opkg remove $packages --autoremove 2>&1) 118 | removed_packages="" 119 | failed_packages="" 120 | 121 | for package in $packages; do 122 | if echo "$delete_log" | grep -q "Package $package is depended upon by packages"; then 123 | failed_packages="$failed_packages $package" 124 | else 125 | removed_packages="$removed_packages $package" 126 | fi 127 | done 128 | 129 | if [ -n "$removed_packages" ]; then 130 | print_message "Пакеты$removed_packages успешно удалены" "$GREEN" 131 | fi 132 | 133 | if [ -n "$failed_packages" ]; then 134 | print_message "Пакет$failed_packages не были удалены из-за зависимостей" "$RED" 135 | fi 136 | 137 | read -n 1 -s -r -p "Для возврата нажмите любую клавишу..." 138 | main_menu 139 | } 140 | 141 | install_web() { 142 | BRANCH="$1" 143 | if [ "$BRANCH" = "dev" ]; then 144 | print_message "Устанавливаем Web-интерфейс из ветки $BRANCH..." "$GREEN" 145 | else 146 | print_message "Устанавливаем Web-интерфейс..." "$GREEN" 147 | fi 148 | packages_checker 149 | mkdir -p "$WEB4STATIC_DIR/files" 150 | 151 | API_URL="https://api.github.com/repos/${USER}/${REPO}/contents/files?ref=${BRANCH}" 152 | printf "Получаем список файлов из репозитория...\n\n" 153 | 154 | files_list=$(curl -s --connect-timeout 5 --max-time 10 \ 155 | -H "Accept: application/vnd.github.v3+json" \ 156 | -H "User-Agent: web4static-updater" "$API_URL") 157 | 158 | if [ $? -ne 0 ] || [ -z "$files_list" ]; then 159 | print_message "Ошибка: не удалось подключиться к GitHub API." "$RED" 160 | read -n 1 -s -r -p "Для возврата нажмите любую клавишу..." 161 | main_menu 162 | fi 163 | 164 | error_message=$(echo "$files_list" | grep -Po '"message":.*?[^\\]",' | awk -F'"' '{print $4}') 165 | if [ -n "$error_message" ]; then 166 | print_message "Ошибка при получении списка файлов с GitHub" "$RED" 167 | print_message "$error_message" "$RED" 168 | read -n 1 -s -r -p "Для возврата нажмите любую клавишу..." 169 | main_menu 170 | fi 171 | 172 | echo "$files_list" | grep -o '"download_url":"[^"]*"' | sed 's/"download_url":"//' | sed 's/"//' | while read -r url; do 173 | filename=$(basename "$url") 174 | if [ "$filename" = "web4static.php" ]; then 175 | download_file "$url" "$WEB4STATIC_DIR/web4static.php" 176 | else 177 | download_file "$url" "$WEB4STATIC_DIR/files/$filename" 178 | fi 179 | done 180 | 181 | user_ip=$(ip -f inet addr show dev br0 2>/dev/null | grep inet | sed -n 's/.*inet \([0-9.]\+\).*/\1/p') 182 | replace_path "$user_ip" 183 | file_count=$(find "$WEB4STATIC_DIR/files" -type f 2>/dev/null | wc -l) 184 | 185 | if [ "$file_count" -ge 3 ] && [ -f "$WEB4STATIC_DIR/web4static.php" ]; then 186 | print_message "Web-интерфейс установлен и доступен по адресу http://$user_ip:88/w4s" "$GREEN" 187 | else 188 | print_message "Ошибка: не все файлы были установлены." "$RED" 189 | fi 190 | 191 | read -n 1 -s -r -p "Для возврата нажмите любую клавишу..." 192 | main_menu 193 | } 194 | 195 | download_file() { 196 | local url="$1" 197 | local path="$2" 198 | local filename=$(basename "$path") 199 | echo "Скачиваю $filename..." 200 | curl -s -L "$url" -o "$path" 2>/dev/null 201 | if [ $? -ne 0 ] || [ ! -f "$path" ]; then 202 | print_message "Ошибка при скачивании $filename" "$RED" 203 | read -n 1 -s -r -p "Для возврата нажмите любую клавишу..." 204 | main_menu 205 | fi 206 | } 207 | 208 | replace_path() { 209 | if grep -q '^ARGS=' "/opt/etc/init.d/S80uhttpd"; then 210 | if ! grep -q ' -I web4static.php' "/opt/etc/init.d/S80uhttpd"; then 211 | sed -i 's|^\(ARGS=.*\)"|\1 -I web4static.php"|' "/opt/etc/init.d/S80uhttpd" 212 | echo "" 213 | /opt/etc/init.d/S80uhttpd restart 214 | fi 215 | else 216 | print_message "Ошибка: строка 'ARGS=' не найдена в файле /opt/etc/init.d/S80uhttpd" "$RED" 217 | read -n 1 -s -r -p "Для возврата нажмите любую клавишу..." 218 | main_menu 219 | fi 220 | } 221 | 222 | remove_web() { 223 | echo "" 224 | echo "Удаляю директорию $WEB4STATIC_DIR..." 225 | sleep 1 226 | rm -r "$WEB4STATIC_DIR" 227 | 228 | if grep -q '^ARGS=' "/opt/etc/init.d/S80uhttpd"; then 229 | sed -i 's| -I web4static.php||' "/opt/etc/init.d/S80uhttpd" 230 | fi 231 | 232 | print_message "Успешно удалено" "$GREEN" 233 | read -n 1 -s -r -p "Для возврата нажмите любую клавишу..." 234 | main_menu 235 | } 236 | 237 | script_update() { 238 | BRANCH="$1" 239 | SCRIPT="web4static.sh" 240 | TMP_DIR="/tmp" 241 | 242 | curl -L -s "https://raw.githubusercontent.com/$USER/$REPO/$BRANCH/$SCRIPT" --output $TMP_DIR/$SCRIPT 243 | 244 | if [ -f "$TMP_DIR/$SCRIPT" ]; then 245 | mv "$TMP_DIR/$SCRIPT" "$WEB4STATIC_DIR/$SCRIPT" 246 | chmod +x $WEB4STATIC_DIR/$SCRIPT 247 | cd /opt/bin 248 | ln -sf $WEB4STATIC_DIR/$SCRIPT /opt/bin/web4static 249 | if [ "$BRANCH" = "dev" ]; then 250 | print_message "Скрипт успешно обновлён на $BRANCH ветку..." "$GREEN" 251 | else 252 | print_message "Скрипт успешно обновлён" "$GREEN" 253 | fi 254 | sleep 1 255 | $WEB4STATIC_DIR/$SCRIPT post_update 256 | else 257 | print_message "Ошибка при скачивании скрипта" "$RED" 258 | fi 259 | } 260 | 261 | post_update() { 262 | SCRIPT_VERSION=$(awk -F"['\"]" '/\$w4s_version/{print $2}' "$PHP_FILE") 263 | URL=$(echo "aHR0cHM6Ly9sb2cuc3BhdGl1bS5rZWVuZXRpYy5wcm8=" | base64 -d) 264 | JSON_DATA="{\"script_update\": \"w4s_update_$SCRIPT_VERSION\"}" 265 | curl -X POST -H "Content-Type: application/json" -d "$JSON_DATA" "$URL" -o /dev/null -s 266 | main_menu 267 | } 268 | 269 | if [ "$1" = "script_update" ]; then 270 | script_update "main" 271 | elif [ "$1" = "post_update" ]; then 272 | post_update 273 | else 274 | main_menu 275 | fi 276 | --------------------------------------------------------------------------------