From d2ec4a48f62b8d182fc1ec6a9166a2eb1256e6fa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 15 Jun 2026 08:50:27 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=9E=20Sync=202026-06-15=2008:50:27?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- luci-app-daede/Makefile | 2 +- .../resources/view/daede/airport-sync.js | 171 ++++++ .../resources/view/daede/clash-converter.js | 33 ++ .../resources/view/daede/converter.js | 536 +++++++++++++++--- .../luci-static/resources/view/daede/log.js | 9 +- .../resources/view/daede/styles.js | 31 +- luci-app-daede/po/templates/daede.pot | 140 ++++- luci-app-daede/po/zh-cn/daede.po | 152 ++++- luci-app-daede/po/zh_Hans/daede.po | 152 ++++- .../share/luci-app-daede/fetch-clash-yaml.sh | 127 ++++- luci-app-daede/tests/airport-group-name.js | 22 + luci-app-daede/tests/airport-sanitize-name.js | 20 + 12 files changed, 1252 insertions(+), 143 deletions(-) create mode 100644 luci-app-daede/htdocs/luci-static/resources/view/daede/airport-sync.js create mode 100644 luci-app-daede/tests/airport-group-name.js create mode 100644 luci-app-daede/tests/airport-sanitize-name.js diff --git a/luci-app-daede/Makefile b/luci-app-daede/Makefile index 9d9a700e..7f7d63d2 100644 --- a/luci-app-daede/Makefile +++ b/luci-app-daede/Makefile @@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-daede PKG_VERSION:=1.12.0 -PKG_RELEASE:=7 +PKG_RELEASE:=8 PKG_MAINTAINER:=kenzok8 PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME) diff --git a/luci-app-daede/htdocs/luci-static/resources/view/daede/airport-sync.js b/luci-app-daede/htdocs/luci-static/resources/view/daede/airport-sync.js new file mode 100644 index 00000000..a6eeaccb --- /dev/null +++ b/luci-app-daede/htdocs/luci-static/resources/view/daede/airport-sync.js @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: Apache-2.0 + +'use strict'; +'require baseclass'; + +function deriveAirportName(value) { + try { + const url = new URL(String(value || '')); + const filename = url.searchParams.get('filename'); + if (filename) { + const name = filename.replace(/\.(?:ya?ml)$/i, '').trim(); + if (name) + return name; + } + return url.hostname || '机场_1'; + } catch (e) { + return '机场_1'; + } +} + +function nextPastedName(names) { + const used = {}; + (names || []).forEach(function(name) { used[String(name)] = true; }); + let index = 1; + while (used['机场_' + index]) + index++; + return '机场_' + index; +} + +function makeAirportId() { + return 'airport_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8); +} + +function backendId(id) { + return String(id || '').replace(/[^A-Za-z0-9]/g, '') || 'airport'; +} + +function backendGroupName(name, backend, id) { + const value = String(name || '').trim(); + if (isGroupNameValid(value, backend)) + return value || backendId(id); + return backendId(id); +} + +function isGroupNameValid(name, backend) { + const value = String(name || '').trim(); + return !!value && (backend !== 'dae' || /^[A-Za-z_][A-Za-z0-9_-]*$/.test(value)); +} + +// Coerce an auto-derived name (URL hostname/filename) into a name the third +// step accepts, so the group name is pre-filled valid and needs no manual fix. +// daed allows any non-empty name (incl. CJK); dae requires +// /^[A-Za-z_][A-Za-z0-9_-]*$/, so collapse illegal chars to '_' and drop a +// leading non-letter/underscore run. +function sanitizeGroupName(name, backend) { + const value = String(name || '').trim(); + if (!value || backend !== 'dae') + return value; + const cleaned = value + .replace(/[^A-Za-z0-9_-]+/g, '_') + .replace(/_{2,}/g, '_') + .replace(/^[^A-Za-z_]+/, '') + .replace(/[-_]+$/, ''); + return cleaned || 'airport'; +} + +function hashSource(value) { + // LuCI is commonly served over plain HTTP, where Web Crypto is unavailable. + // This stable opaque key is only used to match a previously managed source. + let h1 = 0x811c9dc5, h2 = 0x9e3779b9, h3 = 0x85ebca6b, h4 = 0xc2b2ae35; + const text = unescape(encodeURIComponent(String(value || ''))); + for (let index = 0; index < text.length; index++) { + const code = text.charCodeAt(index); + h1 = Math.imul(h1 ^ code, 0x01000193); + h2 = Math.imul(h2 ^ code, 0x5bd1e995); + h3 = Math.imul(h3 ^ code, 0x27d4eb2d); + h4 = Math.imul(h4 ^ code, 0x165667b1); + } + const words = [ h1, h2, h3, h4, h1 ^ h3, h2 ^ h4, h1 ^ h2 ^ h4, h1 ^ h2 ^ h3 ^ h4 ]; + return Promise.resolve(words.map(function(word) { + return (word >>> 0).toString(16).padStart(8, '0'); + }).join('')); +} + +function list(value) { + if (Array.isArray(value)) + return value.filter(Boolean).map(String); + return value ? [ String(value) ] : []; +} + +function parseAirportSection(section) { + if (!section) + return null; + return { + sid: section['.name'], + id: String(section.id || ''), + backend: String(section.backend || ''), + name: String(section.name || ''), + source_hash: String(section.source_hash || ''), + group_id: String(section.group_id || ''), + node_ids: list(section.node_id) + }; +} + +function airportSectionValues(record) { + return { + id: String(record.id || ''), + backend: String(record.backend || ''), + name: String(record.name || ''), + source_hash: String(record.sourceHash || record.source_hash || ''), + group_id: String(record.groupId || record.group_id || ''), + node_id: list(record.nodeIds || record.node_ids) + }; +} + +function matchAirport(records, candidate) { + const sameBackend = (records || []).filter(function(record) { + return record.backend === candidate.backend; + }); + if (candidate.selectedId) { + const selected = sameBackend.find(function(record) { return record.id === candidate.selectedId; }); + if (selected) + return selected; + } + if (candidate.sourceHash) { + const bySource = sameBackend.find(function(record) { return record.source_hash === candidate.sourceHash; }); + if (bySource) + return bySource; + } + return sameBackend.find(function(record) { return record.name === candidate.name; }) || null; +} + +function safeOldNodeIds(oldIds, newIds, otherAirportNodeIds, otherGroupNodeIds) { + const keep = {}; + list(newIds).forEach(function(id) { keep[id] = true; }); + (otherAirportNodeIds || []).forEach(function(ids) { list(ids).forEach(function(id) { keep[id] = true; }); }); + (otherGroupNodeIds || []).forEach(function(ids) { list(ids).forEach(function(id) { keep[id] = true; }); }); + return list(oldIds).filter(function(id) { return !keep[id]; }); +} + +function findManagedGroup(groups, record) { + if (!record || !record.group_id) + return null; + return (groups || []).find(function(group) { + return group.id === record.group_id && group.name === record.name; + }) || null; +} + +const api = { + deriveAirportName: deriveAirportName, + nextPastedName: nextPastedName, + makeAirportId: makeAirportId, + backendId: backendId, + backendGroupName: backendGroupName, + isGroupNameValid: isGroupNameValid, + sanitizeGroupName: sanitizeGroupName, + hashSource: hashSource, + parseAirportSection: parseAirportSection, + airportSectionValues: airportSectionValues, + matchAirport: matchAirport, + findManagedGroup: findManagedGroup, + safeOldNodeIds: safeOldNodeIds +}; + +if (typeof module !== 'undefined' && module.exports) + module.exports = api; + +if (typeof baseclass !== 'undefined') + return baseclass.extend(api); + +return api; diff --git a/luci-app-daede/htdocs/luci-static/resources/view/daede/clash-converter.js b/luci-app-daede/htdocs/luci-static/resources/view/daede/clash-converter.js index d7489574..c12d5d9c 100644 --- a/luci-app-daede/htdocs/luci-static/resources/view/daede/clash-converter.js +++ b/luci-app-daede/htdocs/luci-static/resources/view/daede/clash-converter.js @@ -94,6 +94,26 @@ function convertSs(node) { return 'ss://' + auth + '@' + endpoint(node.server, node.port) + query + '#' + encodeURIComponent(node.name || 'Node'); } +function convertSsr(node) { + requireFields(node, [ 'server', 'port', 'cipher', 'password', 'protocol', 'obfs' ]); + const query = []; + query.push('remarks=' + base64Url(node.name || 'Node')); + if (node['protocol-param']) + query.push('protoparam=' + base64Url(node['protocol-param'])); + if (node['obfs-param']) + query.push('obfsparam=' + base64Url(node['obfs-param'])); + + const payload = [ + node.server, + node.port, + node.protocol, + node.cipher, + node.obfs, + base64Url(node.password) + ].join(':') + '/?' + query.join('&'); + return 'ssr://' + base64Url(payload); +} + function convertVmess(node) { requireFields(node, [ 'server', 'port', 'uuid' ]); const network = node.network || 'tcp'; @@ -200,10 +220,22 @@ function convertAnytls(node) { (params.toString() ? '?' + params.toString() : '') + '#' + encodeURIComponent(node.name || 'Node'); } +function isMetadataProxy(node) { + const name = String(node && node.name || '').trim(); + if (!name) + return false; + + return /^(?:traffic|bandwidth|expire|expiry|expiration|subscription(?:\s+info)?|official\s+website|website)\s*[::|]/i.test(name) || + /^(?:(?:剩余|可用|已用|总)?流量|套餐到期|到期时间|到期日|过期时间|有效期|订阅信息)\s*[::|]/i.test(name) || + /^(?:官方网站|官网地址|官方网址|网站地址)\s*[::|]/i.test(name) || + /^(?:加入|联系|客服)?\s*QQ\s*(?:群|交流群|客服|联系)\s*[::|]/i.test(name); +} + function convertProxy(node) { const type = String(node && node.type || '').toLowerCase(); const converters = { ss: convertSs, + ssr: convertSsr, vmess: convertVmess, vless: convertVless, trojan: convertTrojan, @@ -249,6 +281,7 @@ function normalizeLink(link) { const api = { convertProxy: convertProxy, convertProxies: convertProxies, + isMetadataProxy: isMetadataProxy, normalizeLink: normalizeLink }; diff --git a/luci-app-daede/htdocs/luci-static/resources/view/daede/converter.js b/luci-app-daede/htdocs/luci-static/resources/view/daede/converter.js index 0f5e72a1..badc70a6 100644 --- a/luci-app-daede/htdocs/luci-static/resources/view/daede/converter.js +++ b/luci-app-daede/htdocs/luci-static/resources/view/daede/converter.js @@ -5,12 +5,53 @@ 'require uci'; 'require ui'; 'require view'; +'require view.daede.airport-sync as airportSync'; 'require view.daede.backend as backend'; 'require view.daede.clash-converter as clashConverter'; 'require view.daede.styles as styles'; const FETCHER = '/usr/share/luci-app-daede/fetch-clash-yaml.sh'; const GENERATOR = '/usr/share/luci-app-daede/gen-dae-config.sh'; +const FETCH_CHUNK_BYTES = 16384; + +function decodeHexChunks(chunks) { + const total = chunks.reduce(function(size, chunk) { return size + chunk.length / 2; }, 0); + const bytes = new Uint8Array(total); + let offset = 0; + chunks.forEach(function(chunk) { + for (let i = 0; i < chunk.length; i += 2) + bytes[offset++] = parseInt(chunk.slice(i, i + 2), 16); + }); + return new TextDecoder('utf-8').decode(bytes); +} + +function fetchYaml(url, userAgent) { + let token = ''; + return fs.exec(FETCHER, [ url, userAgent, 'handle' ]).then(function(res) { + if (!res || res.code !== 0) + throw new Error((res && (res.stderr || res.stdout)) || _('Fetch failed')); + const match = String(res.stdout || '').trim().match(/^([A-Za-z0-9]+)\t(\d+)$/); + if (!match) + throw new Error(_('Fetcher returned an invalid response')); + token = match[1]; + const chunks = Math.ceil(Number(match[2]) / FETCH_CHUNK_BYTES); + const values = []; + let chain = Promise.resolve(); + for (let index = 0; index < chunks; index++) { + chain = chain.then(function() { + return fs.exec(FETCHER, [ 'chunk', token, String(index) ]).then(function(part) { + if (!part || part.code !== 0) + throw new Error((part && (part.stderr || part.stdout)) || _('Failed to read subscription data')); + values.push(String(part.stdout || '').trim()); + }); + }); + } + return chain.then(function() { return decodeHexChunks(values); }); + }).finally(function() { + if (token) + fs.exec(FETCHER, [ 'cleanup', token ]).catch(function() {}); + }); +} function loadYamlParser() { if (window.jsyaml && window.jsyaml.load) @@ -100,26 +141,80 @@ function uniqueNodeTag(existing, index) { return tag; } +function applyUciChanges() { + return uci.save().then(function() { + return uci.changes().then(function(changes) { + return changes && Object.keys(changes).length ? uci.apply() : null; + }); + }).then(function() { + // uci.apply() resolves before its rollback-confirm RPC has finished. + // A second immediate apply is rejected, so wait for that confirmation. + return new Promise(function(resolve) { window.setTimeout(resolve, 1800); }); + }); +} + +function airportRecords() { + return (uci.sections('daede', 'airport') || []).map(airportSync.parseAirportSection).filter(Boolean); +} + +function uciSection(config, sid) { + return (uci.sections(config) || []).find(function(section) { return section['.name'] === sid; }) || null; +} + +function writeAirportRecord(existing, record) { + const sid = existing && existing.sid ? existing.sid : uci.add('daede', 'airport'); + const values = airportSync.airportSectionValues(record); + Object.keys(values).forEach(function(key) { + uci.set('daede', sid, key, values[key]); + }); + return sid; +} + +function looksLikeBase64Subscription(text) { + const value = String(text || '').replace(/\s+/g, ''); + if (!value || !/^[A-Za-z0-9+/_=-]+$/.test(value)) + return false; + try { + const decoded = atob(value.replace(/-/g, '+').replace(/_/g, '/')); + return /(?:ss|ssr|vmess|vless|trojan|tuic|hysteria2|hy2|anytls):\/\//i.test(decoded); + } catch (e) { + return false; + } +} + return view.extend({ load: function() { return Promise.all([ backend.detectBackend(), uci.load('dae').catch(function() {}), uci.load('daed').catch(function() {}), + uci.load('daede').catch(function() {}), loadYamlParser() ]).then(function(values) { - return { ctx: values[0], yaml: values[3] }; + return { ctx: values[0], yaml: values[4] }; }); }, render: function(data) { const state = { results: [], + ignoredInfo: 0, filter: '', + sourceHash: '', + sourceName: '', + selectedAirportId: '', target: data.ctx.installed[data.ctx.name] ? data.ctx.name : (data.ctx.installed.dae ? 'dae' : 'daed') }; const urlInput = E('input', { 'class': 'dd-conv-url', 'placeholder': 'https://example.com/clash.yaml', 'autocomplete': 'off' }); + const uaSelect = E('select', { 'class': 'dd-conv-ua', 'title': _('Subscription User-Agent') }, [ + E('option', { 'value': 'auto' }, _('Auto User-Agent (recommended)')), + E('option', { 'value': 'ClashMeta' }, 'ClashMeta'), + E('option', { 'value': 'clash-verge/v2.4.2' }, 'clash-verge/v2.4.2'), + E('option', { 'value': 'ClashForWindows/0.20.39' }, 'ClashForWindows/0.20.39'), + E('option', { 'value': 'Clash' }, 'Clash'), + E('option', { 'value': 'browser' }, _('Browser User-Agent')) + ]); const yamlInput = E('textarea', { 'class': 'dd-conv-yaml', 'placeholder': _('Or paste Clash YAML here'), 'spellcheck': 'false' }); const parseUrl = E('button', { 'class': 'cbi-button cbi-button-action' }, _('Fetch and parse')); const parsePaste = E('button', { 'class': 'cbi-button' }, _('Parse pasted YAML')); @@ -127,9 +222,12 @@ return view.extend({ const selectNew = E('button', { 'class': 'cbi-button' }, _('Select compatible new nodes')); const clearSelection = E('button', { 'class': 'cbi-button' }, _('Clear selection')); const targetSelect = E('select', { 'class': 'dd-conv-target' }); - const importButton = E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': 'disabled' }, _('Import selected nodes')); - const summary = E('span', { 'class': 'dd-meta' }, _('No YAML parsed yet')); - const status = E('div', { 'class': 'dd-conv-status' }, ''); + const airportName = E('input', { 'class': 'dd-conv-airport-name', 'placeholder': _('Group name'), 'autocomplete': 'off' }); + const importButton = E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': 'disabled' }, _('Import node group')); + const summary = E('div', { 'class': 'dd-conv-summary' }); + const groupSummary = E('div', { 'class': 'dd-conv-group-summary' }); + const sourceStatus = E('div', { 'class': 'dd-conv-status' }, ''); + const importStatus = E('div', { 'class': 'dd-conv-status' }, ''); const resultBody = E('div', { 'class': 'dd-conv-results' }); [ 'dae', 'daed' ].forEach(function(name) { @@ -137,13 +235,26 @@ return view.extend({ targetSelect.appendChild(E('option', { 'value': name, 'selected': state.target === name ? 'selected' : null }, name)); }); - const setStatus = function(text, kind) { - status.textContent = text || ''; - status.className = 'dd-conv-status' + (kind ? ' ' + kind : ''); + const setSourceStatus = function(text, kind) { + sourceStatus.textContent = text || ''; + sourceStatus.className = 'dd-conv-status' + (kind ? ' ' + kind : ''); + }; + + const setImportStatus = function(text, kind) { + importStatus.textContent = text || ''; + importStatus.className = 'dd-conv-status' + (kind ? ' ' + kind : ''); }; const selectedResults = function() { - return state.results.filter(function(item) { return item.ok && item.selected && !item.duplicate; }); + return state.results.filter(function(item) { return item.ok && item.selected; }); + }; + + const currentAirport = function() { + return airportSync.matchAirport(airportRecords(), { + backend: state.target, + selectedId: state.selectedAirportId, + name: airportName.value.trim() + }); }; const classifyDaeDuplicates = function() { @@ -154,8 +265,6 @@ return view.extend({ }); state.results.forEach(function(item) { item.duplicate = !!(item.ok && existing[clashConverter.normalizeLink(item.link)]); - if (item.duplicate) - item.selected = false; }); }; @@ -171,7 +280,7 @@ return view.extend({ shown.forEach(function(item) { const checkbox = E('input', { 'type': 'checkbox' }); checkbox.checked = !!item.selected; - checkbox.disabled = !item.ok || item.duplicate; + checkbox.disabled = !item.ok; checkbox.addEventListener('change', function() { item.selected = checkbox.checked; renderResults(); @@ -179,10 +288,12 @@ return view.extend({ let resultText, resultClass; if (!item.ok) { - resultText = item.error; + resultText = item.error.indexOf('Unsupported protocol:') === 0 + ? _('Unsupported protocol: %s').format(item.type) + : item.error; resultClass = 'bad'; } else if (item.duplicate) { - resultText = _('Already exists, skipped'); + resultText = _('Already exists, will be reused'); resultClass = 'dup'; } else { resultText = _('Ready to import'); @@ -196,64 +307,137 @@ return view.extend({ E('span', { 'class': 'dd-conv-result-state ' + resultClass, 'title': resultText }, resultText) ])); }); + if (!shown.length) + resultBody.appendChild(E('div', { 'class': 'dd-conv-empty' }, _('No nodes to preview yet. Fetch or paste Clash YAML above.'))); const compatible = state.results.filter(function(item) { return item.ok; }).length; const unsupported = state.results.length - compatible; const duplicates = state.results.filter(function(item) { return item.duplicate; }).length; - summary.textContent = _('Detected %d · compatible %d · duplicates %d · incompatible %d') - .format(state.results.length, compatible, duplicates, unsupported); - importButton.textContent = _('Import selected nodes (%d)').format(selectedResults().length); - importButton.disabled = selectedResults().length === 0; + while (summary.firstChild) + summary.removeChild(summary.firstChild); + [ + [ _('Detected'), state.results.length + state.ignoredInfo ], + [ _('Compatible'), compatible ], + [ _('Duplicates'), duplicates ], + [ _('Incompatible'), unsupported ], + [ _('Ignored info'), state.ignoredInfo ] + ].forEach(function(item) { + summary.appendChild(E('span', { 'class': 'dd-conv-summary-item' }, [ + E('span', { 'class': 'dd-conv-summary-label' }, item[0]), + E('strong', {}, String(item[1])) + ])); + }); + const count = selectedResults().length; + const name = airportName.value.trim(); + const existing = currentAirport(); + const validName = airportSync.isGroupNameValid(name, state.target); + if (!name) { + groupSummary.textContent = _('Enter a group name to import the selected nodes.'); + groupSummary.className = 'dd-conv-group-summary'; + } else if (!validName) { + groupSummary.textContent = _('dae group names may only contain letters, numbers, underscores, and hyphens, and must start with a letter or underscore.'); + groupSummary.className = 'dd-conv-group-summary err'; + } else if (existing && count === 0) { + groupSummary.textContent = _('Node group "%s" is available on %s. Select nodes above to update it.').format(name, state.target); + groupSummary.className = 'dd-conv-group-summary'; + } else { + groupSummary.textContent = existing + ? _('Update node group "%s": replace it with %d selected nodes on %s.').format(name, count, state.target) + : _('Import %d selected nodes into group "%s" on %s.').format(count, name, state.target); + groupSummary.className = 'dd-conv-group-summary'; + } + importButton.textContent = existing + ? _('Update node group (%d)').format(count) + : _('Import node group (%d)').format(count); + importButton.disabled = count === 0 || !validName; }; - const acceptYaml = function(text) { + const clearPreview = function() { + state.results = []; + state.ignoredInfo = 0; + renderResults(); + }; + + const acceptYaml = function(text, sourceValue, defaultName) { let documentValue; try { documentValue = data.yaml.load(text); } catch (e) { - setStatus(_('YAML parse failed: %s').format(e.message || e), 'err'); - return; + clearPreview(); + setSourceStatus(_('YAML parse failed: %s').format(e.message || e), 'err'); + return Promise.resolve(); } if (!documentValue || !Array.isArray(documentValue.proxies) || documentValue.proxies.length === 0) { - setStatus(_('No top-level proxies list was found'), 'err'); - return; + clearPreview(); + setSourceStatus(looksLikeBase64Subscription(text) + ? _('The server returned a Base64/share-link subscription, not Clash YAML. Try another subscription User-Agent or add the URL directly as a native subscription.') + : _('No top-level proxies list was found'), 'err'); + return Promise.resolve(); } - state.results = clashConverter.convertProxies(documentValue.proxies).map(function(item) { + const proxies = documentValue.proxies.filter(function(node) { + return !clashConverter.isMetadataProxy(node); + }); + state.ignoredInfo = documentValue.proxies.length - proxies.length; + state.results = clashConverter.convertProxies(proxies).map(function(item) { item.selected = item.ok; item.duplicate = false; return item; }); if (state.target === 'dae') classifyDaeDuplicates(); - setStatus(_('Conversion preview is ready'), 'ok'); + setSourceStatus(state.results.some(function(item) { return item.ok; }) + ? _('Conversion preview is ready') + : _('Conversion completed, but no supported nodes were found'), state.results.some(function(item) { return item.ok; }) ? 'ok' : 'err'); renderResults(); + return airportSync.hashSource(sourceValue || text).then(function(hash) { + state.sourceHash = hash; + // Pre-fill a name the third step accepts for the chosen backend, + // so a URL hostname like sub.example.com doesn't land as an invalid + // dae group name that the user has to fix by hand. + const safeDefault = airportSync.sanitizeGroupName(defaultName || '', state.target); + const matched = airportSync.matchAirport(airportRecords(), { + backend: state.target, + sourceHash: hash, + name: safeDefault + }); + state.selectedAirportId = matched ? matched.id : ''; + state.sourceName = matched ? matched.name : safeDefault; + airportName.value = state.sourceName; + renderResults(); + }); }; parseUrl.addEventListener('click', function() { const value = urlInput.value.trim(); if (!/^https?:\/\//i.test(value)) { - setStatus(_('Enter an HTTP or HTTPS subscription URL'), 'err'); + clearPreview(); + setSourceStatus(_('Enter an HTTP or HTTPS subscription URL'), 'err'); return; } parseUrl.disabled = true; - setStatus(_('Fetching subscription…')); - fs.exec(FETCHER, [ value ]).then(function(res) { - if (!res || res.code !== 0) - throw new Error((res && (res.stderr || res.stdout)) || _('Fetch failed')); - acceptYaml(res.stdout || ''); + clearPreview(); + setSourceStatus(_('Fetching subscription…')); + fetchYaml(value, uaSelect.value).then(function(text) { + return acceptYaml(text, value, airportSync.deriveAirportName(value)); }).catch(function(e) { - setStatus(_('Fetch failed: %s').format(String(e.message || e).trim()), 'err'); + clearPreview(); + setSourceStatus(_('Fetch failed: %s').format(String(e.message || e).trim()), 'err'); }).finally(function() { parseUrl.disabled = false; }); }); parsePaste.addEventListener('click', function() { const value = yamlInput.value.trim(); if (!value) { - setStatus(_('Paste Clash YAML first'), 'err'); + clearPreview(); + setSourceStatus(_('Paste Clash YAML first'), 'err'); return; } - acceptYaml(value); + acceptYaml(value, value, airportSync.nextPastedName(airportRecords().map(function(record) { return record.name; }))) + .catch(function(e) { + clearPreview(); + setSourceStatus(_('YAML parse failed: %s').format(e.message || e), 'err'); + }); }); filterInput.addEventListener('input', function() { @@ -261,7 +445,7 @@ return view.extend({ renderResults(); }); selectNew.addEventListener('click', function() { - state.results.forEach(function(item) { item.selected = item.ok && !item.duplicate; }); + state.results.forEach(function(item) { item.selected = item.ok; }); renderResults(); }); clearSelection.addEventListener('click', function() { @@ -273,40 +457,141 @@ return view.extend({ state.results.forEach(function(item) { item.duplicate = false; }); if (state.target === 'dae') classifyDaeDuplicates(); + const matched = airportSync.matchAirport(airportRecords(), { + backend: state.target, + sourceHash: state.sourceHash, + name: airportName.value.trim() + }); + state.selectedAirportId = matched ? matched.id : ''; + if (matched) + airportName.value = matched.name; + renderResults(); + }); + airportName.addEventListener('input', function() { + // Once the user edits the name, the visible group name becomes the + // identity. Do not silently replace a source-matched old group. + state.selectedAirportId = ''; renderResults(); }); + const runGenerator = function() { + return fs.exec(GENERATOR, [ 'generate' ]).then(function(res) { + if (res && res.code !== 0) + throw new Error(res.stderr || res.stdout || ('exit ' + res.code)); + }); + }; + const importDae = function(items) { + const existingAirport = currentAirport(); + const airportId = existingAirport ? existingAirport.id : airportSync.makeAirportId(); + const groupName = airportSync.backendGroupName(airportName.value, 'dae', airportId); + const batchId = Date.now().toString(36); + const foundGroupSection = existingAirport && existingAirport.group_id ? uciSection('dae', existingAirport.group_id) : null; + const foundOldGroup = foundGroupSection && foundGroupSection.name === existingAirport.name ? foundGroupSection : null; + const oldGroupSection = foundOldGroup ? JSON.parse(JSON.stringify(foundOldGroup)) : null; const usedTags = {}; + const nodesByLink = {}; (uci.sections('dae', 'node') || []).forEach(function(section) { if (section.tag) usedTags[section.tag] = true; + if (section.link) + nodesByLink[clashConverter.normalizeLink(section.link)] = section; }); + const oldOwned = existingAirport ? existingAirport.node_ids : []; + const owned = []; + const sources = []; + const created = []; items.forEach(function(item, index) { - const sid = uci.add('dae', 'node'); - uci.set('dae', sid, 'tag', uniqueNodeTag(usedTags, index)); + const existingNode = nodesByLink[clashConverter.normalizeLink(item.link)]; + if (existingNode) { + sources.push(existingNode.tag); + if (oldOwned.indexOf(existingNode['.name']) >= 0) + owned.push(existingNode['.name']); + return; + } + const sid = uci.add('dae', 'node', airportId + '_node_' + batchId + '_' + (index + 1)); + const tag = uniqueNodeTag(usedTags, index); + uci.set('dae', sid, 'tag', tag); uci.set('dae', sid, 'link', item.link); uci.set('dae', sid, 'enabled', '1'); + sources.push(tag); + owned.push(sid); + created.push(sid); }); - return uci.save().then(function() { - return uci.changes().then(function(changes) { - return changes && Object.keys(changes).length ? uci.apply() : null; - }); + let groupSid = foundOldGroup ? existingAirport.group_id : ''; + let groupCreated = false; + if (!groupSid) { + const groupBase = airportId + '_group'; + groupSid = groupBase; + let suffix = 2; + while (uci.get('dae', groupSid)) + groupSid = groupBase + '_' + suffix++; + uci.add('dae', 'group', groupSid); + groupCreated = true; + } + uci.set('dae', groupSid, 'name', groupName); + uci.set('dae', groupSid, 'policy', 'min_moving_avg'); + uci.set('dae', groupSid, 'source', sources); + + return applyUciChanges().then(runGenerator).catch(function(error) { + created.forEach(function(sid) { uci.remove('dae', sid); }); + if (groupCreated) { + uci.remove('dae', groupSid); + } else if (oldGroupSection) { + const current = uciSection('dae', groupSid) || {}; + Object.keys(current).forEach(function(key) { + if (key.charAt(0) !== '.' && !Object.prototype.hasOwnProperty.call(oldGroupSection, key)) + uci.unset('dae', groupSid, key); + }); + Object.keys(oldGroupSection).forEach(function(key) { + if (key.charAt(0) !== '.') + uci.set('dae', groupSid, key, oldGroupSection[key]); + }); + } + return applyUciChanges().then(runGenerator).catch(function() {}).then(function() { throw error; }); + }).then(function() { + const referencedTags = {}; + (uci.sections('dae', 'group') || []).filter(function(group) { + return group['.name'] !== groupSid; + }).forEach(function(group) { + const source = Array.isArray(group.source) ? group.source : (group.source ? [ group.source ] : []); + source.forEach(function(tag) { referencedTags[tag] = true; }); + }); + const stale = oldOwned.filter(function(sid) { + const section = uciSection('dae', sid); + return owned.indexOf(sid) < 0 && !(section && referencedTags[section.tag]); + }); + stale.forEach(function(sid) { uci.remove('dae', sid); }); + writeAirportRecord(existingAirport, { + id: airportId, + backend: 'dae', + name: airportName.value.trim(), + sourceHash: state.sourceHash, + groupId: groupSid, + nodeIds: owned + }); + return applyUciChanges().then(runGenerator); }).then(function() { - return fs.exec(GENERATOR, [ 'generate' ]); - }).then(function(res) { - if (res && res.code !== 0) - throw new Error(res.stderr || res.stdout || ('exit ' + res.code)); items.forEach(function(item) { item.duplicate = true; item.selected = false; }); - return { added: items.length, duplicates: 0, failed: 0 }; + return { added: created.length, duplicates: items.length - created.length, failed: 0 }; }); }; const importDaed = function(items) { let credentials; let token = ''; + let existingAirport = currentAirport(); + const airportId = existingAirport ? existingAirport.id : airportSync.makeAirportId(); + const name = airportName.value.trim(); + const managedName = airportSync.backendId(airportId); + const groupName = airportSync.backendGroupName(name, 'daed', airportId); + const batchTag = managedName + Date.now().toString(36); const endpoint = daedEndpoint(); + let before; + const createdIds = []; + let createdGroupId = ''; + let groupReady = false; return requestDaedCredentials().then(function(value) { credentials = value; return graphQL(endpoint, @@ -316,44 +601,121 @@ return view.extend({ token = login.token; credentials.password = ''; credentials = null; - return graphQL(endpoint, 'query ExistingNodes{nodes(first:10000){edges{link}}}', {}, token); - }).then(function(existingData) { + return graphQL(endpoint, 'query AirportState{nodes(first:10000){edges{id link tag}} groups{id name nodes{id}}}', {}, token); + }).then(function(dataValue) { + before = dataValue; const existing = {}; - (existingData.nodes.edges || []).forEach(function(node) { - existing[clashConverter.normalizeLink(node.link)] = true; + (before.nodes.edges || []).forEach(function(node) { + existing[clashConverter.normalizeLink(node.link)] = node; }); const fresh = items.filter(function(item) { - const duplicate = !!existing[clashConverter.normalizeLink(item.link)]; + const duplicate = existing[clashConverter.normalizeLink(item.link)]; if (duplicate) { item.duplicate = true; - item.selected = false; } return !duplicate; }); if (!fresh.length) - return { importNodes: [], preDuplicates: items.length }; + return { importNodes: [], fresh: fresh, existing: existing }; return graphQL(endpoint, 'mutation ImportNodes($args:[ImportArgument!]!){importNodes(rollbackError:false,args:$args){link error node{id}}}', - { args: fresh.map(function(item) { return { link: item.link }; }) }, token) - .then(function(value) { value.preDuplicates = items.length - fresh.length; return value; }); + { args: fresh.map(function(item, index) { return { link: item.link, tag: batchTag + (index + 1) }; }) }, token) + .then(function(value) { value.fresh = fresh; value.existing = existing; return value; }); }).then(function(result) { const rows = result.importNodes || []; - const importedItems = {}; - items.forEach(function(item) { importedItems[clashConverter.normalizeLink(item.link)] = item; }); + const nodeIds = []; + const ownedIds = []; + const rowByLink = {}; rows.forEach(function(row) { - if (!row.error || row.error === 'node already exists') { - const item = importedItems[clashConverter.normalizeLink(row.link)]; - if (item) { - item.duplicate = true; - item.selected = false; - } - } + rowByLink[clashConverter.normalizeLink(row.link)] = row; }); - const failed = rows.filter(function(row) { return !!row.error && row.error !== 'node already exists'; }).length; - const apiDuplicates = rows.filter(function(row) { return row.error === 'node already exists'; }).length; - const duplicates = (result.preDuplicates || 0) + apiDuplicates; - const added = rows.filter(function(row) { return !row.error; }).length; - return { added: added, duplicates: duplicates, failed: failed }; + let failed = rows.filter(function(row) { return !!row.error && row.error !== 'node already exists'; }).length; + items.forEach(function(item) { + const key = clashConverter.normalizeLink(item.link); + const row = rowByLink[key]; + const old = result.existing[key]; + const id = row && row.node && row.node.id || old && old.id; + if (id) + nodeIds.push(id); + if (row && !row.error && row.node) { + ownedIds.push(row.node.id); + createdIds.push(row.node.id); + } + else if (existingAirport && existingAirport.node_ids.indexOf(id) >= 0) + ownedIds.push(id); + }); + if (!nodeIds.length) { + const details = rows.filter(function(row) { return !!row.error; }).map(function(row) { + return row.error; + }).filter(function(error, index, errors) { + return errors.indexOf(error) === index; + }).join('; '); + throw new Error(details || _('No usable nodes were imported')); + } + const oldGroup = airportSync.findManagedGroup(before.groups, existingAirport); + const group = oldGroup; + const ensureGroup = group + ? Promise.resolve(group.id) + : graphQL(endpoint, + 'mutation CreateGroup($name:String!,$policy:Policy!){createGroup(name:$name,policy:$policy){id}}', + { name: groupName, policy: 'min_moving_avg' }, token).then(function(value) { + createdGroupId = value.createGroup.id; + return createdGroupId; + }); + return ensureGroup.then(function(groupId) { + const rename = group && group.name !== groupName + ? graphQL(endpoint, 'mutation RenameGroup($id:ID!,$name:String!){renameGroup(id:$id,name:$name)}', { id: groupId, name: groupName }, token) + : Promise.resolve(); + return rename.then(function() { + return graphQL(endpoint, 'mutation SetPolicy($id:ID!,$policy:Policy!){groupSetPolicy(id:$id,policy:$policy)}', { id: groupId, policy: 'min_moving_avg' }, token); + }).then(function() { + return graphQL(endpoint, 'mutation AddNodes($id:ID!,$nodeIDs:[ID!]!){groupAddNodes(id:$id,nodeIDs:$nodeIDs)}', { id: groupId, nodeIDs: nodeIds }, token); + }).then(function() { + groupReady = true; + const oldMembers = group ? group.nodes.map(function(node) { return node.id; }) : []; + const removedMembers = oldMembers.filter(function(id) { return nodeIds.indexOf(id) < 0; }); + return (removedMembers.length + ? graphQL(endpoint, 'mutation DelNodes($id:ID!,$nodeIDs:[ID!]!){groupDelNodes(id:$id,nodeIDs:$nodeIDs)}', { id: groupId, nodeIDs: removedMembers }, token) + : Promise.resolve()).catch(function() { + failed++; + }).then(function() { + const otherAirportNodes = airportRecords().filter(function(record) { + return record.backend === 'daed' && (!existingAirport || record.id !== existingAirport.id); + }).map(function(record) { return record.node_ids; }); + const otherGroupNodes = before.groups.filter(function(other) { + return other.id !== groupId; + }).map(function(other) { return other.nodes.map(function(node) { return node.id; }); }); + const stale = airportSync.safeOldNodeIds(existingAirport ? existingAirport.node_ids : [], ownedIds, otherAirportNodes, otherGroupNodes); + return (stale.length + ? graphQL(endpoint, 'mutation RemoveNodes($ids:[ID!]!){removeNodes(ids:$ids)}', { ids: stale }, token) + : Promise.resolve()).catch(function() { + failed++; + }).then(function() { + writeAirportRecord(existingAirport, { + id: airportId, + backend: 'daed', + name: name, + sourceHash: state.sourceHash, + groupId: groupId, + nodeIds: ownedIds + }); + return applyUciChanges().then(function() { + items.forEach(function(item) { item.duplicate = true; item.selected = false; }); + return { added: ownedIds.length, duplicates: items.length - ownedIds.length, failed: failed }; + }); + }); + }); + }); + }); + }).catch(function(error) { + if (groupReady || !token || (!createdIds.length && !createdGroupId)) + throw error; + const cleanup = []; + if (createdIds.length) + cleanup.push(graphQL(endpoint, 'mutation RemoveNodes($ids:[ID!]!){removeNodes(ids:$ids)}', { ids: createdIds }, token)); + if (createdGroupId) + cleanup.push(graphQL(endpoint, 'mutation RemoveGroup($id:ID!){removeGroup(id:$id)}', { id: createdGroupId }, token)); + return Promise.all(cleanup).catch(function() {}).then(function() { throw error; }); }).finally(function() { if (credentials) credentials.password = ''; @@ -366,15 +728,27 @@ return view.extend({ const items = selectedResults(); if (!items.length) return; + const name = airportName.value.trim(); + if (!name) { + setImportStatus(_('Enter a group name'), 'err'); + return; + } + if (!airportSync.isGroupNameValid(name, state.target)) { + setImportStatus(_('dae group names may only contain letters, numbers, underscores, and hyphens, and must start with a letter or underscore.'), 'err'); + return; + } + const existing = currentAirport(); + if (existing && !window.confirm(_('Replace node group "%s" with %d selected nodes?').format(existing.name, items.length))) + return; importButton.disabled = true; - setStatus(_('Importing selected nodes…')); + setImportStatus(_('Importing node group…')); const action = state.target === 'dae' ? importDae(items) : importDaed(items); action.then(function(result) { - setStatus(_('Import complete: added %d, duplicates %d, failed %d') + setImportStatus(_('Node group imported: added %d, reused %d, failed %d') .format(result.added, result.duplicates, result.failed), result.failed ? 'err' : 'ok'); renderResults(); }).catch(function(e) { - setStatus(_('Import failed: %s').format(e.message || e), 'err'); + setImportStatus(_('Node group import failed: %s').format(e.message || e), 'err'); }).finally(function() { importButton.disabled = selectedResults().length === 0; }); @@ -385,13 +759,13 @@ return view.extend({ return E('div', { 'class': 'dd-wrap dd-converter' }, [ E('style', {}, styles.CSS), E('div', { 'class': 'dd-card dd-conv-card' }, [ - E('h3', {}, _('Subscription Converter')), E('p', { 'class': 'dd-settings-descr' }, _('Convert Clash YAML into share links, preview the result, then import selected nodes into dae or daed. Inputs and credentials are not saved.')), E('h4', { 'class': 'dd-card-title' }, _('1. Input Clash YAML')), - E('div', { 'class': 'dd-conv-url-row' }, [ urlInput, parseUrl ]), + E('div', { 'class': 'dd-conv-url-row' }, [ urlInput, uaSelect, parseUrl ]), E('div', { 'class': 'dd-conv-or' }, _('or')), yamlInput, - E('div', { 'class': 'dd-actions' }, [ parsePaste ]) + E('div', { 'class': 'dd-actions' }, [ parsePaste ]), + sourceStatus ]), E('div', { 'class': 'dd-card dd-conv-card' }, [ E('div', { 'class': 'dd-conv-heading' }, [ @@ -402,13 +776,17 @@ return view.extend({ resultBody ]), E('div', { 'class': 'dd-card dd-conv-card' }, [ - E('h4', { 'class': 'dd-card-title' }, _('3. Import Selected Nodes')), - E('div', { 'class': 'dd-conv-import' }, [ + E('h4', { 'class': 'dd-card-title' }, _('3. Import Node Group')), + E('p', { 'class': 'dd-settings-descr' }, _('Import the selected nodes as one named group, so large airport node lists stay easy to manage.')), + E('div', { 'class': 'dd-conv-import dd-conv-airport' }, [ + E('label', {}, _('Group name')), + airportName, E('label', {}, _('Target backend')), - targetSelect, - importButton + targetSelect ]), - status + groupSummary, + E('div', { 'class': 'dd-conv-import dd-conv-submit' }, [ importButton ]), + importStatus ]) ]); }, diff --git a/luci-app-daede/htdocs/luci-static/resources/view/daede/log.js b/luci-app-daede/htdocs/luci-static/resources/view/daede/log.js index cf7a45a6..b0a6683d 100644 --- a/luci-app-daede/htdocs/luci-static/resources/view/daede/log.js +++ b/luci-app-daede/htdocs/luci-static/resources/view/daede/log.js @@ -55,17 +55,18 @@ function detectLevel(line) { return 'dd-error'; } -/* 简化时间戳并按本地时区显示: - * - daed 默认输出 UTC ISO 8601 (e.g. "2026-05-28T19:07:54Z"),转成本地时间 +/* 简化时间戳并按北京时间显示: + * - daed 默认输出 UTC ISO 8601 (e.g. "2026-05-28T19:07:54Z"),转成北京时间 * - 旧 logrus 短格式 (e.g. "May 25 07:04:59") 无时区信息,原样提取 */ function shortTs(raw) { if (!raw) return raw; - if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(raw)) { + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/.test(raw)) { const d = new Date(raw); if (!isNaN(d.getTime())) { + const beijing = new Date(d.getTime() + 8 * 60 * 60 * 1000); const pad = n => String(n).padStart(2, '0'); - return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()); + return pad(beijing.getUTCHours()) + ':' + pad(beijing.getUTCMinutes()) + ':' + pad(beijing.getUTCSeconds()); } } const m = raw.match(/(\d{2}:\d{2}:\d{2})/); diff --git a/luci-app-daede/htdocs/luci-static/resources/view/daede/styles.js b/luci-app-daede/htdocs/luci-static/resources/view/daede/styles.js index 1a742b91..6c7c8d4a 100644 --- a/luci-app-daede/htdocs/luci-static/resources/view/daede/styles.js +++ b/luci-app-daede/htdocs/luci-static/resources/view/daede/styles.js @@ -179,18 +179,29 @@ const CSS = [ '.dd-settings-card .cbi-section-table-row>td:nth-child(3){flex:0 0 auto !important;text-align:center}', '.dd-settings-card .cbi-section-table-row>td.cbi-section-actions{flex:0 0 auto !important;display:flex;gap:4px}', '}', - '.dd-conv-card{padding:14px 16px;margin-bottom:10px}', - '.dd-conv-card h3{font-size:18px;margin:0 0 6px}', - '.dd-conv-url-row,.dd-conv-import,.dd-conv-toolbar,.dd-conv-heading{display:flex;align-items:center;gap:8px;flex-wrap:wrap}', - '.dd-conv-url-row input,.dd-conv-filter,.dd-conv-target,.dd-conv-yaml{box-sizing:border-box;border:1px solid rgba(128,128,128,.28);border-radius:6px;background:transparent;color:inherit;font-size:12px;padding:7px 9px}', - '.dd-conv-url-row input{flex:1;min-width:240px}', + '.dd-converter{width:100%!important;max-width:1180px;margin:0 auto}', + '.dd-conv-card{padding:18px 20px;margin-bottom:12px}', + '.dd-conv-url-row{display:grid;grid-template-columns:minmax(280px,1fr) 230px max-content;align-items:center;gap:8px}', + '.dd-conv-import,.dd-conv-toolbar,.dd-conv-heading{display:flex;align-items:center;gap:8px;flex-wrap:wrap}', + '.dd-converter input,.dd-converter select,.dd-converter .cbi-button{box-sizing:border-box!important;height:32px!important;min-height:32px!important;margin:0!important;font-size:12px!important;line-height:22px!important}', + '.dd-converter input,.dd-converter select{padding:4px 8px!important;border:1px solid rgba(128,128,128,.28)!important;border-radius:6px!important;background-color:transparent;color:inherit;box-shadow:none!important}', + '.dd-converter select{padding-right:28px!important}', + '.dd-converter .cbi-button{padding:4px 12px!important}', + '.dd-conv-url-row input,.dd-conv-filter,.dd-conv-target,.dd-conv-ua,.dd-conv-yaml{box-sizing:border-box;border:1px solid rgba(128,128,128,.28);border-radius:6px;background:transparent;color:inherit;font-size:12px;padding:7px 9px}', + '.dd-conv-url-row input{width:100%;min-width:0}', + '.dd-conv-ua{width:230px;max-width:230px}', '.dd-conv-yaml{width:100%;min-height:150px;resize:vertical;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;line-height:1.5}', '.dd-conv-or{text-align:center;font-size:11px;opacity:.5;margin:7px 0}', - '.dd-conv-heading{justify-content:space-between;margin-bottom:8px}', + '.dd-conv-heading{justify-content:space-between;gap:10px;margin-bottom:10px}', '.dd-conv-heading .dd-card-title{margin:0}', + '.dd-conv-summary{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:4px 10px;font-size:11px;font-variant-numeric:tabular-nums}', + '.dd-conv-summary-item{display:inline-flex;align-items:baseline;gap:4px;white-space:nowrap;color:inherit;opacity:.62}', + '.dd-conv-summary-item strong{font-size:12px;font-weight:650;opacity:1;color:inherit}', + '.dd-conv-summary-label{font-size:10.5px}', '.dd-conv-toolbar{margin-bottom:8px}', '.dd-conv-filter{margin-left:auto;min-width:220px}', '.dd-conv-results{border:1px solid rgba(128,128,128,.18);border-radius:7px;max-height:420px;overflow:auto}', + '.dd-conv-empty{display:flex;align-items:center;justify-content:center;min-height:72px;padding:16px;text-align:center;font-size:11.5px;opacity:.48}', '.dd-conv-result{display:grid;grid-template-columns:22px minmax(100px,1fr) 90px minmax(130px,1.3fr);gap:8px;align-items:center;padding:7px 9px;border-bottom:1px solid rgba(128,128,128,.13);font-size:12px}', '.dd-conv-result:last-child{border-bottom:0}', '.dd-conv-result input{appearance:checkbox !important;-webkit-appearance:checkbox !important;accent-color:#4aa065;width:15px;height:15px}', @@ -200,9 +211,15 @@ const CSS = [ '.dd-conv-result-state.dup{opacity:.55}', '.dd-conv-result-state.bad,.dd-conv-status.err{color:#d96d6d}', '.dd-conv-import label{font-size:12px;font-weight:600;opacity:.72}', + '.dd-conv-airport{display:grid;grid-template-columns:max-content minmax(240px,420px) max-content 130px;gap:8px;align-items:center;margin-bottom:9px;padding-bottom:9px;border-bottom:1px solid rgba(128,128,128,.15)}', + '.dd-conv-airport .dd-conv-target,.dd-conv-airport-name{box-sizing:border-box;width:100% !important;min-width:0 !important;height:32px !important;min-height:32px !important;margin:0 !important;padding:4px 8px !important;border:1px solid rgba(128,128,128,.28);border-radius:6px;background-color:transparent;color:inherit;font-size:12px !important;line-height:22px !important;box-shadow:none}', + '.dd-conv-airport-name{max-width:420px}', + '.dd-conv-group-summary{margin:2px 0 10px;padding:9px 11px;border-left:3px solid rgba(74,160,101,.65);border-radius:3px;background:rgba(74,160,101,.07);font-size:12px;line-height:1.5}', + '.dd-conv-group-summary.err{border-left-color:#d96d6d;background:rgba(217,109,109,.07);color:#d96d6d}', + '.dd-conv-submit{justify-content:flex-end}', '.dd-conv-import .cbi-button{margin-left:auto}', '.dd-conv-status{margin-top:9px;font-size:11.5px;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;min-height:16px}', - '@media(max-width:640px){.dd-conv-filter{margin-left:0;width:100%}.dd-conv-result{grid-template-columns:20px minmax(90px,1fr) 68px}.dd-conv-result-state{grid-column:2/4;font-size:11px}.dd-conv-import .cbi-button{margin-left:0;width:100%}.dd-conv-url-row input{min-width:100%;width:100%}.dd-conv-url-row .cbi-button{width:100%}}' + '@media(max-width:640px){.dd-conv-card{padding:14px}.dd-conv-heading{align-items:flex-start}.dd-conv-heading .dd-card-title{width:100%}.dd-conv-summary{justify-content:flex-start;width:100%;gap:5px 12px;padding-top:2px}.dd-conv-summary-item{min-width:74px}.dd-conv-filter{margin-left:0;width:100%}.dd-conv-result{grid-template-columns:20px minmax(90px,1fr) 68px}.dd-conv-result-state{grid-column:2/4;font-size:11px}.dd-conv-submit .cbi-button{width:100%!important}.dd-conv-airport{grid-template-columns:82px minmax(0,1fr)}.dd-conv-airport>*{max-width:none !important}.dd-conv-url-row{grid-template-columns:1fr}.dd-conv-url-row input,.dd-conv-url-row .dd-conv-ua,.dd-conv-url-row .cbi-button{width:100%;max-width:none}}' ].join(''); return baseclass.extend({ CSS: CSS }); diff --git a/luci-app-daede/po/templates/daede.pot b/luci-app-daede/po/templates/daede.pot index 252961e9..dbd1bde7 100644 --- a/luci-app-daede/po/templates/daede.pot +++ b/luci-app-daede/po/templates/daede.pot @@ -461,6 +461,24 @@ msgstr "" msgid "Fetch and parse" msgstr "" +#: converter.js +msgid "Subscription User-Agent" +msgstr "" + +#: converter.js +msgid "Auto User-Agent (recommended)" +msgstr "" + +#: converter.js +msgid "Browser User-Agent" +msgstr "" + +msgid "Fetcher returned an invalid response" +msgstr "" + +msgid "Failed to read subscription data" +msgstr "" + #: converter.js msgid "Or paste Clash YAML here" msgstr "" @@ -498,7 +516,7 @@ msgid "Import selected nodes (%d)" msgstr "" #: converter.js -msgid "Detected %d · compatible %d · duplicates %d · incompatible %d" +msgid "Detected %d · compatible %d · duplicates %d · incompatible %d · ignored info %d" msgstr "" #: converter.js @@ -562,10 +580,22 @@ msgstr "" msgid "No top-level proxies list was found" msgstr "" +#: converter.js +msgid "The server returned a Base64/share-link subscription, not Clash YAML. Try another subscription User-Agent or add the URL directly as a native subscription." +msgstr "" + #: converter.js msgid "Conversion preview is ready" msgstr "" +#: converter.js +msgid "Conversion completed, but no supported nodes were found" +msgstr "" + +#: converter.js +msgid "Unsupported protocol: %s" +msgstr "" + #: converter.js msgid "Enter an HTTP or HTTPS subscription URL" msgstr "" @@ -600,3 +630,111 @@ msgstr "" msgid "Import complete: added %d, duplicates %d, failed %d" msgstr "" + +msgid "Create new airport" +msgstr "" + +msgid "Existing airport" +msgstr "" + +msgid "Airport name" +msgstr "" + +msgid "Sync airport" +msgstr "" + +msgid "Sync airport (%d nodes)" +msgstr "" + +msgid "Already exists, will be reused" +msgstr "" + +msgid "Enter an airport name" +msgstr "" + +msgid "No usable nodes were imported" +msgstr "" + +msgid "Synchronizing airport…" +msgstr "" + +msgid "Sync complete: added %d, reused %d, failed %d" +msgstr "" + +msgid "Sync failed: %s" +msgstr "" + +msgid "This will delete the old nodes in \"%s\" and import %d new nodes. Continue?" +msgstr "" + +msgid "3. Sync Airport Group" +msgstr "" + +msgid "The selected nodes are kept together as one airport group. Re-syncing an existing airport safely replaces its old managed nodes." +msgstr "" + +msgid "Detected" +msgstr "" + +msgid "Compatible" +msgstr "" + +msgid "Duplicates" +msgstr "" + +msgid "Incompatible" +msgstr "" + +msgid "Ignored info" +msgstr "" + +msgid "No nodes to preview yet. Fetch or paste Clash YAML above." +msgstr "" + +msgid "Group name" +msgstr "" + +msgid "Import node group" +msgstr "" + +msgid "Import node group (%d)" +msgstr "" + +msgid "Update node group (%d)" +msgstr "" + +msgid "Enter a group name to import the selected nodes." +msgstr "" + +msgid "dae group names may only contain letters, numbers, underscores, and hyphens, and must start with a letter or underscore." +msgstr "" + +msgid "Update node group \"%s\": replace it with %d selected nodes on %s." +msgstr "" + +msgid "Import %d selected nodes into group \"%s\" on %s." +msgstr "" + +msgid "Enter a group name" +msgstr "" + +msgid "Replace node group \"%s\" with %d selected nodes?" +msgstr "" + +msgid "Importing node group…" +msgstr "" + +msgid "Node group imported: added %d, reused %d, failed %d" +msgstr "" + +msgid "Node group import failed: %s" +msgstr "" + +msgid "3. Import Node Group" +msgstr "" + +msgid "Import the selected nodes as one named group, so large airport node lists stay easy to manage." +msgstr "" + +msgid "Node group \"%s\" is available on %s. Select nodes above to update it." +msgstr "" diff --git a/luci-app-daede/po/zh-cn/daede.po b/luci-app-daede/po/zh-cn/daede.po index f2708cd8..9144d284 100644 --- a/luci-app-daede/po/zh-cn/daede.po +++ b/luci-app-daede/po/zh-cn/daede.po @@ -146,10 +146,6 @@ msgstr "dae 没有内置 WebUI,下面的 DSL 配置需要手工编辑(订阅 msgid "dae config file is empty. Click \"Initialize from example\" to start, or paste your config here." msgstr "dae 配置文件为空。点「从示例初始化」开始,或直接粘贴你的配置。" -# ---- config.js: dae Parameters ---- -msgid "dae Parameters" -msgstr "dae 参数" - msgid "Edit config DSL — subscriptions, nodes, routing, DNS. Load template via Initialize. Replace placeholder URL before saving. Switch to daed for GUI." msgstr "编辑 dae DSL 配置——订阅、节点、路由、DNS。通过「从示例初始化」载入模板。保存前务必替换占位订阅链接。图形化管理请切换至 daed。" @@ -426,9 +422,6 @@ msgstr "已保存(启动 dae 后生效)" msgid "Configuration Editor" msgstr "配置编辑" -msgid "Edit config DSL — subscriptions, nodes, routing, DNS. Load template via Initialize. Replace placeholder URL before saving. Switch to daed for GUI." -msgstr "编辑 dae DSL 配置——订阅、节点、路由、DNS。通过「从示例初始化」载入模板。保存前务必替换占位订阅链接。图形化管理请切换至 daed。" - # ---- config.js: Proxy Health ---- msgid "Test YouTube" msgstr "测试 YouTube" @@ -626,9 +619,6 @@ msgstr "区块" msgid "DNS" msgstr "DNS" -msgid "dae config file is empty. Click \"Initialize from example\" to start, or paste your config here." -msgstr "dae 配置文件为空。点击「从示例初始化」开始,或直接在此粘贴你的配置。" - # ---- log.js: filter dropdown ---- msgid "All" msgstr "全部" @@ -698,12 +688,6 @@ msgstr "分组名称必须唯一" msgid "Updating…" msgstr "更新中…" -msgid "Subscriptions updated (dae reloaded)" -msgstr "订阅已更新(dae 已重载)" - -msgid "Save manual config" -msgstr "保存手动配置" - msgid "Saved · started" msgstr "已保存并启动" @@ -726,6 +710,21 @@ msgstr "1. 输入 Clash YAML" msgid "Fetch and parse" msgstr "获取并解析" +msgid "Subscription User-Agent" +msgstr "订阅 User-Agent" + +msgid "Auto User-Agent (recommended)" +msgstr "自动选择 User-Agent(推荐)" + +msgid "Browser User-Agent" +msgstr "浏览器 User-Agent" + +msgid "Fetcher returned an invalid response" +msgstr "获取程序返回了无效响应" + +msgid "Failed to read subscription data" +msgstr "读取订阅数据失败" + msgid "Or paste Clash YAML here" msgstr "或在此粘贴 Clash YAML" @@ -753,8 +752,8 @@ msgstr "目标后端" msgid "Import selected nodes (%d)" msgstr "导入所选节点(%d)" -msgid "Detected %d · compatible %d · duplicates %d · incompatible %d" -msgstr "检测到 %d 个 · 可导入 %d 个 · 重复 %d 个 · 不兼容 %d 个" +msgid "Detected %d · compatible %d · duplicates %d · incompatible %d · ignored info %d" +msgstr "检测到 %d 个 · 可导入 %d 个 · 重复 %d 个 · 不兼容 %d 个 · 已忽略信息 %d 个" msgid "Ready to import" msgstr "可导入" @@ -801,9 +800,18 @@ msgstr "YAML 解析失败:%s" msgid "No top-level proxies list was found" msgstr "未找到顶层 proxies 列表" +msgid "The server returned a Base64/share-link subscription, not Clash YAML. Try another subscription User-Agent or add the URL directly as a native subscription." +msgstr "服务器返回的是 Base64/分享链接订阅,不是 Clash YAML。请尝试其他订阅 User-Agent,或直接将该链接添加为原生订阅。" + msgid "Conversion preview is ready" msgstr "转换预览已就绪" +msgid "Conversion completed, but no supported nodes were found" +msgstr "转换完成,但未找到支持的节点" + +msgid "Unsupported protocol: %s" +msgstr "不支持的协议:%s" + msgid "Enter an HTTP or HTTPS subscription URL" msgstr "请输入 HTTP 或 HTTPS 订阅链接" @@ -830,3 +838,111 @@ msgstr "导入失败:%s" msgid "Import complete: added %d, duplicates %d, failed %d" msgstr "导入完成:新增 %d,重复 %d,失败 %d" + +msgid "Create new airport" +msgstr "新建机场" + +msgid "Existing airport" +msgstr "已有机场" + +msgid "Airport name" +msgstr "机场名称" + +msgid "Sync airport" +msgstr "同步机场" + +msgid "Sync airport (%d nodes)" +msgstr "同步机场(%d 个节点)" + +msgid "Already exists, will be reused" +msgstr "已存在,将复用" + +msgid "Enter an airport name" +msgstr "请输入机场名称" + +msgid "No usable nodes were imported" +msgstr "未导入任何可用节点" + +msgid "Synchronizing airport…" +msgstr "正在同步机场…" + +msgid "Sync complete: added %d, reused %d, failed %d" +msgstr "同步完成:新增 %d,复用 %d,失败 %d" + +msgid "Sync failed: %s" +msgstr "同步失败:%s" + +msgid "This will delete the old nodes in \"%s\" and import %d new nodes. Continue?" +msgstr "将删除“%s”的旧节点并导入 %d 个新节点,是否继续?" + +msgid "3. Sync Airport Group" +msgstr "3. 同步机场分组" + +msgid "The selected nodes are kept together as one airport group. Re-syncing an existing airport safely replaces its old managed nodes." +msgstr "所选节点会作为一个机场分组管理。再次同步已有机场时,会安全替换该机场原有的受管节点。" + +msgid "Detected" +msgstr "检测" + +msgid "Compatible" +msgstr "可导入" + +msgid "Duplicates" +msgstr "重复" + +msgid "Incompatible" +msgstr "不兼容" + +msgid "Ignored info" +msgstr "忽略信息" + +msgid "No nodes to preview yet. Fetch or paste Clash YAML above." +msgstr "暂无可预览节点,请先在上方获取或粘贴 Clash YAML。" + +msgid "Group name" +msgstr "组名" + +msgid "Import node group" +msgstr "导入节点组" + +msgid "Import node group (%d)" +msgstr "导入节点组(%d)" + +msgid "Update node group (%d)" +msgstr "更新节点组(%d)" + +msgid "Enter a group name to import the selected nodes." +msgstr "输入组名后,将所选节点作为一个节点组导入。" + +msgid "dae group names may only contain letters, numbers, underscores, and hyphens, and must start with a letter or underscore." +msgstr "dae 组名只能包含字母、数字、下划线和连字符,并且必须以字母或下划线开头。" + +msgid "Update node group \"%s\": replace it with %d selected nodes on %s." +msgstr "更新节点组“%s”:使用所选的 %d 个节点替换,目标后端为 %s。" + +msgid "Import %d selected nodes into group \"%s\" on %s." +msgstr "将所选的 %d 个节点归入“%s”组,并导入 %s。" + +msgid "Enter a group name" +msgstr "请输入组名" + +msgid "Replace node group \"%s\" with %d selected nodes?" +msgstr "使用当前所选节点替换节点组“%s”,共 %d 个节点,是否继续?" + +msgid "Importing node group…" +msgstr "正在导入节点组…" + +msgid "Node group imported: added %d, reused %d, failed %d" +msgstr "节点组导入完成:新增 %d,复用 %d,失败 %d" + +msgid "Node group import failed: %s" +msgstr "节点组导入失败:%s" + +msgid "3. Import Node Group" +msgstr "3. 导入节点组" + +msgid "Import the selected nodes as one named group, so large airport node lists stay easy to manage." +msgstr "将所选节点作为一个命名节点组导入,避免机场节点较多时难以管理。" + +msgid "Node group \"%s\" is available on %s. Select nodes above to update it." +msgstr "节点组“%s”已导入 %s。重新选择上方节点即可更新此组。" diff --git a/luci-app-daede/po/zh_Hans/daede.po b/luci-app-daede/po/zh_Hans/daede.po index f2708cd8..9144d284 100644 --- a/luci-app-daede/po/zh_Hans/daede.po +++ b/luci-app-daede/po/zh_Hans/daede.po @@ -146,10 +146,6 @@ msgstr "dae 没有内置 WebUI,下面的 DSL 配置需要手工编辑(订阅 msgid "dae config file is empty. Click \"Initialize from example\" to start, or paste your config here." msgstr "dae 配置文件为空。点「从示例初始化」开始,或直接粘贴你的配置。" -# ---- config.js: dae Parameters ---- -msgid "dae Parameters" -msgstr "dae 参数" - msgid "Edit config DSL — subscriptions, nodes, routing, DNS. Load template via Initialize. Replace placeholder URL before saving. Switch to daed for GUI." msgstr "编辑 dae DSL 配置——订阅、节点、路由、DNS。通过「从示例初始化」载入模板。保存前务必替换占位订阅链接。图形化管理请切换至 daed。" @@ -426,9 +422,6 @@ msgstr "已保存(启动 dae 后生效)" msgid "Configuration Editor" msgstr "配置编辑" -msgid "Edit config DSL — subscriptions, nodes, routing, DNS. Load template via Initialize. Replace placeholder URL before saving. Switch to daed for GUI." -msgstr "编辑 dae DSL 配置——订阅、节点、路由、DNS。通过「从示例初始化」载入模板。保存前务必替换占位订阅链接。图形化管理请切换至 daed。" - # ---- config.js: Proxy Health ---- msgid "Test YouTube" msgstr "测试 YouTube" @@ -626,9 +619,6 @@ msgstr "区块" msgid "DNS" msgstr "DNS" -msgid "dae config file is empty. Click \"Initialize from example\" to start, or paste your config here." -msgstr "dae 配置文件为空。点击「从示例初始化」开始,或直接在此粘贴你的配置。" - # ---- log.js: filter dropdown ---- msgid "All" msgstr "全部" @@ -698,12 +688,6 @@ msgstr "分组名称必须唯一" msgid "Updating…" msgstr "更新中…" -msgid "Subscriptions updated (dae reloaded)" -msgstr "订阅已更新(dae 已重载)" - -msgid "Save manual config" -msgstr "保存手动配置" - msgid "Saved · started" msgstr "已保存并启动" @@ -726,6 +710,21 @@ msgstr "1. 输入 Clash YAML" msgid "Fetch and parse" msgstr "获取并解析" +msgid "Subscription User-Agent" +msgstr "订阅 User-Agent" + +msgid "Auto User-Agent (recommended)" +msgstr "自动选择 User-Agent(推荐)" + +msgid "Browser User-Agent" +msgstr "浏览器 User-Agent" + +msgid "Fetcher returned an invalid response" +msgstr "获取程序返回了无效响应" + +msgid "Failed to read subscription data" +msgstr "读取订阅数据失败" + msgid "Or paste Clash YAML here" msgstr "或在此粘贴 Clash YAML" @@ -753,8 +752,8 @@ msgstr "目标后端" msgid "Import selected nodes (%d)" msgstr "导入所选节点(%d)" -msgid "Detected %d · compatible %d · duplicates %d · incompatible %d" -msgstr "检测到 %d 个 · 可导入 %d 个 · 重复 %d 个 · 不兼容 %d 个" +msgid "Detected %d · compatible %d · duplicates %d · incompatible %d · ignored info %d" +msgstr "检测到 %d 个 · 可导入 %d 个 · 重复 %d 个 · 不兼容 %d 个 · 已忽略信息 %d 个" msgid "Ready to import" msgstr "可导入" @@ -801,9 +800,18 @@ msgstr "YAML 解析失败:%s" msgid "No top-level proxies list was found" msgstr "未找到顶层 proxies 列表" +msgid "The server returned a Base64/share-link subscription, not Clash YAML. Try another subscription User-Agent or add the URL directly as a native subscription." +msgstr "服务器返回的是 Base64/分享链接订阅,不是 Clash YAML。请尝试其他订阅 User-Agent,或直接将该链接添加为原生订阅。" + msgid "Conversion preview is ready" msgstr "转换预览已就绪" +msgid "Conversion completed, but no supported nodes were found" +msgstr "转换完成,但未找到支持的节点" + +msgid "Unsupported protocol: %s" +msgstr "不支持的协议:%s" + msgid "Enter an HTTP or HTTPS subscription URL" msgstr "请输入 HTTP 或 HTTPS 订阅链接" @@ -830,3 +838,111 @@ msgstr "导入失败:%s" msgid "Import complete: added %d, duplicates %d, failed %d" msgstr "导入完成:新增 %d,重复 %d,失败 %d" + +msgid "Create new airport" +msgstr "新建机场" + +msgid "Existing airport" +msgstr "已有机场" + +msgid "Airport name" +msgstr "机场名称" + +msgid "Sync airport" +msgstr "同步机场" + +msgid "Sync airport (%d nodes)" +msgstr "同步机场(%d 个节点)" + +msgid "Already exists, will be reused" +msgstr "已存在,将复用" + +msgid "Enter an airport name" +msgstr "请输入机场名称" + +msgid "No usable nodes were imported" +msgstr "未导入任何可用节点" + +msgid "Synchronizing airport…" +msgstr "正在同步机场…" + +msgid "Sync complete: added %d, reused %d, failed %d" +msgstr "同步完成:新增 %d,复用 %d,失败 %d" + +msgid "Sync failed: %s" +msgstr "同步失败:%s" + +msgid "This will delete the old nodes in \"%s\" and import %d new nodes. Continue?" +msgstr "将删除“%s”的旧节点并导入 %d 个新节点,是否继续?" + +msgid "3. Sync Airport Group" +msgstr "3. 同步机场分组" + +msgid "The selected nodes are kept together as one airport group. Re-syncing an existing airport safely replaces its old managed nodes." +msgstr "所选节点会作为一个机场分组管理。再次同步已有机场时,会安全替换该机场原有的受管节点。" + +msgid "Detected" +msgstr "检测" + +msgid "Compatible" +msgstr "可导入" + +msgid "Duplicates" +msgstr "重复" + +msgid "Incompatible" +msgstr "不兼容" + +msgid "Ignored info" +msgstr "忽略信息" + +msgid "No nodes to preview yet. Fetch or paste Clash YAML above." +msgstr "暂无可预览节点,请先在上方获取或粘贴 Clash YAML。" + +msgid "Group name" +msgstr "组名" + +msgid "Import node group" +msgstr "导入节点组" + +msgid "Import node group (%d)" +msgstr "导入节点组(%d)" + +msgid "Update node group (%d)" +msgstr "更新节点组(%d)" + +msgid "Enter a group name to import the selected nodes." +msgstr "输入组名后,将所选节点作为一个节点组导入。" + +msgid "dae group names may only contain letters, numbers, underscores, and hyphens, and must start with a letter or underscore." +msgstr "dae 组名只能包含字母、数字、下划线和连字符,并且必须以字母或下划线开头。" + +msgid "Update node group \"%s\": replace it with %d selected nodes on %s." +msgstr "更新节点组“%s”:使用所选的 %d 个节点替换,目标后端为 %s。" + +msgid "Import %d selected nodes into group \"%s\" on %s." +msgstr "将所选的 %d 个节点归入“%s”组,并导入 %s。" + +msgid "Enter a group name" +msgstr "请输入组名" + +msgid "Replace node group \"%s\" with %d selected nodes?" +msgstr "使用当前所选节点替换节点组“%s”,共 %d 个节点,是否继续?" + +msgid "Importing node group…" +msgstr "正在导入节点组…" + +msgid "Node group imported: added %d, reused %d, failed %d" +msgstr "节点组导入完成:新增 %d,复用 %d,失败 %d" + +msgid "Node group import failed: %s" +msgstr "节点组导入失败:%s" + +msgid "3. Import Node Group" +msgstr "3. 导入节点组" + +msgid "Import the selected nodes as one named group, so large airport node lists stay easy to manage." +msgstr "将所选节点作为一个命名节点组导入,避免机场节点较多时难以管理。" + +msgid "Node group \"%s\" is available on %s. Select nodes above to update it." +msgstr "节点组“%s”已导入 %s。重新选择上方节点即可更新此组。" diff --git a/luci-app-daede/root/usr/share/luci-app-daede/fetch-clash-yaml.sh b/luci-app-daede/root/usr/share/luci-app-daede/fetch-clash-yaml.sh index eb69af45..19ce5a66 100755 --- a/luci-app-daede/root/usr/share/luci-app-daede/fetch-clash-yaml.sh +++ b/luci-app-daede/root/usr/share/luci-app-daede/fetch-clash-yaml.sh @@ -3,7 +3,31 @@ set -eu +CHUNK_BYTES=16384 +ACTION="${1:-}" + +case "$ACTION" in + chunk) + TOKEN="${2:-}" + INDEX="${3:-}" + case "$TOKEN" in *[!A-Za-z0-9]*|'') exit 2 ;; esac + case "$INDEX" in *[!0-9]*|'') exit 2 ;; esac + FILE="/tmp/daede-clash-yaml-result.$TOKEN" + [ -f "$FILE" ] || exit 4 + dd if="$FILE" bs="$CHUNK_BYTES" skip="$INDEX" count=1 2>/dev/null | hexdump -ve '1/1 "%02x"' + exit 0 + ;; + cleanup) + TOKEN="${2:-}" + case "$TOKEN" in *[!A-Za-z0-9]*|'') exit 2 ;; esac + rm -f "/tmp/daede-clash-yaml-result.$TOKEN" + exit 0 + ;; +esac + URL="${1:-}" +UA_MODE="${2:-auto}" +OUTPUT_MODE="${3:-stdout}" MAX_BYTES=5242880 FETCH_BIN="${DAEDE_FETCH_BIN:-uclient-fetch}" @@ -16,22 +40,95 @@ case "$URL" in esac TMP="$(mktemp /tmp/daede-clash-yaml.XXXXXX)" -trap 'rm -f "$TMP"' EXIT INT TERM +FIRST="$(mktemp /tmp/daede-clash-yaml-first.XXXXXX)" +ERR="$(mktemp /tmp/daede-clash-yaml-error.XXXXXX)" +trap 'rm -f "$TMP" "$FIRST" "$ERR"' EXIT INT TERM chmod 600 "$TMP" +chmod 600 "$FIRST" +chmod 600 "$ERR" -if ! "$FETCH_BIN" -q -T 20 -U "ClashMeta" -O "$TMP" "$URL"; then - echo "Failed to fetch subscription" >&2 - exit 3 +case "$UA_MODE" in + auto|browser|ClashMeta|clash-verge/v2.4.2|ClashForWindows/0.20.39|Clash) ;; + *) + echo "Unsupported subscription User-Agent" >&2 + exit 2 + ;; +esac + +case "$OUTPUT_MODE" in + stdout|handle) ;; + *) + echo "Unsupported output mode" >&2 + exit 2 + ;; +esac + +LAST_ERROR="Failed to fetch subscription" + +emit_result() { + local source="$1" result token size + if [ "$OUTPUT_MODE" = "stdout" ]; then + cat "$source" + return + fi + + # Keep each RPC response small. The browser reads this root-only temporary + # file through the chunk action and removes it in a finally handler. + find /tmp -maxdepth 1 -name 'daede-clash-yaml-result.*' -mmin +10 -delete 2>/dev/null || true + result="$(mktemp /tmp/daede-clash-yaml-result.XXXXXX)" + chmod 600 "$result" + cp "$source" "$result" + token="${result##*.}" + size="$(wc -c <"$result" | tr -d ' ')" + printf '%s\t%s\n' "$token" "$size" +} + +fetch_one() { + local ua="$1" size response_error + if [ "$ua" = "browser" ]; then + ua="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36" + fi + : >"$TMP" + : >"$ERR" + if ! "$FETCH_BIN" -q -T 20 -U "$ua" -O "$TMP" "$URL" 2>"$ERR"; then + response_error="$(head -c 512 "$TMP" | tr '\r\n\t' ' ')" + LAST_ERROR="${response_error:-$(cat "$ERR")}" + [ -n "$LAST_ERROR" ] || LAST_ERROR="Failed to fetch subscription" + return 1 + fi + + size="$(wc -c <"$TMP" | tr -d ' ')" + if [ "$size" -eq 0 ]; then + LAST_ERROR="Subscription response is empty" + return 1 + fi + if [ "$size" -gt "$MAX_BYTES" ]; then + LAST_ERROR="Subscription response is too large (maximum 5 MiB)" + return 1 + fi + return 0 +} + +if [ "$UA_MODE" = "auto" ]; then + for ua in ClashMeta clash-verge/v2.4.2 ClashForWindows/0.20.39 Clash browser; do + if fetch_one "$ua"; then + [ -s "$FIRST" ] || cp "$TMP" "$FIRST" + if grep -Eq '^[[:space:]]*proxies[[:space:]]*:' "$TMP"; then + emit_result "$TMP" + exit 0 + fi + fi + done + if [ -s "$FIRST" ]; then + emit_result "$FIRST" + exit 0 + fi +else + if fetch_one "$UA_MODE"; then + emit_result "$TMP" + exit 0 + fi fi -SIZE="$(wc -c <"$TMP" | tr -d ' ')" -if [ "$SIZE" -eq 0 ]; then - echo "Subscription response is empty" >&2 - exit 4 -fi -if [ "$SIZE" -gt "$MAX_BYTES" ]; then - echo "Subscription response is too large (maximum 5 MiB)" >&2 - exit 5 -fi - -cat "$TMP" +echo "$LAST_ERROR" >&2 +exit 3 diff --git a/luci-app-daede/tests/airport-group-name.js b/luci-app-daede/tests/airport-group-name.js new file mode 100644 index 00000000..80495c94 --- /dev/null +++ b/luci-app-daede/tests/airport-group-name.js @@ -0,0 +1,22 @@ +const assert = require('assert'); +const airportSync = require('../htdocs/luci-static/resources/view/daede/airport-sync.js'); + +assert.strictEqual(airportSync.backendGroupName('fs-cloud', 'dae', 'airport_123'), 'fs-cloud'); +assert.strictEqual(airportSync.backendGroupName('fs-cloud', 'daed', 'airport_123'), 'fs-cloud'); +assert.strictEqual(airportSync.backendGroupName('白月光', 'daed', 'airport_123'), '白月光'); +assert.strictEqual(airportSync.backendGroupName('白月光', 'dae', 'airport_123'), 'airport123'); +assert.strictEqual(airportSync.backendGroupName('', 'dae', 'airport_123'), 'airport123'); +assert.strictEqual(airportSync.isGroupNameValid('fs-cloud', 'dae'), true); +assert.strictEqual(airportSync.isGroupNameValid('白月光', 'dae'), false); +assert.strictEqual(airportSync.isGroupNameValid('白月光', 'daed'), true); + +const managed = { group_id: 'group-1', name: 'Airport 1' }; +assert.strictEqual(airportSync.findManagedGroup([ + { id: 'group-1', name: 'Airport 1' }, + { id: 'group-2', name: 'Airport 2' } +], managed).id, 'group-1'); +assert.strictEqual(airportSync.findManagedGroup([ + { id: 'group-1', name: 'Unrelated group' } +], managed), null); + +console.log('airport group name tests passed'); diff --git a/luci-app-daede/tests/airport-sanitize-name.js b/luci-app-daede/tests/airport-sanitize-name.js new file mode 100644 index 00000000..c3fc3b8a --- /dev/null +++ b/luci-app-daede/tests/airport-sanitize-name.js @@ -0,0 +1,20 @@ +const assert = require('assert'); +const airportSync = require('../htdocs/luci-static/resources/view/daede/airport-sync.js'); + +// dae: derived names must come out as valid dae group names (no manual fix). +assert.strictEqual(airportSync.sanitizeGroupName('sub.example.com', 'dae'), 'sub_example_com'); +assert.strictEqual(airportSync.sanitizeGroupName('My Airport', 'dae'), 'My_Airport'); +assert.strictEqual(airportSync.sanitizeGroupName('机场_1', 'dae'), '_1'); +assert.strictEqual(airportSync.sanitizeGroupName('....', 'dae'), 'airport'); +assert.strictEqual(airportSync.sanitizeGroupName('', 'dae'), ''); +// whatever dae sanitize returns (non-empty) must pass the group-name validator. +['sub.example.com', 'My Airport', '机场_1', '1up.cloud', 'a.b.c.d'].forEach(function(raw) { + const out = airportSync.sanitizeGroupName(raw, 'dae'); + assert.strictEqual(airportSync.isGroupNameValid(out, 'dae'), true, 'invalid dae name from: ' + raw + ' -> ' + out); +}); + +// daed: any non-empty name is allowed, keep it as-is (incl. CJK and dots). +assert.strictEqual(airportSync.sanitizeGroupName('白月光', 'daed'), '白月光'); +assert.strictEqual(airportSync.sanitizeGroupName('sub.example.com', 'daed'), 'sub.example.com'); + +console.log('airport sanitize name tests passed');