#!/bin/sh
#
# tcblock_update - APP 在线版本检查与自更新
#
# 用法:
#   tcblock_update check    # 拉云端 latest.json,与本地 app_version 比对,结果写 update.state
#   tcblock_update apply    # 下载新包 → 校验 MD5/结构 → 备份 → 覆盖 → 重启 app(失败自动回滚)
#
# 设计要点:
#   - apply 必须以 detach 方式从"持久化目录"启动:appctrl stop 会 rm -rf ramdisk 运行区
#     (含正在执行的 CGI),从 ramdisk 跑会自杀。
#   - 三重校验(HTTP 200 / MD5 / 包内 app.inf 版本可解析)全过才落盘,任一失败保持原状。
#   - 覆盖前整目录备份,重启后校验 app 起来了,没起来立即回滚并重启。
#   - 全程不碰 floweye 群组,封堵在更新期间持续生效。
#

. /etc/PG.conf

if [ -f "/usr/pabin/pacu" ]; then
	CURL="/usr/pabin/pacu"
elif [ -f "/usr/bin/curl" ]; then
	CURL="/usr/bin/curl"
else
	CURL=$(which curl 2>/dev/null)
fi

APPNAME=tcblock
MYROOT="${PGPATH}/app/${APPNAME}"
# 备份目录必须放在 app 扫描目录之外:应用中心扫 ${PGPATH}/app/*/,
# 任何带 app.inf + appctrl 的目录都会被当成独立 APP 列出来
# (2026-08-07:三个遗留 .bak 目录导致应用商店里出现 3 个"TrapCloud 精准封堵")
BAKROOT="${PGPATH}/appbak"
KEEP_BAK=2
CONFDIR="${PGETC}/App/${APPNAME}"
APPCONF="${CONFDIR}/${APPNAME}.conf"
STATEF="${CONFDIR}/update.state"
LOGFILE="${CONFDIR}/update.log"
TMPDIR="/tmp/${APPNAME}_upd"
MANIFEST_URL="https://dl.trapcloud.cn:64005/panabit/latest.json"
CURL_TIMEOUT=60
MAX_LOG=30

calc_md5()
{
	if command -v md5sum >/dev/null 2>&1; then
		md5sum "$1" | cut -d' ' -f1
	else
		md5 -q "$1"
	fi
}

write_log()
{
	mkdir -p "${CONFDIR}"
	echo "$(date '+%Y-%m-%d %H:%M:%S')|${1}|${2}" >> "${LOGFILE}"
	tail -${MAX_LOG} "${LOGFILE}" > "${LOGFILE}.tmp" 2>/dev/null && mv "${LOGFILE}.tmp" "${LOGFILE}"
}

local_version()
{
	grep -a '^app_version=' "${MYROOT}/app.inf" 2>/dev/null | cut -d'"' -f2
}

# 从 JSON 里取字符串字段(无 jq,用 sed;字段值不含引号)
json_str()
{
	sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$2" | head -1
}

# 版本号形如 20260726.001200,去掉点后按数字比大小
ver_num()
{
	echo "$1" | tr -cd '0-9' | cut -c1-14
}

