#!/bin/bash
#===============================================================================
# 把面板的「云端」指向自持镜像 bt.kajiejie.com
#
# 作用：让面板自己的这些功能改从自持镜像取，而不是厂商的 install.btweb.co
#   * 软件商店的插件列表        class/PluginLoader.py
#   * 插件包的下载              class/panelPlugin.py (__api_root_url / public.sh 替换)
#   * UI 里的面板版本检测       class/system.py
#   * UI 里的面板升级下载       script/upgrade_panel_optimized.py
#
# 用法：
#   curl -sSL https://bt.kajiejie.com/tools/bt_selfhost_patch.sh | bash
#   bash bt_selfhost_patch.sh              # 应用补丁
#   bash bt_selfhost_patch.sh --check      # 只检查当前指向，不改动
#   bash bt_selfhost_patch.sh --revert     # 从最近一次备份还原
#
# 说明：
#   * 幂等 —— 已打过补丁的会跳过。
#   * 不动 public.GetConfigValue('home')（它有 70+ 调用点，涉及授权校验/云查杀/
#     WAF 恶意库/微信登录等厂商运行时服务，自持镜像没有这些接口）。
#   * 不动 panelPlugin.py 里 '/api/Pluginother/get_file'（「一键部署」类插件取
#     子文件的接口，本镜像未实现，保持指向厂商以免反而弄坏它）。
#   * 补丁会随面板升级被覆盖，所以已接入 install.sh / update6.sh 自动重打。
#   * 另外把 install/{nginx,mysql,php}.sh 里校验 public.sh 的 md5 守卫一并改成自持版
#     public.sh 的 md5，否则面板装/修 nginx/mysql/php 时会从 download.bt.cn 重下官方
#     public.sh 覆盖客户端，把上面的补丁整个回退。
#   * 还有一处同类问题：装/修环境时 install/install_soft.sh 会先用 wget 把镜像下发的
#     <name>.sh（nginx/mysql/php.sh）整份覆盖到 install/<name>.sh，把上面那三个 md5 守卫
#     整个冲掉；而 <name>.sh 自己也会从厂商源重下官方 public.sh。所以本脚本还在
#     install/install_soft.sh 里插入一段「执行前就地改写 <name>.sh」的代码（用 [selfhost]
#     标记，幂等）。install_soft.sh 同样会被升级包覆盖，故每次升级后需重新插入。
#===============================================================================
MIRROR="${MIRROR:-https://bt.kajiejie.com}"
PANEL="${PANEL:-/www/server/panel}"
OLD="https://install.btweb.co"
BAK_ROOT="${BAK_ROOT:-/root/selfhost-patch-backup}"

RED=$'\033[31m'; GRN=$'\033[32m'; YEL=$'\033[33m'; NC=$'\033[0m'

MODE=apply
case "${1:-}" in
    --check)  MODE=check ;;
    --revert) MODE=revert ;;
    "")       MODE=apply ;;
    *) echo "未知参数: $1"; exit 1 ;;
esac

[ -d "$PANEL/class" ] || { echo "${RED}找不到面板目录 $PANEL${NC}"; exit 1; }

TARGETS=(
    "class/PluginLoader.py"
    "class/panelPlugin.py"
    "class/system.py"
    "script/upgrade_panel_optimized.py"
)

# 面板 install/ 下这三个脚本在安装/修复 nginx/mysql/php 时会校验客户端 public.sh 的
# md5（第 8 行左右的 md5check=），一旦不符就从 download.bt.cn 重下官方 public.sh 覆盖
# 回去，让上面的补丁失效（nginx.sh 无条件触发，mysql/php 在 NODE_URL 为空时触发）。
# 所以把守卫里的常量一并换成自持版 public.sh 的 md5：
#   SH_OLD_MD5 = 官方原版 public.sh 的 md5
#   SH_NEW_MD5 = 本镜像 install/public.sh 的 md5（与厂商版不同 → 必须同步守卫）
SH_TARGETS=(
    "install/nginx.sh"
    "install/mysql.sh"
    "install/php.sh"
)
SH_OLD_MD5="484c945780dc4a802cff267f3fb15a66"
SH_NEW_MD5="742857589a6e1678316387065a61cc09"

