// Copyright MOSSDeF, 2023 Stan Grishin // This code wouldn't have been possible without help from: // - [@vsviridov](https://github.com/vsviridov) "require ui"; "require rpc"; "require form"; "require baseclass"; var pkg = { get Name() { return "adblock-fast"; }, get LuciCompat() { return 14; }, get ReadmeCompat() { return ""; }, get URL() { return ( "https://docs.openwrt.melmac.ca/" + pkg.Name + "/" + (pkg.ReadmeCompat ? pkg.ReadmeCompat + "/" : "") ); }, get DonateURL() { return ( "https://docs.openwrt.melmac.ca/" + pkg.Name + "/" + (pkg.ReadmeCompat ? pkg.ReadmeCompat + "/" : "") + "#donate" ); }, isVersionMismatch: function (luci, pkg, rpcd) { return luci !== pkg || pkg !== rpcd || luci !== rpcd; }, formatMessage: function (info, template) { if (!template) return _("Unknown message") + "
"; return ( (Array.isArray(info) ? template.format(...info) : template.format(info || " ")) + "
" ); }, humanFileSize: function (bytes, si = false, dp = 2) { return `%${si ? 1000 : 1024}.${dp ?? 0}mB`.format(bytes); }, isObjEmpty: function (obj) { return Object.keys(obj).length === 0; }, formatPauseTimeout: function (seconds) { var s = parseInt(seconds) || 20; if (s < 60) return s + "s"; var m = Math.floor(s / 60); var rem = s % 60; if (rem === 0) return m + "m"; return m + "m " + rem + "s"; }, statusTable: { statusNoInstall: _("%s is not installed or not found").format( "adblock-fast", ), statusStopped: _("Stopped"), statusStarting: _("Starting"), statusProcessing: _("Processing lists"), statusRestarting: _("Restarting"), statusForceReloading: _("Force Reloading"), statusDownloading: _("Downloading lists"), statusFail: _("Failed to start"), statusSuccess: _("Active"), statusTriggerBootWait: _("Waiting for trigger (on_boot)"), statusTriggerStartWait: _("Waiting for trigger (on_start)"), }, warningTable: { warningInternalVersionMismatch: _( "Internal version mismatch (package: %s, luci app: %s, luci rpcd: %s), you may need to update packages or reboot the device, please check the %sREADME%s.", ), warningExternalDnsmasqConfig: _( "Use of external dnsmasq config file detected, please set '%s' option to '%s'", ).format("dns", "dnsmasq.conf"), warningMissingRecommendedPackages: _("Missing recommended package: '%s'"), warningOutdatedLuciPackage: _( "The WebUI application (luci-app-adblock-fast) is outdated, please update it", ), warningOutdatedPrincipalPackage: _( "The principal package (adblock-fast) is outdated, please update it", ), warningInvalidCompressedCacheDir: _( "Invalid compressed cache directory '%s'", ), warningFreeRamCheckFail: _("Can't detect free RAM"), warningSanityCheckTLD: _("Sanity check discovered TLDs in %s"), warningSanityCheckLeadingDot: _( "Sanity check discovered leading dots in %s", ), warningInvalidDomainsRemoved: _( "Removed %s invalid domain entries from block-list (domains starting with -/./numbers or containing invalid patterns)", ), warningCronDisabled: _( "Cron service is not enabled or running. Enable it with: %s.", ), warningCronMissing: _( "Cron daemon is not available. If BusyBox crond is present, enable it with: %s; otherwise install another cron daemon.", ), }, errorTable: { errorConfigValidationFail: _("Config (%s) validation failure!").format( "/etc/config/" + "adblock-fast", ), errorServiceDisabled: _("%s is currently disabled").format("adblock-fast"), errorNoDnsmasqIpset: _( "The dnsmasq ipset support is enabled, but dnsmasq is either not installed or installed dnsmasq does not support ipset", ), errorNoIpset: _( "The dnsmasq ipset support is enabled, but ipset is either not installed or installed ipset does not support '%s' type", ).format("hash:net"), errorNoDnsmasqNftset: _( "The dnsmasq nft set support is enabled, but dnsmasq is either not installed or installed dnsmasq does not support nft set", ), errorNoNft: _( "The dnsmasq nft sets support is enabled, but nft is not installed", ), errorNoWanGateway: _("The %s failed to discover WAN gateway").format( "adblock-fast", ), errorOutputDirCreate: _("Failed to create directory for %s file"), errorOutputFileCreate: _("Failed to create '%s' file"), errorFailDNSReload: _("Failed to restart/reload DNS resolver"), errorSharedMemory: _("Failed to access shared memory"), errorSorting: _("Failed to sort data file"), errorOptimization: _("Failed to optimize data file"), errorAllowListProcessing: _("Failed to process allow-list"), errorDataFileFormatting: _("Failed to format data file"), // NOTE: keep placeholders; fill with info at render time errorMovingDataFile: _("Failed to move temporary data file to '%s'"), errorCreatingCompressedCache: _("Failed to create compressed cache"), errorRemovingTempFiles: _("Failed to remove temporary files"), errorRestoreCompressedCache: _("Failed to unpack compressed cache"), // NOTE: keep placeholders; fill with info at render time errorRestoreCache: _("Failed to move '%s' to '%s'"), errorOhSnap: _("Failed to create block-list or restart DNS resolver"), errorStopping: _("Failed to stop %s").format("adblock-fast"), errorDNSReload: _("Failed to reload/restart DNS resolver"), errorDownloadingConfigUpdate: _("Failed to download Config Update file"), errorDownloadingList: _("Failed to download %s"), errorParsingConfigUpdate: _("Failed to parse Config Update file"), errorParsingList: _("Failed to parse %s"), errorNoSSLSupport: _("No HTTPS/SSL support on device"), errorCreatingDirectory: _( "Failed to create output/cache/gzip file directory", ), errorDetectingFileType: _("Failed to detect format %s"), errorNothingToDo: _("No blocked list URLs nor blocked-domains enabled"), errorTooLittleRam: _( "Free ram (%s) is not enough to process all enabled block-lists", ), errorCreatingBackupFile: _("failed to create backup file %s"), errorDeletingDataFile: _("failed to delete data file %s"), errorRestoringBackupFile: _("failed to restore backup file %s"), errorNoOutputFile: _("failed to create final block-list %s"), errorNoHeartbeat: _( "Heartbeat domain is not accessible after resolver restart", ), }, }; var getFileUrlFilesizes = rpc.declare({ object: "luci." + pkg.Name, method: "getFileUrlFilesizes", params: ["name", "url"], }); var syncCron = rpc.declare({ object: "luci." + pkg.Name, method: "syncCron", params: ["name", "action"], expect: { result: false }, }); var getInitList = rpc.declare({ object: "luci." + pkg.Name, method: "getInitList", params: ["name"], }); var getInitStatus = rpc.declare({ object: "luci." + pkg.Name, method: "getInitStatus", params: ["name"], }); var getCronStatus = rpc.declare({ object: "luci." + pkg.Name, method: "getCronStatus", params: ["name"], }); var setCronEntry = rpc.declare({ object: "luci." + pkg.Name, method: "setCronEntry", params: ["name", "entry"], expect: { result: false }, }); var getPlatformSupport = rpc.declare({ object: "luci." + pkg.Name, method: "getPlatformSupport", params: ["name"], }); var getServiceInfo = rpc.declare({ object: "service", method: "list", params: ["name", "verbose"], }); var _setInitAction = rpc.declare({ object: "luci." + pkg.Name, method: "setInitAction", params: ["name", "action"], expect: { result: false }, }); var _setRpcdToken = rpc.declare({ object: "luci." + pkg.Name, method: "setRpcdToken", params: ["name", "token"], expect: { result: false }, }); var getQueryLogStatus = rpc.declare({ object: "luci." + pkg.Name, method: "getQueryLogStatus", params: ["name"], }); var setQueryLog = rpc.declare({ object: "luci." + pkg.Name, method: "setQueryLog", params: ["name", "action"], expect: { result: false }, }); var callLogRead = rpc.declare({ object: "log", method: "read", params: ["lines", "stream", "oneshot"], expect: { log: [] }, }); var RPC = { listeners: [], on: function (event, callback) { var pair = { event: event, callback: callback }; this.listeners.push(pair); return function unsubscribe() { this.listeners = this.listeners.filter(function (listener) { return listener !== pair; }); }.bind(this); }, emit: function (event, data) { this.listeners.forEach(function (listener) { if (listener.event === event) { listener.callback(data); } }); }, setInitAction: function (name, action) { _setInitAction(name, action) .then( function (result) { this.emit("setInitAction", result); }.bind(this), ) .catch( function (error) { // Even if RPC call fails/times out, emit event to start polling // This handles cases where the backend task starts but RPC times out this.emit("setInitAction", { timeout: true }); }.bind(this), ); }, setRpcdToken: function (name, token) { return _setRpcdToken(name, token); }, }; // Poll service status until completion (for long-running operations like download) var pollServiceStatus = function (callback) { var maxAttempts = 120; // Max 2 minutes of polling var attempt = 0; var checkStatus = function () { attempt++; // Use the RPC function directly from the module scope L.resolveDefault(getInitStatus(pkg.Name), {}) .then(function (statusData) { var currentStatus = statusData && statusData[pkg.Name] && statusData[pkg.Name].status; // Check if completed or failed if ( currentStatus === "statusSuccess" || currentStatus === "statusFail" || currentStatus === "statusStopped" ) { callback(true, currentStatus); } // Check if timed out else if (attempt >= maxAttempts) { callback(false, "timeout"); } // Continue polling else { setTimeout(checkStatus, 1000); // Check again in 1 second } }) .catch(function (err) { // Retry on error unless timed out if (attempt < maxAttempts) { setTimeout(checkStatus, 1000); } else { callback(false, "error"); } }); }; // Start polling after 2 seconds delay (give backend time to start the task) setTimeout(checkStatus, 2000); }; var status = baseclass.extend({ render: function () { return Promise.all([ L.resolveDefault(getInitStatus(pkg.Name), {}), L.resolveDefault(getCronStatus(pkg.Name), {}), ]).then(function ([initStatus, cronStatus]) { var initData = initStatus?.[pkg.Name] || {}; var reply = { status: initData.enabled !== undefined ? initData : { enabled: false, status: null, packageCompat: 0, rpcdCompat: 0, running: null, version: null, errors: [], warnings: [], force_dns_active: null, force_dns_ports: [], entries: null, dns: null, outputFile: null, outputCache: null, outputGzip: null, outputFileExists: null, outputCacheExists: null, outputGzipExists: null, leds: [], }, ubus: { packageCompat: initData.packageCompat || 0, errors: initData.errors ? [...initData.errors] : [], warnings: initData.warnings ? [...initData.warnings] : [], }, cron: cronStatus?.[pkg.Name] || { auto_update_enabled: false, cron_init: false, cron_bin: false, cron_enabled: false, cron_running: false, cron_line_present: false, cron_line_match: false, cron_line_state: "none", }, }; if ( pkg.isVersionMismatch( pkg.LuciCompat, reply.status.packageCompat, reply.status.rpcdCompat, ) ) { reply.ubus.warnings.push({ code: "warningInternalVersionMismatch", info: [ reply.ubus.packageCompat, pkg.LuciCompat, reply.status.rpcdCompat, '', "", ], }); } if ( reply.status.enabled && reply.status.running && (reply.cron.auto_update_enabled || reply.cron.cron_line_state === "suspended") ) { var enableCronCmd = "/etc/init.d/cron enable && /etc/init.d/cron start"; if (!reply.cron.cron_init || !reply.cron.cron_bin) { reply.ubus.warnings.push({ code: "warningCronMissing", info: enableCronCmd, }); } else if (!reply.cron.cron_enabled || !reply.cron.cron_running) { reply.ubus.warnings.push({ code: "warningCronDisabled", info: enableCronCmd, }); } } var text = ""; var outputFile = reply.status.outputFile; var outputCache = reply.status.outputCache; var header = E("h2", {}, _("AdBlock-Fast - Status")); var statusTitle = E( "label", { class: "cbi-value-title", for: pkg.Name + "-status" }, _("Service Status"), ); if (reply.status.version) { text += _("Version %s").format(reply.status.version) + " - "; switch (reply.status.status) { case "statusSuccess": text += pkg.statusTable[reply.status.status] + "."; break; case "statusStopped": if (reply.status.enabled) { text += pkg.statusTable[reply.status.status] + "."; } else { text += pkg.statusTable[reply.status.status] + " (" + _("Disabled") + ")."; } break; case "statusRestarting": case "statusForceReloading": case "statusDownloading": case "statusProcessing": text += pkg.statusTable[reply.status.status] + "..."; break; default: text += pkg.statusTable[reply.status.status] + "."; break; } } else { text = _("Not installed or not found"); } var statusText = E("output", { id: pkg.Name + "-status" }, text); var statusField = E("div", { class: "cbi-value-field" }, statusText); var statusDiv = E("div", { class: "cbi-value" }, [ statusTitle, statusField, ]); var detailsDiv = []; if (reply.status.version) { var detailsText = ""; if (reply.status.status === "statusSuccess") { detailsText += _("Blocking %s domains (with %s).").format( reply.status.entries, reply.status.dns, ); if (reply.status.outputGzipExists) { detailsText += "
" + _("Compressed cache file created."); } if (reply.status.force_dns_active) { detailsText += "
" + _("Force DNS ports:"); reply.status.force_dns_ports.forEach((element) => { detailsText += " " + element; }); detailsText += "."; } } if (reply.status.status === "statusStopped") { if (reply.status.outputCacheExists) { detailsText += _("Cache file found."); } else if (reply.status.outputGzipExists) { detailsText += _("Compressed cache file found."); } } if (detailsText) { var detailsTitle = E( "label", { class: "cbi-value-title", for: pkg.Name + "-details" }, _("Service Details"), ); var detailsDescr = E( "div", { class: "cbi-value-description" }, _( "Please %sdonate%s to support development of this project.", ).format( "", "", ), ); var detailsContent = E("output", { id: pkg.Name + "-details" }, detailsText); var detailsField = E("div", { class: "cbi-value-field" }, [ detailsContent, E("br"), E("br"), detailsDescr, ]); detailsDiv = E("div", { class: "cbi-value" }, [ detailsTitle, detailsField, ]); } } var warningsDiv = []; if (reply.ubus.warnings && reply.ubus.warnings.length) { var warningsTitle = E( "label", { class: "cbi-value-title", for: pkg.Name + "-warnings" }, _("Service Warnings"), ); text = ""; reply.ubus.warnings.forEach((element) => { if (element.code && pkg.warningTable[element.code]) { text += pkg.formatMessage( element.info, pkg.warningTable[element.code], ); } else { text += _("Unknown warning") + "
"; } }); var warningsText = E("output", { id: pkg.Name + "-warnings", class: "cbi-value-description" }, text); var warningsField = E( "div", { class: "cbi-value-field" }, warningsText, ); warningsDiv = E("div", { class: "cbi-value" }, [ warningsTitle, warningsField, ]); } var errorsDiv = []; if (reply.ubus.errors && reply.ubus.errors.length) { var errorsTitle = E( "label", { class: "cbi-value-title", for: pkg.Name + "-errors" }, _("Service Errors"), ); text = ""; reply.ubus.errors.forEach((element) => { if (element.code && pkg.errorTable[element.code]) { text += pkg.formatMessage( element.info, pkg.errorTable[element.code], ); } else { text += _("Unknown error") + "
"; } }); text += _("Errors encountered, please check the %sREADME%s").format( '', "!
", ); var errorsText = E("output", { id: pkg.Name + "-errors", class: "cbi-value-description" }, text); var errorsField = E("div", { class: "cbi-value-field" }, errorsText); errorsDiv = E("div", { class: "cbi-value" }, [ errorsTitle, errorsField, ]); } var btn_gap = E("span", {}, "  "); var btn_gap_long = E( "span", {}, "        ", ); var btn_start = E( "button", { class: "btn cbi-button cbi-button-apply", disabled: true, click: function (ev) { ui.showModal(null, [ E( "p", { class: "spinning" }, _("Starting %s service").format(pkg.Name), ), ]); return RPC.setInitAction(pkg.Name, "start"); }, }, _("Start"), ); var btn_action_dl = E( "button", { class: "btn cbi-button cbi-button-apply", disabled: true, click: function (ev) { ui.showModal(null, [ E( "p", { class: "spinning" }, _("Force redownloading %s block lists").format(pkg.Name), ), ]); return RPC.setInitAction(pkg.Name, "dl"); }, }, _("Redownload"), ); var pauseTimeout = parseInt(reply.status.pause_timeout) || 20; var pauseLabel = _("Pause") + " (" + pkg.formatPauseTimeout(pauseTimeout) + ")"; var btn_action_pause = E( "button", { class: "btn cbi-button cbi-button-apply", disabled: true, click: function (ev) { var remaining = pauseTimeout; var allButtons = [ btn_start, btn_action_dl, btn_action_pause, btn_stop, btn_enable, btn_disable, ]; if (typeof btn_sync_cron !== "undefined") allButtons.push(btn_sync_cron); allButtons.forEach(function (b) { b.disabled = true; }); btn_action_pause.textContent = _("Pause") + " (" + pkg.formatPauseTimeout(remaining) + ")"; RPC.setInitAction(pkg.Name, "pause"); var countdown = setInterval(function () { remaining--; if (remaining > 0) { btn_action_pause.textContent = _("Pause") + " (" + pkg.formatPauseTimeout(remaining) + ")"; } else { clearInterval(countdown); btn_action_pause.textContent = _("Pause") + " (" + _("Restarting") + "…)"; pollServiceStatus(function () { location.reload(); }); } }, 1000); }, }, pauseLabel, ); var btn_stop = E( "button", { class: "btn cbi-button cbi-button-reset", disabled: true, click: function (ev) { ui.showModal(null, [ E( "p", { class: "spinning" }, _("Stopping %s service").format(pkg.Name), ), ]); return RPC.setInitAction(pkg.Name, "stop"); }, }, _("Stop"), ); var btn_enable = E( "button", { class: "btn cbi-button cbi-button-apply", disabled: true, click: function (ev) { ui.showModal(null, [ E( "p", { class: "spinning" }, _("Enabling %s service").format(pkg.Name), ), ]); return RPC.setInitAction(pkg.Name, "enable"); }, }, _("Enable"), ); var btn_disable = E( "button", { class: "btn cbi-button cbi-button-reset", disabled: true, click: function (ev) { ui.showModal(null, [ E( "p", { class: "spinning" }, _("Disabling %s service").format(pkg.Name), ), ]); return RPC.setInitAction(pkg.Name, "disable"); }, }, _("Disable"), ); if (reply.status.enabled) { btn_enable.disabled = true; btn_disable.disabled = false; switch (reply.status.status) { case "statusSuccess": btn_start.disabled = true; btn_action_dl.disabled = false; btn_action_pause.disabled = false; btn_stop.disabled = false; break; case "statusStopped": btn_start.disabled = false; btn_action_dl.disabled = false; btn_action_pause.disabled = true; btn_stop.disabled = true; break; default: btn_start.disabled = false; btn_action_dl.disabled = true; btn_action_pause.disabled = true; btn_stop.disabled = false; btn_enable.disabled = true; btn_disable.disabled = true; break; } } else { btn_start.disabled = true; btn_action_dl.disabled = true; btn_action_pause.disabled = true; btn_stop.disabled = true; btn_enable.disabled = false; btn_disable.disabled = true; } var buttonsDiv = []; var buttonsTitle = E( "label", { class: "cbi-value-title", for: pkg.Name + "-buttons" }, _("Service Control"), ); var buttonsTextItems = [ btn_start, btn_gap, btn_action_dl, btn_gap, btn_action_pause, btn_gap, btn_stop, btn_gap_long, btn_enable, btn_gap, btn_disable, ]; var buttonsText = E("output", { id: pkg.Name + "-buttons" }, buttonsTextItems); var buttonsField = E("div", { class: "cbi-value-field" }, buttonsText); if (reply.status.version) { buttonsDiv = E("div", { class: "cbi-value" }, [ buttonsTitle, buttonsField, ]); } return E("div", {}, [ header, statusDiv, detailsDiv, warningsDiv, errorsDiv, buttonsDiv, ]); }); }, }); RPC.on("setInitAction", function (reply) { // Don't immediately hide modal and reload // Instead, poll status until the operation actually completes pollServiceStatus(function () { ui.hideModal(); location.reload(); }); }); return L.Class.extend({ status: status, pkg: pkg, getInitStatus: getInitStatus, getFileUrlFilesizes: getFileUrlFilesizes, syncCron: syncCron, getCronStatus: getCronStatus, setCronEntry: setCronEntry, getPlatformSupport: getPlatformSupport, getServiceInfo: getServiceInfo, getQueryLogStatus: getQueryLogStatus, setQueryLog: setQueryLog, setRpcdToken: RPC.setRpcdToken, callLogRead: callLogRead, });