├── 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 | 
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 |
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 |
133 | 134 |135 |