# install/install_soft.sh 不是「替换常量」，而是要往里插一段代码（见 patch_install_soft），
# 故单独一组。它是面板装/修环境（nginx/mysql/php 等）的统一入口：
#   panelPlugin.install_async → cd /www/server/panel/install && bash install_soft.sh <mtype> ...
# 所以把改写逻辑放在这里与 mtype 无关，天然覆盖 mtype=1/3/4 全部入口。
# 插入段以 INS_MARK 为标记，重复执行时凭标记跳过（幂等）。
INSERT_TARGETS=(
    "install/install_soft.sh"
)
INS_MARK='[selfhost]'

# 注意: grep -c 在无匹配时会输出 0 但退出码为 1，不能再接 `|| echo 0`（会得到两行）
# 另外要排除「故意保留指向厂商」的那一行：panelPlugin.py 里的
# '/api/Pluginother/get_file'（一键部署类插件取子文件，本镜像未实现）。
KEEP_RE='Pluginother/get_file'
count_refs() {
    [ -f "$PANEL/$1" ] || { echo 0; return; }
    local n
    n=$(grep -c -- "$OLD" "$PANEL/$1" 2>/dev/null | head -1) || true
    local k
    k=$(grep -- "$OLD" "$PANEL/$1" 2>/dev/null | grep -c -- "$KEEP_RE" | head -1) || true
    echo $(( ${n:-0} - ${k:-0} ))
}
count_mine() {
    [ -f "$PANEL/$1" ] || { echo 0; return; }
    local n
    n=$(grep -c -- "$MIRROR" "$PANEL/$1" 2>/dev/null | head -1) || true
    echo "${n:-0}"
}
count_sh() {
    [ -f "$PANEL/$1" ] || { echo 0; return; }
    local n
    n=$(grep -c -- "$SH_OLD_MD5" "$PANEL/$1" 2>/dev/null | head -1) || true
    echo "${n:-0}"
}
count_sh_new() {
    [ -f "$PANEL/$1" ] || { echo 0; return; }
    local n
    n=$(grep -c -- "$SH_NEW_MD5" "$PANEL/$1" 2>/dev/null | head -1) || true
    echo "${n:-0}"
}

