// SPDX-License-Identifier: Apache-2.0 'use strict'; 'require fs'; '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.daed-session as daedSession'; '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 SNAPSHOT_HELPER = '/usr/share/luci-app-daede/snapshot-file.sh'; const SUB_STAGE = '/tmp/daede-sub.txt'; 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) return Promise.resolve(window.jsyaml); return new Promise(function(resolve, reject) { const script = document.createElement('script'); script.src = L.resource('view/daede/vendor/js-yaml.min.js'); script.onload = function() { if (window.jsyaml && window.jsyaml.load) resolve(window.jsyaml); else reject(new Error(_('YAML parser failed to load'))); }; script.onerror = function() { reject(new Error(_('YAML parser failed to load'))); }; document.head.appendChild(script); }); } function daedEndpoint() { // HTTPS page: browser blocks the plain-HTTP :2023 fetch as mixed // content, so relay via a same-origin CGI. HTTP: hit daed direct. #11 if (window.location.protocol === 'https:') return '/cgi-bin/daede-graphql'; const listen = uci.get('daed', 'config', 'listen_addr') || '0.0.0.0:2023'; const match = String(listen).match(/:(\d+)$/); const port = match ? match[1] : '2023'; const host = window.location.hostname.indexOf(':') >= 0 ? '[' + window.location.hostname + ']' : window.location.hostname; return 'http://' + host + ':' + port + '/graphql'; } function graphQL(endpoint, query, variables, token) { const headers = { 'Content-Type': 'application/json' }; if (token) headers.Authorization = 'Bearer ' + token; return fetch(endpoint, { method: 'POST', headers: headers, body: JSON.stringify({ query: query, variables: variables || {} }) }).then(function(response) { if (!response.ok) throw new Error(_('daed returned HTTP %s').format(response.status)); return response.json(); }).then(function(body) { if (body.errors && body.errors.length) throw new Error(body.errors.map(function(e) { return e.message; }).join('; ')); return body.data; }); } function requestDaedCredentials(mode) { const init = (mode === 'init'); return new Promise(function(resolve, reject) { const username = E('input', { 'class': 'cbi-input-text', 'autocomplete': 'username', 'placeholder': _('Username') }); const password = E('input', { 'class': 'cbi-input-password', 'type': 'password', 'autocomplete': init ? 'new-password' : 'current-password', 'placeholder': _('Password') }); if (init) { // prefill from the config-page account so the daed admin created here // matches what cron subscription updates use. username.value = uci.get('daed', 'config', 'dashboard_username') || ''; password.value = uci.get('daed', 'config', 'dashboard_password') || ''; } const cancel = E('button', { 'class': 'btn cbi-button' }, _('Cancel')); const submit = E('button', { 'class': 'btn cbi-button cbi-button-positive' }, init ? _('Initialize and import') : _('Sign in and import')); cancel.addEventListener('click', function() { password.value = ''; ui.hideModal(); reject(new Error(init ? _('daed is not initialized yet. Open the daed web panel (port 2023) to create an admin account, then import again.') : _('Import cancelled'))); }); submit.addEventListener('click', function() { if (!username.value || !password.value) return; const result = { username: username.value, password: password.value }; password.value = ''; ui.hideModal(); resolve(result); }); const intro = init ? _('daed has not been initialized yet. The account below will be created as the daed admin (password needs at least 6 characters with both letters and numbers), then used to import.') : _('The password is not saved. Sign-in is remembered in this browser for up to 30 days.'); ui.showModal(init ? _('Initialize daed') : _('Sign in to daed'), [ E('div', { 'class': 'dd-daed-login' }, [ E('p', {}, intro), E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('Username')), E('div', { 'class': 'cbi-value-field' }, username) ]), E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('Password')), E('div', { 'class': 'cbi-value-field' }, password) ]), E('div', { 'class': 'right dd-daed-login-actions' }, [ cancel, ' ', submit ]) ]) ]); username.focus(); }); } function requestDaedToken(endpoint, forceLogin) { const cached = forceLogin ? '' : daedSession.load(window.localStorage); if (cached) return Promise.resolve({ token: cached, cached: true }); // numberUsers is unauthenticated; 0 means the daed panel was never // initialized, so log-in can never succeed — bootstrap it with createUser. return graphQL(endpoint, 'query Init{numberUsers}', {}).then(function(state) { const needInit = !state || state.numberUsers === 0; let credentials; return requestDaedCredentials(needInit ? 'init' : 'login').then(function(value) { credentials = value; if (needInit) return graphQL(endpoint, 'mutation Init($username:String!,$password:String!){createUser(username:$username,password:$password)}', credentials).then(function(r) { return r.createUser; }); return graphQL(endpoint, 'query Login($username:String!,$password:String!){token(username:$username,password:$password)}', credentials).then(function(r) { return r.token; }); }).then(function(token) { daedSession.save(window.localStorage, token); return { token: token, cached: false }; }).finally(function() { if (credentials) credentials.password = ''; credentials = null; }); }); } 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); }); }).then(function() { // refresh LuCI's header indicator so the just-committed changes stop // showing as "Unsaved Changes". return ui.changes.init(); }); } 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) { const v = values[key]; // uci/set rejects an empty list value; clear the option instead. if (Array.isArray(v) && v.length === 0) uci.unset('daede', sid, key); else uci.set('daede', sid, key, v); }); 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[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')); const filterInput = E('input', { 'class': 'dd-conv-filter', 'placeholder': _('Filter name or protocol') }); 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 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 importTitle = E('h4', { 'class': 'dd-card-title' }); const airportNameLabel = E('label'); 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) { if (data.ctx.installed[name]) targetSelect.appendChild(E('option', { 'value': name, 'selected': state.target === name ? 'selected' : null }, name)); }); 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; }); }; const currentAirport = function() { return airportSync.matchAirport(airportRecords(), { backend: state.target, selectedId: state.selectedAirportId, name: airportName.value.trim() }); }; const classifyDaeDuplicates = function() { const existing = {}; (uci.sections('dae', 'node') || []).forEach(function(section) { if (section.link) existing[clashConverter.normalizeLink(section.link)] = true; }); state.results.forEach(function(item) { item.duplicate = !!(item.ok && existing[clashConverter.normalizeLink(item.link)]); }); }; const updateResultSummary = function() { const isDaed = state.target === 'daed'; importTitle.textContent = isDaed ? _('3. Import Subscription') : _('3. Import Node Group'); airportNameLabel.textContent = isDaed ? _('Subscription name') : _('Group name'); 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; 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 = isDaed ? _('Enter a subscription name to import the selected nodes.') : _('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 (isDaed && existing && count === 0) { groupSummary.textContent = _('Subscription "%s" is available on daed. Select nodes above to update it.').format(name); groupSummary.className = 'dd-conv-group-summary'; } else if (isDaed) { groupSummary.textContent = existing ? _('Update subscription "%s" with %d selected nodes in default group "proxy".').format(name, count) : _('Import %d selected nodes as subscription "%s" into default group "proxy".').format(count, name); groupSummary.className = 'dd-conv-group-summary'; } 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 = isDaed ? (existing ? _('Update subscription (%d)').format(count) : _('Import subscription (%d)').format(count)) : (existing ? _('Update node group (%d)').format(count) : _('Import node group (%d)').format(count)); importButton.disabled = count === 0 || !validName; }; const renderResults = function() { while (resultBody.firstChild) resultBody.removeChild(resultBody.firstChild); const filter = state.filter.toLowerCase(); const shown = state.results.filter(function(item) { return !filter || item.name.toLowerCase().indexOf(filter) >= 0 || item.type.toLowerCase().indexOf(filter) >= 0; }); shown.forEach(function(item) { const checkbox = E('input', { 'type': 'checkbox' }); checkbox.checked = !!item.selected; checkbox.disabled = !item.ok; checkbox.addEventListener('change', function() { item.selected = checkbox.checked; updateResultSummary(); }); let resultText, resultClass; if (!item.ok) { resultText = item.error.indexOf('Unsupported protocol:') === 0 ? _('Unsupported protocol: %s').format(item.type) : item.error; resultClass = 'bad'; } else if (item.duplicate) { resultText = _('Already exists, will be reused'); resultClass = 'dup'; } else { resultText = _('Ready to import'); resultClass = 'good'; } resultBody.appendChild(E('div', { 'class': 'dd-conv-result' }, [ checkbox, E('span', { 'class': 'dd-conv-name', 'title': item.name }, item.name), E('span', { 'class': 'dd-conv-proto' }, item.type.toUpperCase()), 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.'))); updateResultSummary(); }; 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) { clearPreview(); setSourceStatus(_('YAML parse failed: %s').format(e.message || e), 'err'); return Promise.resolve(); } if (!documentValue || !Array.isArray(documentValue.proxies) || documentValue.proxies.length === 0) { 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(); } 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(); 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)) { clearPreview(); setSourceStatus(_('Enter an HTTP or HTTPS subscription URL'), 'err'); return; } parseUrl.disabled = true; clearPreview(); setSourceStatus(_('Fetching subscription…')); fetchYaml(value, uaSelect.value).then(function(text) { return acceptYaml(text, value, airportSync.deriveAirportName(value)); }).catch(function(e) { 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) { clearPreview(); setSourceStatus(_('Paste Clash YAML first'), 'err'); return; } 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() { state.filter = filterInput.value; renderResults(); }); selectNew.addEventListener('click', function() { state.results.forEach(function(item) { item.selected = item.ok; }); renderResults(); }); clearSelection.addEventListener('click', function() { state.results.forEach(function(item) { item.selected = false; }); renderResults(); }); targetSelect.addEventListener('change', function() { state.target = targetSelect.value; 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); // the group sources this tag; gen-dae-config slugs tag and source the // same way, so subtag() resolves even for CJK/hyphen names. const subTag = groupName; const subUrl = 'file://subscriptions/' + airportId + '.sub'; const subContent = items.map(function(item) { return item.link; }).join('\n') + '\n'; let subSid = existingAirport && existingAirport.subscription_id ? existingAirport.subscription_id : ''; let groupSid = existingAirport && existingAirport.group_id ? existingAirport.group_id : ''; const foundOldSub = (subSid && uciSection('dae', subSid)) || null; const foundOldGroup = (groupSid && uciSection('dae', groupSid)) || null; const oldSubSection = foundOldSub ? JSON.parse(JSON.stringify(foundOldSub)) : null; const oldGroupSection = foundOldGroup ? JSON.parse(JSON.stringify(foundOldGroup)) : null; const oldNodeIds = existingAirport ? existingAirport.node_ids : []; let subCreated = false; let groupCreated = false; const restoreSection = function(sid, snapshot) { const current = uciSection('dae', sid) || {}; Object.keys(current).forEach(function(key) { if (key.charAt(0) !== '.' && !Object.prototype.hasOwnProperty.call(snapshot, key)) uci.unset('dae', sid, key); }); Object.keys(snapshot).forEach(function(key) { if (key.charAt(0) !== '.') uci.set('dae', sid, key, snapshot[key]); }); }; // 1. stage the converted links and let the backend write the local // .sub file (mkdir + chmod 0600 — dae rejects loose perms). return fs.write(SUB_STAGE, subContent).then(function() { return fs.exec(GENERATOR, [ 'write-sub', airportId ]); }).then(function(res) { if (res && res.code !== 0) throw new Error((res && (res.stderr || res.stdout)) || _('Failed to write subscription file')); // 2. one file:// subscription for the whole airport if (!foundOldSub) { subSid = uci.add('dae', 'subscription', airportId + '_sub'); subCreated = true; } uci.set('dae', subSid, 'tag', subTag); uci.set('dae', subSid, 'url', subUrl); uci.set('dae', subSid, 'enabled', '1'); // 3. group that sources the subscription if (!foundOldGroup) { 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', [ subTag ]); return applyUciChanges().then(runGenerator).catch(function(error) { if (subCreated) uci.remove('dae', subSid); else if (oldSubSection) restoreSection(subSid, oldSubSection); if (groupCreated) uci.remove('dae', groupSid); else if (oldGroupSection) restoreSection(groupSid, oldGroupSection); return applyUciChanges().then(runGenerator).catch(function() {}) .then(function() { if (subCreated) return fs.exec(GENERATOR, [ 'delete-sub', airportId ]).catch(function() {}); }) .then(function() { throw error; }); }); }).then(function() { // 4. drop this airport's legacy converted_N manual nodes oldNodeIds.forEach(function(sid) { if (uciSection('dae', sid)) uci.remove('dae', sid); }); writeAirportRecord(existingAirport, { id: airportId, backend: 'dae', name: airportName.value.trim(), sourceHash: state.sourceHash, groupId: groupSid, subscriptionId: subSid, nodeIds: [] }); return applyUciChanges().then(runGenerator); }).then(function() { items.forEach(function(item) { item.duplicate = true; item.selected = false; }); return { added: items.length, duplicates: 0, failed: 0 }; }); }; const importDaed = function(items) { let token = ''; let usedCachedToken = false; const existingAirport = currentAirport(); const airportId = existingAirport ? existingAirport.id : airportSync.makeAirportId(); const name = airportName.value.trim(); const groupName = airportSync.backendGroupName(name, 'daed', airportId); const endpoint = daedEndpoint(); // daed can't read file:// — it HTTP-fetches a subscription link. Persist // the converted snapshot and let daed pull it from the loopback-only CGI, // so later daed refreshes and configuration backups keep working. // Keep the CGI token opaque, bounded, and unique across imports started in // the same millisecond. The final sanitization also protects the filename // and the CGI's allow-list if backendId ever changes. const tokenTime = Date.now().toString(36); const tokenRandom = Math.random().toString(36).slice(2, 12); const tokenPrefix = airportSync.backendId(airportId).replace(/[^A-Za-z0-9]/g, '') .slice(0, Math.max(0, 64 - tokenTime.length - tokenRandom.length)); const subToken = (tokenPrefix + tokenTime + tokenRandom).slice(0, 64); const snapshotFile = '/etc/daed/daede-sub-' + subToken; const stageFile = '/etc/daed/.daede-sub-stage-' + subToken; const subUrl = 'http://127.0.0.1/cgi-bin/daede-sub?t=' + subToken; const b64 = btoa(items.map(function(item) { return item.link; }).join('\n') + '\n'); let before; let newSubId = ''; let createdGroupId = ''; const createdNodeIds = []; let groupReady = false; let snapshotCommitted = false; let snapshotFinalToken = ''; let reuseExistingSnapshot = false; const oldSubId = existingAirport ? existingAirport.subscription_id : ''; const oldNodeIds = existingAirport ? existingAirport.node_ids : []; const dropSnapshot = function(path) { if (!path) return Promise.resolve(); return fs.remove(path).catch(function(error) { const message = String(error && (error.message || error) || ''); if (/not found|no such file|enoent/i.test(message)) return; throw error; }); }; const appendWarning = function(error, warning) { const result = error instanceof Error ? error : new Error(String(error && (error.message || error) || error)); result.message = result.message + '; ' + warning; return result; }; const snapshotAction = function(args) { return fs.exec(SNAPSHOT_HELPER, args).then(function(res) { if (!res || res.code !== 0) throw new Error((res && (res.stderr || res.stdout)) || _('Snapshot operation failed')); return res; }); }; const cleanupStage = function() { return snapshotAction([ 'discard', subToken ]); }; const querySubscriptions = function() { if (!token) return Promise.reject(new Error(_('daed subscription cleanup could not be verified'))); return graphQL(endpoint, 'query SubscriptionLinks{subscriptions{id link}}', {}, token).then(function(value) { if (!value || !Array.isArray(value.subscriptions)) throw new Error(_('daed returned an invalid subscription list')); return value.subscriptions; }); }; const dropSnapshotIfUnreferenced = function() { if (snapshotFinalToken !== subToken) return Promise.resolve(); return querySubscriptions().then(function(subscriptions) { if (subscriptions.some(function(sub) { return String(sub.link || '') === subUrl; })) throw new Error(_('new daed subscription snapshot is still referenced; it was kept')); return dropSnapshot(snapshotFile); }); }; const finishWithSnapshotCleanup = function(result) { return dropSnapshotIfUnreferenced().then(function() { return result; }).catch(function(error) { const warning = String(error && (error.message || error) || error); result.warning = result.warning ? result.warning + '; ' + warning : warning; return result; }); }; const updateExistingSubscription = function() { const old = (((before || {}).subscriptions) || []).find(function(sub) { return sub.id === oldSubId; }); if (!old) return Promise.reject(new Error(_('Managed subscription no longer exists in daed'))); const oldLink = String(old.link || ''); const localToken = oldLink.match(/^http:\/\/127\.0\.0\.1\/cgi-bin\/daede-sub\?t=([A-Za-z0-9]{1,64})$/); const refresh = function() { return graphQL(endpoint, 'mutation UpdateSub($id:ID!){updateSubscription(id:$id){id}}', { id: oldSubId }, token); }; const currentLink = function() { return querySubscriptions().then(function(subscriptions) { const current = subscriptions.find(function(sub) { return sub.id === oldSubId; }); return current ? String(current.link || '') : ''; }); }; const restoreOldLink = function(originalError) { return graphQL(endpoint, 'mutation RestoreLink($id:ID!,$link:String!){updateSubscriptionLink(id:$id,link:$link)}', { id: oldSubId, link: oldLink }, token).then(refresh).then(currentLink).then(function(link) { if (link !== oldLink) { snapshotCommitted = true; throw appendWarning(originalError, _('rollback was not confirmed; both subscription snapshots were kept')); } throw originalError; }, function(rollbackError) { snapshotCommitted = true; throw appendWarning(originalError, _('rollback could not be confirmed; both subscription snapshots were kept: %s').format(String(rollbackError && (rollbackError.message || rollbackError) || rollbackError))); }); }; const finish = function() { const tag = old.tag !== groupName ? graphQL(endpoint, 'mutation TagSub($id:ID!,$tag:String!){tagSubscription(id:$id,tag:$tag)}', { id: oldSubId, tag: groupName }, token).catch(function(error) { return String(error && (error.message || error) || error); }) : Promise.resolve(''); return tag.then(function(tagWarning) { writeAirportRecord(existingAirport, { id: airportId, backend: 'daed', name: name, sourceHash: state.sourceHash, groupId: existingAirport.group_id, subscriptionId: oldSubId, nodeIds: [] }); return applyUciChanges().then(function() { items.forEach(function(item) { item.duplicate = true; item.selected = false; }); return { added: items.length, duplicates: 0, failed: 0, warning: tagWarning }; }); }); }; if (localToken && reuseExistingSnapshot) { return graphQL(endpoint, 'mutation UpdateSub($id:ID!){updateSubscription(id:$id){id}}', { id: oldSubId }, token) .then(currentLink).then(function(link) { if (link !== oldLink) throw new Error(_('daed did not confirm the existing subscription link after refresh')); }).catch(function(error) { throw appendWarning(error, _('daed subscription refresh failed; the new snapshot remains at the existing link and existing nodes were kept')); }).then(finish); } return graphQL(endpoint, 'mutation SetLink($id:ID!,$link:String!){updateSubscriptionLink(id:$id,link:$link)}', { id: oldSubId, link: subUrl }, token).then(refresh).then(function() { return currentLink().then(function(link) { if (link !== subUrl) throw new Error(_('daed did not confirm the new subscription link')); }); }).catch(restoreOldLink).then(function() { snapshotCommitted = true; return finish(); }); }; const loadState = function(forceLogin) { return requestDaedToken(endpoint, forceLogin).then(function(auth) { token = auth.token; usedCachedToken = auth.cached; return graphQL(endpoint, 'query State{nodes(first:10000){edges{id link tag}} groups{id name nodes{id}} subscriptions{id link tag}}', {}, token); }); }; const importDirectNodes = function() { const existing = {}; const usedTags = {}; (((before || {}).nodes || {}).edges || []).forEach(function(node) { existing[clashConverter.normalizeLink(node.link)] = node; if (node.tag) usedTags[String(node.tag)] = true; }); const readableNodeTag = function(item, index) { const base = String(item.name || '').replace(/[\x00-\x1f\x7f]+/g, ' ').replace(/\s+/g, ' ').trim() || _('Node %d').format(index + 1); let tag = base.slice(0, 96); let suffix = 2; while (usedTags[tag]) { const tail = ' ' + suffix++; tag = base.slice(0, 96 - tail.length) + tail; } usedTags[tag] = true; return tag; }; const fresh = items.filter(function(item) { const duplicate = existing[clashConverter.normalizeLink(item.link)]; if (duplicate) item.duplicate = true; return !duplicate; }); const imported = fresh.length ? graphQL(endpoint, 'mutation ImportNodes($args:[ImportArgument!]!){importNodes(rollbackError:false,args:$args){link error node{id}}}', { args: fresh.map(function(item, index) { return { link: item.link, tag: readableNodeTag(item, index) }; }) }, token) : Promise.resolve({ importNodes: [] }); return imported.then(function(result) { const rows = result.importNodes || []; const nodeIds = []; const ownedIds = []; const rowByLink = {}; rows.forEach(function(row) { rowByLink[clashConverter.normalizeLink(row.link)] = row; }); 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 = 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); createdNodeIds.push(row.node.id); } else if (existingAirport && oldNodeIds.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 oldManagedGroup = airportSync.findManagedGroup(before.groups, existingAirport); const ensureGroup = oldManagedGroup ? Promise.resolve(oldManagedGroup.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 = oldManagedGroup && oldManagedGroup.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 = oldManagedGroup ? oldManagedGroup.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(oldNodeIds, 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, subscriptionId: '', 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 }; }); }); }); }); }); }); }; return fs.write(stageFile, b64, 384).then(function() { return loadState(false).catch(function(error) { if (!usedCachedToken || !daedSession.isAccessDenied(error)) throw error; daedSession.clear(window.localStorage); return loadState(true); }); }).then(function(dataValue) { before = dataValue; const old = (before.subscriptions || []).find(function(sub) { return sub.id === oldSubId; }); const localToken = old && String(old.link || '').match(/^http:\/\/127\.0\.0\.1\/cgi-bin\/daede-sub\?t=([A-Za-z0-9]{1,64})$/); const oldLinkReferences = localToken ? (before.subscriptions || []).filter(function(sub) { return String(sub.link || '') === String(old.link || ''); }) : []; reuseExistingSnapshot = !!(localToken && oldLinkReferences.length === 1 && String(oldLinkReferences[0].id) === String(oldSubId)); const publish = reuseExistingSnapshot ? snapshotAction([ 'replace', localToken[1], subToken ]).then(function() { snapshotFinalToken = localToken[1]; snapshotCommitted = true; }) : snapshotAction([ 'publish', subToken ]).then(function() { snapshotFinalToken = subToken; }); return publish.then(function() { if (oldSubId) return updateExistingSubscription().then(function(result) { return { existingResult: result }; }); // import the whole converted batch as one subscription return graphQL(endpoint, 'mutation Import($a:ImportArgument!){importSubscription(rollbackError:false,arg:$a){sub{id} nodeImportResult{error}}}', { a: { link: subUrl, tag: groupName } }, token).then(function(result) { const sub = result.importSubscription && result.importSubscription.sub; const rows = (result.importSubscription && result.importSubscription.nodeImportResult) || []; const usable = rows.length ? rows.some(function(r) { return !r.error || r.error === 'node already exists'; }) : !!(sub && sub.id); if (!sub || !sub.id || !usable) { const details = rows.map(function(r) { return r.error; }).filter(Boolean).join('; '); if (sub && sub.id) newSubId = sub.id; throw new Error(details || _('No usable nodes were imported')); } return result; }).catch(function(error) { if (daedSession.isAccessDenied(error)) throw error; const cleanupSub = newSubId ? graphQL(endpoint, 'mutation Rm($ids:[ID!]!){removeSubscriptions(ids:$ids)}', { ids: [ newSubId ] }, token).catch(function(cleanupError) { snapshotCommitted = true; throw appendWarning(error, _('rollback could not be confirmed; the new subscription snapshot was kept: %s').format(String(cleanupError && (cleanupError.message || cleanupError) || cleanupError))); }) : Promise.resolve(); newSubId = ''; return cleanupSub.then(function() { if (oldSubId) throw error; return importDirectNodes(); }).then(function(result) { return { directResult: result }; }); }); }); }).then(function(result) { if (result.existingResult) return result.existingResult; if (result.directResult) return finishWithSnapshotCleanup(result.directResult); const sub = result.importSubscription && result.importSubscription.sub; newSubId = sub.id; const rows = result.importSubscription.nodeImportResult || []; const failed = rows.filter(function(r) { return !!r.error && r.error !== 'node already exists'; }).length; const oldManagedGroup = airportSync.findManagedGroup(before.groups, existingAirport); const proxyGroup = airportSync.findGroupByName(before.groups, 'proxy'); const ensureGroup = proxyGroup ? Promise.resolve(proxyGroup.id) : graphQL(endpoint, 'mutation CreateGroup($name:String!,$policy:Policy!){createGroup(name:$name,policy:$policy){id}}', { name: 'proxy', policy: 'min_moving_avg' }, token).then(function(value) { createdGroupId = value.createGroup.id; return createdGroupId; }); return ensureGroup.then(function(groupId) { return graphQL(endpoint, 'mutation AddSubs($id:ID!,$ids:[ID!]!){groupAddSubscriptions(id:$id,subscriptionIDs:$ids)}', { id: groupId, ids: [ newSubId ] }, token).then(function() { groupReady = true; snapshotCommitted = true; const cleanup = []; // Migrate converter-managed airport groups to proxy, but never // disturb proxy's existing nodes or other subscriptions. if (oldManagedGroup && oldManagedGroup.id !== groupId) cleanup.push(graphQL(endpoint, 'mutation RmG($id:ID!){removeGroup(id:$id)}', { id: oldManagedGroup.id }, token).catch(function() {})); if (oldNodeIds.length) cleanup.push(graphQL(endpoint, 'mutation Rm($ids:[ID!]!){removeNodes(ids:$ids)}', { ids: oldNodeIds }, token).catch(function() {})); return Promise.all(cleanup).then(function() { writeAirportRecord(existingAirport, { id: airportId, backend: 'daed', name: name, sourceHash: state.sourceHash, groupId: groupId, subscriptionId: newSubId, nodeIds: [] }); return applyUciChanges().then(function() { items.forEach(function(item) { item.duplicate = true; item.selected = false; }); return { added: items.length, duplicates: 0, failed: failed, warning: '' }; }); }); }); }); }).catch(function(error) { if (daedSession.isAccessDenied(error)) daedSession.clear(window.localStorage); if (groupReady || !token || (!newSubId && !createdGroupId && !createdNodeIds.length)) throw error; const cleanup = []; if (newSubId) cleanup.push(graphQL(endpoint, 'mutation Rm($ids:[ID!]!){removeSubscriptions(ids:$ids)}', { ids: [ newSubId ] }, token).catch(function(cleanupError) { snapshotCommitted = true; throw cleanupError; })); if (createdNodeIds.length) cleanup.push(graphQL(endpoint, 'mutation Rm($ids:[ID!]!){removeNodes(ids:$ids)}', { ids: createdNodeIds }, token)); if (createdGroupId) cleanup.push(graphQL(endpoint, 'mutation RmG($id:ID!){removeGroup(id:$id)}', { id: createdGroupId }, token)); return Promise.all(cleanup).then(function() { throw error; }, function(cleanupError) { snapshotCommitted = true; throw appendWarning(error, _('rollback could not be confirmed; the new subscription snapshot was kept: %s').format(String(cleanupError && (cleanupError.message || cleanupError) || cleanupError))); }); }).then(function(result) { return cleanupStage().then(function() { token = ''; return result; }, function(cleanupError) { token = ''; result.warning = result.warning ? result.warning + '; ' + String(cleanupError && (cleanupError.message || cleanupError) || cleanupError) : String(cleanupError && (cleanupError.message || cleanupError) || cleanupError); return result; }); }, function(error) { let cleanupWarning = ''; const removeSnapshot = snapshotCommitted ? Promise.resolve() : dropSnapshotIfUnreferenced().catch(function(cleanupError) { cleanupWarning = _('rollback could not be confirmed; the new subscription snapshot was kept: %s').format(String(cleanupError && (cleanupError.message || cleanupError) || cleanupError)); }); return removeSnapshot.then(function() { return cleanupStage().catch(function(stageError) { const warning = _('snapshot stage cleanup failed: %s').format(String(stageError && (stageError.message || stageError) || stageError)); cleanupWarning = cleanupWarning ? cleanupWarning + '; ' + warning : warning; }); }).then(function() { token = ''; throw cleanupWarning ? appendWarning(error, cleanupWarning) : error; }); }); }; importButton.addEventListener('click', function() { 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; setImportStatus(_('Importing node group…')); const action = state.target === 'dae' ? importDae(items) : importDaed(items); action.then(function(result) { let message = _('Node group imported: added %d, reused %d, failed %d') .format(result.added, result.duplicates, result.failed); if (result.warning) message += ' ' + _('Completed with warning: %s').format(result.warning); setImportStatus(message, result.failed || result.warning ? 'err' : 'ok'); renderResults(); }).catch(function(e) { setImportStatus(_('Node group import failed: %s').format(e.message || e), 'err'); }).finally(function() { importButton.disabled = selectedResults().length === 0; }); }); renderResults(); return E('div', { 'class': 'dd-wrap dd-converter' }, [ E('style', {}, styles.CSS), E('div', { 'class': 'dd-card dd-conv-card' }, [ E('p', { 'class': 'dd-settings-descr' }, _('Convert Clash YAML to share links and import the nodes you pick. Nothing is saved.')), E('h4', { 'class': 'dd-card-title' }, _('1. Input Clash YAML')), E('div', { 'class': 'dd-conv-url-row' }, [ urlInput, uaSelect, parseUrl ]), E('div', { 'class': 'dd-conv-or' }, _('or')), yamlInput, E('div', { 'class': 'dd-actions' }, [ parsePaste ]), sourceStatus ]), E('div', { 'class': 'dd-card dd-conv-card' }, [ E('div', { 'class': 'dd-conv-heading' }, [ E('h4', { 'class': 'dd-card-title' }, _('2. Conversion Preview')), summary ]), E('div', { 'class': 'dd-conv-toolbar' }, [ selectNew, clearSelection, filterInput ]), resultBody ]), E('div', { 'class': 'dd-card dd-conv-card' }, [ importTitle, E('div', { 'class': 'dd-conv-import dd-conv-airport' }, [ airportNameLabel, airportName, E('label', {}, _('Target backend')), targetSelect ]), groupSummary, E('div', { 'class': 'dd-conv-import dd-conv-submit' }, [ importButton ]), importStatus ]) ]); }, handleSave: null, handleSaveApply: null, handleReset: null });