Files
2026-08-23 21:18:49 +08:00

885 lines
28 KiB
Lua

-- SPDX-License-Identifier: GPL-3.0-only
-- Copyright (C) 2026 smallprogram
-- Repository: https://github.com/smallprogram/luci-app-ghfu
module("luci.controller.ghfu", package.seeall)
local http = require "luci.http"
local jsonc = require "luci.jsonc"
local i18n = require "luci.i18n"
local uci = require("luci.model.uci").cursor()
local fs = require "nixio.fs"
local sys = require "luci.sys"
local util = require "luci.util"
local CFG = "ghfu"
local SEC = "main"
local tr = i18n.translate
local function json_resp(tbl)
http.prepare_content("application/json")
http.write_json(tbl)
end
local function to_bool(v)
return v == "1" or v == "true" or v == "on" or v == "yes"
end
local function trim(s)
if not s then
return ""
end
return (s:gsub("^%s+", ""):gsub("%s+$", ""))
end
local function read_lines(path)
local f = io.open(path, "r")
if not f then
return {}
end
local lines = {}
for line in f:lines() do
lines[#lines + 1] = line
end
f:close()
return lines
end
local function human_bytes(n)
n = tonumber(n) or 0
if n < 0 then
n = 0
end
local units = { "B", "KB", "MB", "GB", "TB" }
local idx = 1
while n >= 1024 and idx < #units do
n = n / 1024
idx = idx + 1
end
if idx == 1 then
return string.format("%d %s", math.floor(n + 0.5), units[idx])
end
return string.format("%.2f %s", n, units[idx])
end
local DEFAULT_VALID_EXTS = ".img .img.gz .bin .tar .itb .trx .chk .dlf .ari"
local function migrate_legacy_filter_cfg()
if not uci:get(CFG, SEC) then
return
end
local changed = false
if uci:get(CFG, SEC, "filter_ext_enabled") == nil then
uci:set(CFG, SEC, "filter_ext_enabled", "1")
changed = true
end
local valid_extensions = uci:get(CFG, SEC, "valid_extensions")
if not valid_extensions or trim(valid_extensions) == "" then
uci:set(CFG, SEC, "valid_extensions", DEFAULT_VALID_EXTS)
changed = true
end
if uci:get(CFG, SEC, "filter_prefix_enabled") ~= nil then
uci:delete(CFG, SEC, "filter_prefix_enabled")
changed = true
end
if uci:get(CFG, SEC, "filter_prefix") ~= nil then
uci:delete(CFG, SEC, "filter_prefix")
changed = true
end
if uci:get(CFG, SEC, "filter_min_size_enabled") ~= nil then
uci:delete(CFG, SEC, "filter_min_size_enabled")
changed = true
end
if uci:get(CFG, SEC, "filter_min_size") ~= nil then
uci:delete(CFG, SEC, "filter_min_size")
changed = true
end
if changed then
uci:commit(CFG)
end
end
local function get_cfg()
migrate_legacy_filter_cfg()
return {
github_repo = uci:get(CFG, SEC, "github_repo") or "smallprogram/OpenWrtAction",
selected_release = uci:get(CFG, SEC, "selected_release") or "",
keep_config = uci:get(CFG, SEC, "keep_config") or "1",
fetch_timeout = uci:get(CFG, SEC, "fetch_timeout") or "15",
filter_ext_enabled = uci:get(CFG, SEC, "filter_ext_enabled") or "1",
valid_extensions = uci:get(CFG, SEC, "valid_extensions") or DEFAULT_VALID_EXTS
}
end
local function set_cfg(repo, selected_release, keep_config, fetch_timeout, filter_ext_enabled, valid_extensions)
if not uci:get(CFG, SEC) then
uci:section(CFG, "ghfu", SEC, {})
end
if repo and repo ~= "" then
uci:set(CFG, SEC, "github_repo", repo)
end
if selected_release ~= nil then
uci:set(CFG, SEC, "selected_release", selected_release)
end
if keep_config ~= nil then
uci:set(CFG, SEC, "keep_config", keep_config)
end
if fetch_timeout ~= nil then
local t = tonumber(fetch_timeout)
if t and t >= 5 and t <= 300 then
uci:set(CFG, SEC, "fetch_timeout", tostring(math.floor(t)))
end
end
if filter_ext_enabled ~= nil then
uci:set(CFG, SEC, "filter_ext_enabled", to_bool(filter_ext_enabled) and "1" or "0")
end
if valid_extensions ~= nil then
local v = trim(valid_extensions)
v = v:gsub("[\r\n]+", " "):gsub("%s+", " ")
uci:set(CFG, SEC, "valid_extensions", v)
end
uci:commit(CFG)
end
local function get_install_epoch()
-- Prefer dedicated flash timestamp written by uci-defaults on every post-flash boot
local f = io.open("/etc/ghfu_flash_epoch", "r")
if f then
local v = f:read("*l")
f:close()
local epoch = tonumber(v)
if epoch and epoch > 0 then
return epoch
end
end
-- Fallback: mtime of /etc/openwrt_version
local st = fs.stat("/etc/openwrt_version")
if st and st.mtime then
return tonumber(st.mtime) or 0
end
return 0
end
local function epoch_to_iso_utc(epoch)
if not epoch or epoch <= 0 then
return ""
end
return os.date("!%Y-%m-%dT%H:%M:%SZ", epoch)
end
local function epoch_to_local_string(epoch)
if not epoch or epoch <= 0 then
return ""
end
return os.date("%Y-%m-%d %H:%M:%S", epoch)
end
local function get_tz_offset_minutes()
local z = os.date("%z") or "+0000"
local sign, hh, mm = z:match("([%+%-])(%d%d)(%d%d)")
if not sign then
return 0
end
local v = (tonumber(hh) or 0) * 60 + (tonumber(mm) or 0)
if sign == "-" then
v = -v
end
return v
end
local function normalize_repo(repo)
repo = trim(repo)
repo = repo:gsub("^https://github.com/", "")
repo = repo:gsub("^http://github.com/", "")
repo = repo:gsub("^github.com/", "")
repo = repo:gsub("/*$", "")
return repo
end
local function normalize_version(v)
v = trim(v or "")
if v == "" then
return ""
end
v = v:gsub("^luci%-app%-ghfu[-%s]+", "")
local token = v:match("([0-9][%w%.%+%-%_~]*)")
return token or ""
end
local function read_first_line(path)
local f = io.open(path, "r")
if not f then
return ""
end
local line = f:read("*l") or ""
f:close()
return trim(line)
end
local function read_apk_db_version(path)
local f = io.open(path, "r")
if not f then
return ""
end
local found = false
for line in f:lines() do
if line == "P:luci-app-ghfu" then
found = true
elseif found and line:match("^V:") then
f:close()
return normalize_version(line:sub(3))
elseif found and line == "" then
found = false
end
end
f:close()
return ""
end
local function get_app_version()
local apk_db_ver = read_apk_db_version("/lib/apk/db/installed")
if apk_db_ver ~= "" then
return apk_db_ver
end
apk_db_ver = read_apk_db_version("/usr/lib/apk/db/installed")
if apk_db_ver ~= "" then
return apk_db_ver
end
local opkg_ctrl_ver = normalize_version(sys.exec("sh -c \"awk -F': ' '/^Version:/{print $2; exit}' /usr/lib/opkg/info/luci-app-ghfu.control 2>/dev/null\""))
if opkg_ctrl_ver ~= "" then
return opkg_ctrl_ver
end
local opkg_status_ver = normalize_version(sys.exec("sh -c \"awk 'BEGIN{f=0} /^Package: luci-app-ghfu$/{f=1;next} f&&/^Version:/{sub(/^Version: /,\"\"); print; exit} f&&/^$/{f=0}' /usr/lib/opkg/status 2>/dev/null\""))
if opkg_status_ver ~= "" then
return opkg_status_ver
end
local apk_line_ver = normalize_version(sys.exec("sh -c \"apk info -v luci-app-ghfu 2>/dev/null | head -n1\""))
if apk_line_ver ~= "" then
return apk_line_ver
end
local opkg_ver = normalize_version(sys.exec("sh -c \"opkg status luci-app-ghfu 2>/dev/null | awk -F': ' '/^Version:/{print $2; exit}'\""))
if opkg_ver ~= "" then
return opkg_ver
end
local verfile = normalize_version(read_first_line("/usr/share/ghfu/version"))
if verfile ~= "" then
return verfile
end
return ""
end
local function parse_assets(raw_assets)
local assets = {}
if not raw_assets or type(raw_assets) ~= "table" then
return assets
end
local tmp = {}
for k, a in pairs(raw_assets) do
if type(a) == "table" then
local digest_raw = tostring(a.digest or "")
local sha256_val = digest_raw:match("^sha256:([a-f0-9]+)$") or ""
tmp[#tmp + 1] = {
idx = tonumber(k) or (1000000 + #tmp),
name = a.name or "",
url = a.browser_download_url or "",
size = a.size or 0,
sha256 = sha256_val
}
end
end
table.sort(tmp, function(x, y)
return (x.idx or 0) < (y.idx or 0)
end)
for _, a in ipairs(tmp) do
assets[#assets + 1] = {
name = a.name,
url = a.url,
size = a.size,
sha256 = a.sha256
}
end
return assets
end
local function release_from_obj(obj)
return {
tag_name = obj.tag_name or "",
release_name = obj.name or obj.tag_name or "",
published_at = obj.published_at or "",
html_url = obj.html_url or "",
assets = parse_assets(obj.assets)
}
end
local fetch_github_json
local function fetch_previous_release_with_assets(repo, timeout, accept_header, exclude_tag)
local releases_url = "https://api.github.com/repos/" .. repo .. "/releases?per_page=10"
local rel_obj = fetch_github_json(releases_url, timeout, accept_header)
if not rel_obj or type(rel_obj) ~= "table" then
return nil
end
local rel_tmp = {}
for k, v in pairs(rel_obj) do
if type(v) == "table" then
rel_tmp[#rel_tmp + 1] = {
idx = tonumber(k) or (1000000 + #rel_tmp),
item = v
}
end
end
table.sort(rel_tmp, function(x, y)
return (x.idx or 0) < (y.idx or 0)
end)
for _, r in ipairs(rel_tmp) do
local candidate = release_from_obj(r.item)
if candidate.tag_name ~= "" and candidate.tag_name ~= exclude_tag and #candidate.assets > 0 then
return candidate
end
end
return nil
end
fetch_github_json = function(url, timeout, accept_header)
local cmd = "uclient-fetch -T " .. tostring(timeout) .. " -H " .. util.shellquote(accept_header) .. " -qO- " .. util.shellquote(url) .. " 2>/dev/null"
local raw = sys.exec(cmd)
-- Fallback for uclient-fetch variants without custom-header support.
if not raw or raw == "" then
local fallback = "uclient-fetch -T " .. tostring(timeout) .. " -qO- " .. util.shellquote(url) .. " 2>/dev/null"
raw = sys.exec(fallback)
end
if not raw or raw == "" then
return nil, tr("Failed to fetch GitHub release data")
end
local obj = jsonc.parse(raw)
if not obj then
return nil, tr("Failed to parse GitHub API response")
end
return obj
end
local function fetch_latest_release(repo, timeout)
local t = math.floor(tonumber(timeout) or 15)
if t < 5 then t = 5 end
if t > 300 then t = 300 end
local accept = "Accept: application/vnd.github.full+json"
local latest_url = "https://api.github.com/repos/" .. repo .. "/releases/latest"
local obj, err = fetch_github_json(latest_url, t, accept)
if not obj then
return nil, err
end
if obj.message and not obj.tag_name then
return nil, tr("GitHub API error: ") .. tostring(obj.message)
end
local latest = release_from_obj(obj)
local info = {
fallback_used = false,
partial_assets = false
}
if #latest.assets > 0 then
local prev = fetch_previous_release_with_assets(repo, t, accept, latest.tag_name)
if prev and #prev.assets > #latest.assets then
info.partial_assets = true
end
return latest, nil, info
end
-- Latest release exists but assets are not ready yet (e.g. CI still uploading).
-- Fallback to the previous release that already has assets.
local fallback = fetch_previous_release_with_assets(repo, t, accept, latest.tag_name)
if fallback then
info.fallback_used = true
return fallback, nil, info
end
return latest, nil, info
end
function index()
if not fs.access("/etc/config/ghfu") then
return
end
entry({"admin", "system", "ghfu"}, call("action_index"), _("GitHub Firmware Upgrade"), 65)
entry({"admin", "system", "ghfu", "status"}, call("action_status")).leaf = true
entry({"admin", "system", "ghfu", "local_status"}, call("action_local_status")).leaf = true
entry({"admin", "system", "ghfu", "config"}, call("action_config")).leaf = true
entry({"admin", "system", "ghfu", "download"}, call("action_download")).leaf = true
entry({"admin", "system", "ghfu", "download_status"}, call("action_download_status")).leaf = true
entry({"admin", "system", "ghfu", "upgrade"}, call("action_upgrade")).leaf = true
entry({"admin", "system", "ghfu", "backup"}, call("action_backup")).leaf = true
end
function action_index()
luci.template.render("ghfu/main", {
app_version = get_app_version()
})
end
function action_local_status()
local cfg = get_cfg()
local app_version = get_app_version()
local install_epoch = get_install_epoch()
local install_local = epoch_to_local_string(install_epoch)
json_resp({
ok = true,
app_version = app_version,
config = cfg,
install_epoch = install_epoch,
install_time = install_local,
tz_offset_min = get_tz_offset_minutes()
})
end
function action_status()
local cfg = get_cfg()
local repo_input = normalize_repo(http.formvalue("repo") or cfg.github_repo)
local app_version = get_app_version()
-- Read and validate fetch_timeout from form; fall back to saved config
local timeout_raw = trim(http.formvalue("fetch_timeout") or "")
local fetch_timeout = tonumber(timeout_raw)
if not fetch_timeout or fetch_timeout < 5 or fetch_timeout > 300 then
fetch_timeout = tonumber(cfg.fetch_timeout) or 15
end
fetch_timeout = math.floor(fetch_timeout)
-- Persist timeout setting
set_cfg(nil, nil, nil, tostring(fetch_timeout))
if repo_input == "" then
json_resp({
ok = false,
msg = tr("GitHub repository cannot be empty"),
app_version = app_version,
config = cfg
})
return
end
local install_epoch = get_install_epoch()
local install_iso = epoch_to_iso_utc(install_epoch)
local install_local = epoch_to_local_string(install_epoch)
local latest, err, rel_info = fetch_latest_release(repo_input, fetch_timeout)
if not latest then
local timed_out = (err == tr("Failed to fetch GitHub release data"))
json_resp({
ok = false,
msg = timed_out and tr("Unable to fetch GitHub Release, please check your network connection") or err,
app_version = app_version,
config = cfg,
install_epoch = install_epoch,
install_time = install_local,
tz_offset_min = get_tz_offset_minutes(),
fetch_timeout = fetch_timeout
})
return
end
latest.release_total_count = (latest.assets and #latest.assets) or 0
local has_new = false
if latest.published_at ~= "" and install_iso ~= "" then
has_new = latest.published_at > install_iso
end
local msg
if has_new then
msg = tr("New firmware is available")
else
msg = tr("No firmware newer than current system was found")
end
json_resp({
ok = true,
msg = msg,
app_version = app_version,
has_new = has_new,
fallback_used = rel_info and rel_info.fallback_used or false,
partial_assets = rel_info and rel_info.partial_assets or false,
repo = repo_input,
install_epoch = install_epoch,
install_time = install_local,
tz_offset_min = get_tz_offset_minutes(),
fetch_timeout = fetch_timeout,
config = cfg,
latest = latest
})
end
function action_config()
local repo = normalize_repo(http.formvalue("repo") or "")
local keep_config = http.formvalue("keep_config")
local fetch_timeout_raw = trim(http.formvalue("fetch_timeout") or "")
local fetch_timeout = nil
if fetch_timeout_raw ~= "" then
local t = tonumber(fetch_timeout_raw)
if t and t >= 5 and t <= 300 then
fetch_timeout = tostring(math.floor(t))
end
end
local filter_ext_enabled = http.formvalue("filter_ext_enabled")
local valid_extensions = http.formvalue("valid_extensions")
local has_basic = (http.formvalue("repo") ~= nil) or (keep_config ~= nil) or (http.formvalue("fetch_timeout") ~= nil)
if has_basic and repo == "" then
json_resp({
ok = false,
msg = tr("GitHub repository cannot be empty"),
config = get_cfg()
})
return
end
set_cfg(
has_basic and repo or nil,
nil,
keep_config,
fetch_timeout,
filter_ext_enabled,
valid_extensions
)
json_resp({
ok = true,
config = get_cfg()
})
end
function action_download()
local repo = normalize_repo(http.formvalue("repo") or "")
local selected_release = trim(http.formvalue("selected_release") or "")
local keep_config = to_bool(http.formvalue("keep_config")) and "1" or "0"
local asset_url = trim(http.formvalue("asset_url") or "")
local asset_name = trim(http.formvalue("asset_name") or "firmware.bin.gz")
local asset_size = tonumber(trim(http.formvalue("asset_size") or "")) or 0
local asset_digest = trim(http.formvalue("asset_digest") or "")
if not asset_digest:match("^[a-f0-9]+$") then
asset_digest = ""
end
local logs = {}
if repo == "" then
logs[#logs + 1] = tr("GitHub repository cannot be empty")
json_resp({ ok = false, logs = logs })
return
end
set_cfg(repo, selected_release, keep_config)
if selected_release == "" then
logs[#logs + 1] = tr("Please select firmware to upgrade")
json_resp({ ok = false, logs = logs })
return
end
if asset_url == "" then
logs[#logs + 1] = tr("Please select a firmware asset")
json_resp({ ok = false, logs = logs })
return
end
if not asset_url:match("^https://") then
logs[#logs + 1] = tr("Invalid firmware URL, only https is supported")
json_resp({ ok = false, logs = logs })
return
end
local clean_name = asset_name:gsub("[^%w%._%-]", "_")
if clean_name == "" then
clean_name = "firmware.bin.gz"
end
local fw_path = "/tmp/ghfu_" .. clean_name
local progress_log = "/tmp/ghfu-download-progress.log"
local raw_log = "/tmp/ghfu-download-raw.log"
fs.unlink(progress_log)
fs.unlink(raw_log)
local shell_script = table.concat({
"progress_log=", util.shellquote(progress_log), "\n",
"raw_log=", util.shellquote(raw_log), "\n",
"target=", util.shellquote(fw_path), "\n",
"total=", tostring(math.max(0, math.floor(asset_size))), "\n",
"url=", util.shellquote(asset_url), "\n",
": > \"$progress_log\"\n",
": > \"$raw_log\"\n",
"uclient-fetch -qO \"$target\" \"$url\" >\"$raw_log\" 2>&1 &\n",
"dlpid=$!\n",
"prev_size=0\n",
"prev_ts=$(date +%s)\n",
"last_pct=-1\n",
"while kill -0 \"$dlpid\" 2>/dev/null; do\n",
" sleep 1\n",
" size=$(wc -c < \"$target\" 2>/dev/null)\n",
" [ -z \"$size\" ] && size=0\n",
" now=$(date +%s)\n",
" elapsed=$((now - prev_ts))\n",
" [ \"$elapsed\" -le 0 ] && elapsed=1\n",
" delta=$((size - prev_size))\n",
" rate=$((delta / elapsed))\n",
" if [ \"$total\" -gt 0 ]; then\n",
" pct=$((size * 100 / total))\n",
" [ \"$pct\" -gt 100 ] && pct=100\n",
" if [ \"$pct\" -ne \"$last_pct\" ]; then\n",
" echo \"PROGRESS|$pct|$size|$total|$rate\" >> \"$progress_log\"\n",
" last_pct=$pct\n",
" fi\n",
" else\n",
" echo \"PROGRESS|0|$size|0|$rate\" >> \"$progress_log\"\n",
" fi\n",
" prev_size=$size\n",
" prev_ts=$now\n",
"done\n",
"wait \"$dlpid\"\n",
"rc=$?\n",
"size=$(wc -c < \"$target\" 2>/dev/null)\n",
"[ -z \"$size\" ] && size=0\n",
"if [ \"$rc\" -eq 0 ] && [ -s \"$target\" ]; then\n",
" sha256=$(sha256sum \"$target\" 2>/dev/null | awk '{print $1}')\n",
" expected=", util.shellquote(asset_digest), "\n",
" if [ -n \"$expected\" ] && [ -n \"$sha256\" ]; then\n",
" if [ \"$sha256\" = \"$expected\" ]; then\n",
" echo \"SHA256OK|$sha256\" >> \"$progress_log\"\n",
" echo \"DONE|OK|$size|$total|$target\" >> \"$progress_log\"\n",
" else\n",
" echo \"SHA256FAIL|$sha256|$expected\" >> \"$progress_log\"\n",
" echo \"DONE|FAIL|$size|$total|$target\" >> \"$progress_log\"\n",
" rm -f \"$target\"\n",
" fi\n",
" else\n",
" echo \"DONE|OK|$size|$total|$target\" >> \"$progress_log\"\n",
" fi\n",
"else\n",
" echo \"DONE|FAIL|$size|$total|$target\" >> \"$progress_log\"\n",
" if [ -s \"$raw_log\" ]; then\n",
" sed 's/^/ERR|/' \"$raw_log\" >> \"$progress_log\"\n",
" fi\n",
"fi\n",
})
logs[#logs + 1] = tr("Downloading firmware: ") .. clean_name
local start_cmd = "sh -c " .. util.shellquote("(" .. shell_script .. ") >/dev/null 2>&1 &")
local rc = sys.call(start_cmd)
if rc ~= 0 then
logs[#logs + 1] = tr("Firmware download failed, please check network or GitHub repository")
json_resp({ ok = false, logs = logs })
return
end
json_resp({
ok = true,
started = true,
logs = logs
})
end
function action_download_status()
local offset = tonumber(trim(http.formvalue("offset") or "0")) or 0
if offset < 0 then
offset = 0
end
local progress_log = "/tmp/ghfu-download-progress.log"
local lines = read_lines(progress_log)
local total_lines = #lines
local done = false
local download_ok = false
local sha256_fail = false
local fw_name = ""
for _, line in ipairs(lines) do
local status, _, _, target = line:match("^DONE|([A-Z]+)|(%d+)|(%d+)|(.+)$")
if status then
done = true
download_ok = (status == "OK")
local n = tostring(target or ""):match("/tmp/ghfu_(.+)$")
if n and n ~= "" then
fw_name = n
end
end
if line:match("^SHA256FAIL|") then
sha256_fail = true
end
end
if sha256_fail then
download_ok = false
end
local logs = {}
local start_idx = offset + 1
if start_idx < 1 then start_idx = 1 end
for i = start_idx, total_lines do
local line = lines[i]
local pct, size, total, rate = line:match("^PROGRESS|(%d+)|(%d+)|(%d+)|(%-?%d+)$")
if pct then
local speed_text = human_bytes(rate) .. "/s"
if tonumber(total) and tonumber(total) > 0 then
logs[#logs + 1] = tr("Download progress: ") .. pct .. "% (" .. human_bytes(size) .. "/" .. human_bytes(total) .. "), " .. tr("Speed: ") .. speed_text
else
logs[#logs + 1] = tr("Download progress: ") .. human_bytes(size) .. ", " .. tr("Speed: ") .. speed_text
end
else
local status2, done_size, done_total, target2 = line:match("^DONE|([A-Z]+)|(%d+)|(%d+)|(.+)$")
if status2 then
if tonumber(done_total) and tonumber(done_total) > 0 then
logs[#logs + 1] = tr("Download finished: ") .. human_bytes(done_size) .. "/" .. human_bytes(done_total)
else
logs[#logs + 1] = tr("Download finished: ") .. human_bytes(done_size)
end
if status2 == "OK" then
local name2 = tostring(target2 or ""):match("/tmp/ghfu_(.+)$")
if name2 and name2 ~= "" then
logs[#logs + 1] = name2 .. tr(" downloaded successfully")
end
else
logs[#logs + 1] = tr("Firmware download failed, please check network or GitHub repository")
end
else
local sha256ok_hash = line:match("^SHA256OK|([a-f0-9]+)$")
if sha256ok_hash then
logs[#logs + 1] = tr("SHA256 verification passed: ") .. sha256ok_hash
else
local comp, exp = line:match("^SHA256FAIL|([a-f0-9]+)|([a-f0-9]+)$")
if comp then
logs[#logs + 1] = tr("SHA256 verification failed, upgrade cancelled")
logs[#logs + 1] = tr(" Expected: ") .. exp
logs[#logs + 1] = tr(" Got: ") .. comp
else
local err_line = line:match("^ERR|(.+)$")
if err_line and trim(err_line) ~= "" then
logs[#logs + 1] = err_line
end
end
end
end
end
end
json_resp({
ok = true,
done = done,
download_ok = download_ok,
fw_name = fw_name,
logs = logs,
next_offset = total_lines
})
end
function action_upgrade()
local keep_config = to_bool(http.formvalue("keep_config")) and "1" or "0"
local fw_name = trim(http.formvalue("fw_name") or "")
local logs = {}
-- Prevent path traversal: only allow safe filenames
if fw_name == "" or not fw_name:match("^[%w%._%-]+$") then
logs[#logs + 1] = tr("Invalid firmware file name")
json_resp({ ok = false, logs = logs })
return
end
local fw_path = "/tmp/ghfu_" .. fw_name
if not fs.access(fw_path) then
logs[#logs + 1] = tr("Firmware file not found, please download again")
json_resp({ ok = false, logs = logs })
return
end
local keep_flag = (keep_config == "1") and "" or "-n"
local up_cmd = "/sbin/sysupgrade " .. keep_flag .. " " .. util.shellquote(fw_path)
logs[#logs + 1] = tr("Starting system upgrade")
logs[#logs + 1] = (keep_config == "1") and tr("Upgrade mode: keep configuration") or tr("Upgrade mode: do not keep configuration")
local bg = string.format("(sleep 2; %s >/tmp/ghfu-upgrade.log 2>&1) &", up_cmd)
sys.call(bg)
json_resp({
ok = true,
logs = logs,
rebooting = true
})
end
function action_backup()
local tmp = "/tmp/ghfu-backup.tar.gz"
fs.unlink(tmp)
local rc = sys.call("sysupgrade --create-backup " .. util.shellquote(tmp) .. " >/dev/null 2>&1")
if rc ~= 0 or not fs.access(tmp) then
http.status(500, "Internal Server Error")
http.prepare_content("text/plain; charset=utf-8")
http.write(tr("Backup failed"))
return
end
local st = fs.stat(tmp)
local hostname = trim(sys.exec("cat /proc/sys/kernel/hostname 2>/dev/null"))
if hostname == "" then
hostname = "OpenWrt"
end
hostname = hostname:gsub("[^%w%._%-]", "_")
local fname = "backup-" .. hostname .. "-" .. os.date("%Y-%m-%d") .. ".tar.gz"
http.header("Content-Disposition", 'attachment; filename="' .. fname .. '"')
if st then
http.header("Content-Length", tostring(st.size))
end
http.prepare_content("application/octet-stream")
local f = io.open(tmp, "rb")
if f then
repeat
local chunk = f:read(65536)
if chunk then http.write(chunk) end
until not chunk
f:close()
fs.unlink(tmp)
end
end