# ---------- install/install_soft.sh 的插入段 ----------
# 该文件是否已含我们的插入段（凭 [selfhost] 注释标记判定）。
# 必须用 -F：标记里的 [] 是 grep 的字符组语法，不加 -F 会退化成一个字符的匹配。
ins_marked() {
    [ -f "$PANEL/$1" ] || return 1
    grep -qF -- "$INS_MARK" "$PANEL/$1" 2>/dev/null
}
# 找「执行刚下载脚本」那一行（bash $name.sh / bash ${name}.sh ...）的行号，找不到输出空。
# 不写死行号：厂商不同 revision 的行号会漂（当前是 30 行、bash $name.sh 在第 28 行）。
find_anchor_line() {
    [ -f "$PANEL/$1" ] || return 0
    grep -nE '^[[:space:]]*bash[[:space:]]+[$][{]?name[}]?[.]sh' "$PANEL/$1" 2>/dev/null \
        | head -1 | cut -d: -f1
}
# 输出要插进 install_soft.sh 的代码块。
#   * 用 <<'SHBLOCK' 引号 heredoc：里面的 $name 等要保持原样，留给 install_soft.sh 运行时展开。
#   * __SELFHOST_MIRROR__ / __SELFHOST_MD5__ 两个占位符再替换成本脚本的 MIRROR / SH_NEW_MD5，
#     避免常量在两处重复维护（MIRROR 里若含 | 或 & 需自行留意，默认值不含）。
build_insert_block() {
    cat <<'SHBLOCK' | sed -e "s|__SELFHOST_MIRROR__|$MIRROR|g" -e "s|__SELFHOST_MD5__|$SH_NEW_MD5|g"

#>>> [selfhost] 自持镜像改写段 — 开始（由 bt_selfhost_patch.sh 插入，请勿手改）>>>
# 刚下载的 $name.sh（nginx/mysql/php）里钉着校验 public.sh 的 md5check 常量：客户端
# public.sh 的 md5 一旦与它不符，脚本就会从厂商源重下官方 public.sh 覆盖回去，让自持
# 补丁整体失效；另外它还有 public.sh 的兜底取址。这里在真正执行前就地改写它（面板上文
# 也做同类就地 sed，本段与之同构）。install_soft.sh 会被面板升级包整份覆盖，所以本段由
# bt_selfhost_patch.sh 在每次升级后重新插入（与 class/*.py 等目标同属一类，必须重打）。
_sh_md5_new='__SELFHOST_MD5__'
_sh_pub_new='__SELFHOST_MIRROR__/install/public.sh'
if [ -f "$name.sh" ]; then
	sed -i "s|md5check=\"[0-9a-fA-F]\{32\}\"|md5check=\"${_sh_md5_new}\"|g" "$name.sh" 2>/dev/null
	sed -i "s|http[s]\?://download\.bt\.cn/install/public\.sh|${_sh_pub_new}|g" "$name.sh" 2>/dev/null
	sed -i "s|http[s]\?://install\.btweb\.co/install/public\.sh|${_sh_pub_new}|g" "$name.sh" 2>/dev/null
	sed -i "s|http[s]\?://io\.bt\.sb/install/public\.sh|${_sh_pub_new}|g" "$name.sh" 2>/dev/null
	_sh_md5_hit=$(grep -cF "md5check=\"${_sh_md5_new}\"" "$name.sh" 2>/dev/null)
	_sh_pub_hit=$(grep -cF "${_sh_pub_new}" "$name.sh" 2>/dev/null)
	if [ "${_sh_md5_hit:-0}" -lt 1 ] || [ "${_sh_pub_hit:-0}" -lt 1 ]; then
		echo "[selfhost][WARN] $name.sh 就地改写未命中(md5 ${_sh_md5_hit:-0} 处 / public.sh ${_sh_pub_hit:-0} 处)：厂商可能改了写法，本次自持补丁未生效！"
	fi
	unset _sh_md5_hit _sh_pub_hit
fi
# lib.sh 有同样的 public.sh 兜底取址（仅在缺失/为空时才重下，改盘即持久）；best-effort，不告警
if [ -f 'lib.sh' ]; then
	sed -i "s|http[s]\?://download\.bt\.cn/install/public\.sh|${_sh_pub_new}|g" lib.sh 2>/dev/null
	sed -i "s|http[s]\?://install\.btweb\.co/install/public\.sh|${_sh_pub_new}|g" lib.sh 2>/dev/null
	sed -i "s|http[s]\?://io\.bt\.sb/install/public\.sh|${_sh_pub_new}|g" lib.sh 2>/dev/null
fi
unset _sh_pub_new _sh_md5_new
#<<< [selfhost] 自持镜像改写段 — 结束 <<<
SHBLOCK
}
# 把插入块放到锚点行之前（锚点行本身不动）。
#   MODE=check → 只打印一行状态、不动文件
#   MODE=apply → 执行插入
# 返回：0=已插入（check 下为「厂商原样」）  1=已含插入段（幂等跳过）
#       2=文件不存在  3=未找到锚点（不插入，宁漏勿错）
patch_install_soft() {
    local f="$1" src="$PANEL/$1" blk tmp anchor
    if [ ! -f "$src" ]; then
        [ "$MODE" = "check" ] && printf "  %-42s %s\n" "$f" "缺失"
        return 2
    fi
    if ins_marked "$f"; then
        [ "$MODE" = "check" ] && printf "  %-42s %s\n" "$f" "已插入自持改写段"
        return 1
    fi
    if [ "$MODE" = "check" ]; then
        printf "  %-42s %s\n" "$f" "厂商原样（未插入）"
        return 0
    fi

    anchor=$(find_anchor_line "$f")
    [ -n "$anchor" ] || return 3

    blk=$(mktemp 2>/dev/null) || return 3
    tmp="$src.selfhost.tmp"
    build_insert_block > "$blk"
    if ! awk -v blk="$blk" '
            !done && $0 ~ /bash[ \t]+[$][{]?name[}]?[.]sh/ {
                while ((getline l < blk) > 0) print l
                close(blk); done = 1
            }
            { print }
        ' "$src" > "$tmp"; then
        rm -f "$blk" "$tmp"
        return 3
    fi
    rm -f "$blk"
    mv "$tmp" "$src"
    return 0
}