# ============================================================
do_check()
{
	mkdir -p "${TMPDIR}" "${CONFDIR}"
	mf="${TMPDIR}/latest.json"
	code=$(${CURL} -k -s -m ${CURL_TIMEOUT} -o "${mf}" -w "%{http_code}" "${MANIFEST_URL}" 2>/dev/null)
	if [ "${code}" != "200" ]; then
		write_log "CHECK_FAIL" "http_${code}"
		printf 'status=fail\nerror=http_%s\nchecked_at=%s\n' "${code}" "$(date +%s)" > "${STATEF}"
		rm -f "${mf}"
		return 1
	fi

	remote=$(json_str version "${mf}")
	url=$(json_str url "${mf}")
	md5=$(json_str md5 "${mf}")
	notes=$(json_str notes "${mf}")
	local_v=$(local_version)

	if [ "${remote}" = "" ] || [ "${url}" = "" ] || [ "${md5}" = "" ]; then
		write_log "CHECK_FAIL" "bad_manifest"
		printf 'status=fail\nerror=bad_manifest\nchecked_at=%s\n' "$(date +%s)" > "${STATEF}"
		rm -f "${mf}"
		return 1
	fi

	has=0
	[ "$(ver_num "${remote}")" -gt "$(ver_num "${local_v}")" ] 2>/dev/null && has=1

	cat > "${STATEF}" << EOF
status=ok
local=${local_v}
latest=${remote}
has_update=${has}
url=${url}
md5=${md5}
notes=${notes}
checked_at=$(date +%s)
EOF
	write_log "CHECK_OK" "local=${local_v},latest=${remote},has_update=${has}"
	rm -f "${mf}"

	# 自动更新(2026-08-07):conf 里 autoupdate=1 才触发。
	#
	# 🔴 默认必须是关闭,且只能由用户在 APP 页面显式开启——这是安全要求,不是默认值品味问题:
	#    开启即等于"云端改一行 latest.json 就能让这台网关自动执行我们下发的代码",
	#    是一条无人值守的供应链下推通道。客户网关是安全设备,默认必须人工确认才升级。
	#    任何时候都不要把默认值改成 1,也不要提供"为所有客户批量开启"的后门。
	# 关闭时(默认)的行为:check 只写 update.state + 日志,APP 页面提示有新版,升不升由用户点按钮。
	#
	# 为什么需要这个开关:第三方 APP 进不了 Panabit 官方云端清单
	# (应用商店列表页那个"可更新"绿标由 download.panabit.com 的 panabit_app_list.json 决定),
	# 客户不点进 APP 就看不到新版提示 → 给愿意承担自动升级的客户一个自选项。
	#
	# 只在 check 里触发:每天 cron 最多一次,不会连环重试。
	# 升级完 do_apply 会自己再 check 一次,那时 has_update=0,不会递归;
	# 且 TCB_UPD_DETACHED=1 时不触发,防 apply 内部的 check 再拉起一次 apply。
	if [ "${has}" = "1" ] && [ "${TCB_UPD_DETACHED}" != "1" ]; then
		au=0
		[ -f "${APPCONF}" ] && au=$(grep '^autoupdate=' "${APPCONF}" 2>/dev/null | cut -d'=' -f2 | tr -cd '01')
		if [ "${au}" = "1" ]; then
			write_log "AUTO_APPLY" "autoupdate=1,自动升级 ${local_v} → ${remote}"
			# 从持久化目录 detach 启动:appctrl stop 会 rm -rf ramdisk 运行区
			# 🔴 不用 nohup:部分固件没有该命令(见 ajax_tcblock 里的同款说明)
			( trap '' HUP; exec sh "${MYROOT}/bin/${APPNAME}_update" apply ) >/dev/null 2>&1 &
		fi
	fi

	return 0
}