# 定向替换规则（不用全局替换，避免误伤不该改的地方）
#   1) 赋值语句里的字面量，如 api_root_url = 'https://install.btweb.co'
#      —— 不含 panelPlugin.py:981 的 '/api/Pluginother/get_file'（那是
#      「一键部署」类插件取子文件的接口，本镜像未实现，保持指向厂商）
#   2) get_home_node('https://install.btweb.co')
#   3) __set_pyenv 里把 public.sh 换成厂商地址的那两行 → 换成自持镜像（本镜像有官方版 public.sh）
#   4) system.py 的 UI 版本检测地址
#   5) upgrade_panel_optimized.py 里的下载端点（本镜像全部实现了：isCN、
#      get_panel_version_v3、get_stable_panel_version_v3、.pl、.zip、update_prep_script.sh）
apply_rules() {
    local f="$1" src="$PANEL/$1"
    case "$f" in
        class/PluginLoader.py)
            sed -i "s|'$OLD'|'$MIRROR'|g" "$src"
            ;;
        class/panelPlugin.py)
            sed -i "s|'$OLD'|'$MIRROR'|g" "$src"                  # 51 / 89 行
            sed -i "s|'$OLD/install/public.sh'|'$MIRROR/install/public.sh'|g" "$src"  # 246 / 247 行
            ;;
        class/system.py)
            # UI 的版本检测地址
            sed -i "s|public\.GetConfigValue('home') + '/api/panel/get_panel_version_v3'|'$MIRROR' + '/api/panel/get_panel_version_v3'|g" "$src"
            # UI 的升级流程会下载 .pl 校验信息（本镜像 install/update/ 下有）
            sed -i "s|'$OLD/install/update/|'$MIRROR/install/update/|g" "$src"
            ;;
        script/upgrade_panel_optimized.py)
            sed -i "s|$OLD|$MIRROR|g" "$src"
            ;;
        install/nginx.sh|install/mysql.sh|install/php.sh)
            # 只换守卫里的 md5 常量，不动这三个脚本的其他内容
            sed -i "s|$SH_OLD_MD5|$SH_NEW_MD5|g" "$src"
            ;;
    esac
}

# 只核对、不自动改：客户端 install/public.sh 应当已由 update6.sh 从镜像还原成自持版，
# 所以它的 md5 应该等于 SH_NEW_MD5。若不符只提示重跑升级，补丁这里不下载覆盖它
# （避免与 update6.sh 的 public.sh 还原逻辑职责重叠）。
check_public_md5() {
    local p="$PANEL/install/public.sh" cur
    if [ ! -f "$p" ]; then
        echo "${YEL}提示: 未找到 $p，跳过 public.sh 核对${NC}"
        return
    fi
    cur=$(md5sum "$p" 2>/dev/null | awk '{print $1}')
    if [ "$cur" = "$SH_NEW_MD5" ]; then
        echo "${GRN}✓ install/public.sh 已是自持版 (md5=$cur)${NC}"
    else
        echo "${YEL}注意: install/public.sh 不是自持版${NC}"
        echo "  期望: $SH_NEW_MD5"
        echo "  实际: $cur"
        echo "  本次升级应当已把它替换为自持版，若不一致请重跑升级。"
    fi
}