# ============================================================
do_apply()
{
	[ -f "${STATEF}" ] || { write_log "APPLY_FAIL" "no_state,先执行 check"; return 1; }
	url=$(grep '^url=' "${STATEF}" | cut -d'=' -f2-)
	want_md5=$(grep '^md5=' "${STATEF}" | cut -d'=' -f2)
	latest=$(grep '^latest=' "${STATEF}" | cut -d'=' -f2)
	[ "${url}" = "" ] && { write_log "APPLY_FAIL" "no_url"; return 1; }

	mkdir -p "${TMPDIR}"
	pkg="${TMPDIR}/new.apx"
	code=$(${CURL} -k -s -m 180 -o "${pkg}" -w "%{http_code}" "${url}" 2>/dev/null)
	if [ "${code}" != "200" ]; then
		write_log "APPLY_FAIL" "download_http_${code}"
		rm -f "${pkg}"; return 1
	fi

	got_md5=$(calc_md5 "${pkg}")
	if [ "${got_md5}" != "${want_md5}" ]; then
		write_log "APPLY_FAIL" "md5_mismatch:want=${want_md5},got=${got_md5}"
		rm -f "${pkg}"; return 1
	fi

	# 解包并校验结构完整
	ext="${TMPDIR}/ext"
	rm -rf "${ext}"; mkdir -p "${ext}"
	tar -xzf "${pkg}" -C "${ext}" 2>/dev/null
	newver=$(grep -a '^app_version=' "${ext}/app.inf" 2>/dev/null | cut -d'"' -f2)
	if [ ! -f "${ext}/appctrl" ] || [ ! -f "${ext}/bin/tcblock_sync" ] || [ "${newver}" = "" ]; then
		write_log "APPLY_FAIL" "bad_package"
		rm -rf "${ext}" "${pkg}"; return 1
	fi

	# 备份 → 覆盖(备份落在 BAKROOT,不能落在 app 扫描目录里)
	mkdir -p "${BAKROOT}"
	bak="${BAKROOT}/${APPNAME}-upd-$(date +%Y%m%d%H%M%S)"
	cp -r "${MYROOT}" "${bak}" 2>/dev/null
	cp -f "${ext}/app.inf" "${ext}/app.png" "${ext}/appctrl" "${MYROOT}/" 2>/dev/null
	cp -f ${ext}/bin/* "${MYROOT}/bin/" 2>/dev/null
	cp -f ${ext}/web/cgi/* "${MYROOT}/web/cgi/" 2>/dev/null
	cp -f ${ext}/web/html/* "${MYROOT}/web/html/" 2>/dev/null
	# 755 而非 +x:+x 补不回 group/other 读位,打包机若是 700 会一路传染(2026-08-07)
	chmod 755 "${MYROOT}/appctrl" "${MYROOT}/app.inf" "${MYROOT}/app.png" 2>/dev/null
	chmod 755 ${MYROOT}/bin/* ${MYROOT}/web/cgi/* ${MYROOT}/web/html/* 2>/dev/null

	# 重启 app(stop 不动 floweye 群组,封堵不中断)
	sh "${MYROOT}/appctrl" stop >/dev/null 2>&1
	sh "${MYROOT}/appctrl" start >/dev/null 2>&1
	sleep 2

	# 校验:运行区起来了且版本已变
	runver=$(grep -a '^app_version=' "/usr/ramdisk/app/${APPNAME}/app.inf" 2>/dev/null | cut -d'"' -f2)
	if [ "${runver}" != "${newver}" ]; then
		write_log "APPLY_ROLLBACK" "启动校验失败(run=${runver},want=${newver}),回滚"
		rm -rf "${MYROOT}"
		mv "${bak}" "${MYROOT}"
		sh "${MYROOT}/appctrl" stop >/dev/null 2>&1
		sh "${MYROOT}/appctrl" start >/dev/null 2>&1
		rm -rf "${ext}" "${pkg}"
		return 1
	fi

	write_log "APPLY_OK" "${latest} 更新完成(备份 ${bak})"

	# 只留最近 KEEP_BAK 份备份,其余删除(目录名带时间戳,字典序=时间序)
	# 不用 head -n -N(busybox head 不支持负数),改成算出要删几个再 head -N
	cnt=$(ls -1d ${BAKROOT}/${APPNAME}-upd-* 2>/dev/null | wc -l | tr -cd '0-9')
	[ "${cnt}" = "" ] && cnt=0
	old=""
	if [ "${cnt}" -gt "${KEEP_BAK}" ]; then
		old=$(ls -1d ${BAKROOT}/${APPNAME}-upd-* 2>/dev/null | sort | head -$((cnt - KEEP_BAK)))
	fi
	for d in ${old}; do
		case "${d}" in
			"${BAKROOT}/${APPNAME}-upd-"*) rm -rf "${d}" ;;
		esac
	done

	# 刷新 update.state
	do_check >/dev/null 2>&1
	rm -rf "${ext}" "${pkg}"
	return 0
}

case "$1" in
"check")  do_check  ;;
"apply")
	# ⚠️ 自我覆盖防护(2026-08-07 实测踩到):do_apply 会把新包的 bin/* 覆盖进
	# ${MYROOT}/bin/,其中就包括正在执行的本脚本。sh 是按文件偏移边读边执行的,
	# 文件被就地换成新版后,shell 继续从旧偏移读,读到的是错位字节 → 收尾代码
	# 随机崩(实测 "syntax error near unexpected token '('",rc=2,备份清理段没跑到)。
	# 解法:先把自己复制到 /tmp 再 exec 副本,让被覆盖的那份不是正在执行的那份。
	if [ "${TCB_UPD_DETACHED}" != "1" ]; then
		mkdir -p "${TMPDIR}"
		if cp -f "$0" "${TMPDIR}/upd_run.sh" 2>/dev/null; then
			chmod +x "${TMPDIR}/upd_run.sh" 2>/dev/null
			TCB_UPD_DETACHED=1
			export TCB_UPD_DETACHED
			exec sh "${TMPDIR}/upd_run.sh" apply
		fi
		# 复制失败(如 /tmp 满)则退回原地执行,保持可用
	fi
	do_apply
	;;
*) echo "usage: tcblock_update check|apply" ;;
esac