# ---------- --check ----------
if [ "$MODE" = "check" ]; then
    echo "镜像: 自持云端"
    echo
    printf "  %-42s %-12s %s\n" "文件" "厂商引用" "自持引用"
    for f in "${TARGETS[@]}"; do
        printf "  %-42s %-12s %s\n" "$f" "$(count_refs "$f")" "$(count_mine "$f")"
    done
    echo
    printf "  %-42s %-12s %s\n" "文件" "厂商 md5" "自持 md5"
    for f in "${SH_TARGETS[@]}"; do
        printf "  %-42s %-12s %s\n" "$f" "$(count_sh "$f")" "$(count_sh_new "$f")"
    done
    echo
    printf "  %-42s %s\n" "文件" "自持插入段"
    for f in "${INSERT_TARGETS[@]}"; do
        patch_install_soft "$f"
    done
    echo
    tot=0
    for f in "${TARGETS[@]}"; do tot=$((tot + $(count_refs "$f"))); done
    for f in "${SH_TARGETS[@]}"; do tot=$((tot + $(count_sh "$f"))); done
    for f in "${INSERT_TARGETS[@]}"; do
        if [ -f "$PANEL/$f" ] && ! ins_marked "$f"; then tot=$((tot + 1)); fi
    done
    if [ "$tot" = "0" ]; then
        echo "${GRN}✓ 已全部指向自持镜像${NC}"
    else
        echo "${YEL}仍有 $tot 处指向厂商，跑一次不带参数的即可应用补丁${NC}"
    fi
    echo
    check_public_md5
    exit 0
fi

# ---------- --revert ----------
if [ "$MODE" = "revert" ]; then
    # 取「最新的、且至少含一个文件」的备份目录：apply 若不产生改动就不再建备份目录，
    # 但历史版本可能在 BAK_ROOT 下留下过空目录，必须跳过它们，否则会选到空目录、
    # 什么都不还原却打印成功。
    LAST=""
    for d in $(ls -1dt "$BAK_ROOT"/* 2>/dev/null); do
        [ -d "$d" ] || continue
        if [ -n "$(find "$d" -type f -print -quit 2>/dev/null)" ]; then
            LAST="$d"; break
        fi
    done
    if [ -z "$LAST" ]; then
        echo "${RED}找不到可用备份：$BAK_ROOT 下没有含文件的备份目录，无法还原${NC}"
        exit 1
    fi
    echo "从备份还原: $LAST"
    n=0
    while IFS= read -r rel; do
        [ -n "$rel" ] || continue
        if cp -a "$LAST/$rel" "$PANEL/$rel"; then
            echo "  已还原 $rel"; n=$((n+1))
        fi
    done < <( cd "$LAST" && find . -type f | sed 's|^\./||' )
    if [ "$n" -eq 0 ]; then
        echo "${RED}备份目录中无可还原文件：$LAST${NC}"
        exit 1
    fi
    echo "${GRN}还原完成（$n 个文件），请重启面板${NC}"
    exit 0
fi

# ---------- apply ----------
# 备份目录按需创建：只有本次确实改了文件时才建（见下方 cp -a ... && BAK_HAS=1），
# 避免一次「无改动」的 apply 留下空目录，进而让 --revert 选到它而静默空转。
BAK="$BAK_ROOT/$(date +%Y%m%d-%H%M%S)"
BAK_HAS=0

changed=0
for f in "${TARGETS[@]}"; do
    src="$PANEL/$f"
    [ -f "$src" ] || { echo "${YEL}跳过（不存在）: $f${NC}"; continue; }

    before=$(count_refs "$f")
    if [ "$before" = "0" ]; then
        printf "  %-42s ${GRN}已是自持指向，跳过${NC}\n" "$f"
        continue
    fi

    # 备份（首次改这个文件时才备份）
    mkdir -p "$BAK/$(dirname "$f")"
    cp -a "$src" "$BAK/$f" && BAK_HAS=1

    apply_rules "$f"

    after=$(count_refs "$f")
    printf "  %-42s %s → %s 处厂商引用\n" "$f" "$before" "$after"
    changed=$((changed + before - after))
done

for f in "${SH_TARGETS[@]}"; do
    src="$PANEL/$f"
    [ -f "$src" ] || { echo "${YEL}跳过（不存在）: $f${NC}"; continue; }

    before=$(count_sh "$f")
    if [ "$before" = "0" ]; then
        printf "  %-42s ${GRN}已是自持 md5，跳过${NC}\n" "$f"
        continue
    fi

    # 备份（首次改这个文件时才备份）
    mkdir -p "$BAK/$(dirname "$f")"
    cp -a "$src" "$BAK/$f" && BAK_HAS=1

    apply_rules "$f"

    after=$(count_sh "$f")
    printf "  %-42s %s → %s 处厂商 md5\n" "$f" "$before" "$after"
    changed=$((changed + before - after))
done

# install/install_soft.sh：不是替换常量，而是插入一段「执行前就地改写 <name>.sh」的代码
for f in "${INSERT_TARGETS[@]}"; do
    src="$PANEL/$f"
    [ -f "$src" ] || { echo "${YEL}跳过（不存在）: $f${NC}"; continue; }

    if ins_marked "$f"; then
        printf "  %-42s ${GRN}已含自持插入段，跳过${NC}\n" "$f"
        continue
    fi

    anchor=$(find_anchor_line "$f")
    if [ -z "$anchor" ]; then
        printf "  %-42s ${YEL}未找到锚点(bash \$name.sh)，未插入${NC}\n" "$f"
        continue
    fi

    # 备份（首次改这个文件时才备份）
    mkdir -p "$BAK/$(dirname "$f")"
    cp -a "$src" "$BAK/$f" && BAK_HAS=1

    if patch_install_soft "$f"; then
        printf "  %-42s 已在第 %s 行(bash \$name.sh)前插入自持改写段\n" "$f" "$anchor"
        changed=$((changed + 1))
    else
        printf "  %-42s ${RED}插入失败，未改动${NC}\n" "$f"
    fi
done

echo
if [ "$changed" = "0" ]; then
    echo "${GRN}无需改动（已全部指向自持镜像）${NC}"
else
    echo "${GRN}已替换 $changed 处引用指向自持云端${NC}"
fi
if [ "$BAK_HAS" = "1" ]; then
    echo "备份: $BAK"
else
    # 本次没有任何文件被改，不应留下备份目录（防御：清掉可能残留的空目录）
    [ -d "$BAK" ] && rm -rf "$BAK"
    echo "备份: 无（本次无改动，未创建备份目录）"
fi

# 语法自检，出错立即回滚，避免把面板改坏
echo
echo "语法自检:"
fail=0
for f in "${TARGETS[@]}"; do
    [ -f "$PANEL/$f" ] || continue
    if [ -x "$PANEL/pyenv/bin/python3" ]; then
        if "$PANEL/pyenv/bin/python3" -c "import ast,sys; ast.parse(open(sys.argv[1],encoding='utf-8',errors='ignore').read())" "$PANEL/$f" 2>/dev/null; then
            printf "  %-42s ${GRN}OK${NC}\n" "$f"
        else
            printf "  %-42s ${RED}语法错误！${NC}\n" "$f"
            fail=1
        fi
    fi
done

# 这三个是 shell 脚本，用 bash -n 自检（不能对 .sh 用 ast.parse）
for f in "${SH_TARGETS[@]}"; do
    [ -f "$PANEL/$f" ] || continue
    if bash -n "$PANEL/$f" 2>/dev/null; then
        printf "  %-42s ${GRN}OK${NC}\n" "$f"
    else
        printf "  %-42s ${RED}语法错误！${NC}\n" "$f"
        fail=1
    fi
done

# install_soft.sh 同样用 bash -n 自检（插入段本身也是 shell 代码）
for f in "${INSERT_TARGETS[@]}"; do
    [ -f "$PANEL/$f" ] || continue
    if bash -n "$PANEL/$f" 2>/dev/null; then
        printf "  %-42s ${GRN}OK${NC}\n" "$f"
    else
        printf "  %-42s ${RED}语法错误！${NC}\n" "$f"
        fail=1
    fi
done

if [ "$fail" = "1" ]; then
    echo "${RED}有文件语法错误，回滚全部改动${NC}"
    for f in "${TARGETS[@]}" "${SH_TARGETS[@]}" "${INSERT_TARGETS[@]}"; do
        [ -f "$BAK/$f" ] && cp -a "$BAK/$f" "$PANEL/$f"
    done
    exit 1
fi

echo
check_public_md5
echo
echo "${GRN}补丁完成。${NC}面板需要重载才生效："
echo "  面板管理里点「重启面板」，或执行: systemctl restart bt.service"
echo "  提示：重启会断开当前面板登录会话，不影响网站运行。"
