diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index a7607e9..5b09f8d 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -60,7 +60,7 @@ jobs: umask 077 key_file="${RUNNER_TEMP:-/tmp}/kaidi-erp-release-key.pem" - cleanup() { rm -f "$key_file" release.json release-payload.json; } + cleanup() { rm -f "$key_file" release.json release-payload.json release-notes.md; } trap cleanup EXIT printf '%s' "$RELEASE_PRIVATE_KEY_B64" | base64 --decode > "$key_file" export ERP_RELEASE_PRIVATE_KEY_FILE="$key_file" @@ -71,6 +71,23 @@ jobs: ) bash scripts/package-release.sh "$version" + previous_tag="$(git describe --tags --match 'v[0-9]*' --abbrev=0 "${tag}^" 2>/dev/null || true)" + change_range="$tag" + [[ -z "$previous_tag" ]] || change_range="$previous_tag..$tag" + { + printf '## 更新内容\n\n' + if ! git log --no-merges --format='- %s (`%h`)' "$change_range"; then + printf -- '- Kaidi ERP %s 正式发布\n' "$version" + fi + printf '\n## 安全校验\n\n' + printf -- '- 安装包:`kaidi-erp-%s.tar.gz`\n' "$version" + printf -- '- 完整性:SHA-256\n' + printf -- '- 发布签名:Ed25519\n' + if [[ -n "$previous_tag" ]]; then + printf '\n上一个正式版本:`%s`\n' "$previous_tag" + fi + } > release-notes.md + owner="${repository%%/*}" repo="${repository#*/}" release_api="${api_base%/}/api/v1/repos/$owner/$repo/releases" @@ -80,13 +97,15 @@ jobs: --header "$auth_header" --header 'Accept: application/json' \ "$release_api/tags/$tag")" - python3 - "$tag" > release-payload.json <<'PY' + python3 - "$tag" release-notes.md > release-payload.json <<'PY' import json, sys tag = sys.argv[1] + with open(sys.argv[2], encoding="utf-8") as handle: + notes = handle.read().strip() print(json.dumps({ "tag_name": tag, - "name": tag, - "body": f"Kaidi ERP {tag}", + "name": f"Kaidi ERP {tag}", + "body": notes, "draft": False, "prerelease": "-" in tag.split("+", 1)[0], }, separators=(",", ":"))) diff --git a/README.md b/README.md index a39832d..5154c83 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ PostgreSQL-only 正式构建: ```bash cd oa-backend -./gradlew clean bootJar -PreleaseVersion=0.2.0 -PproductionBuild=true +./gradlew clean bootJar -PreleaseVersion=0.3.5 -PproductionBuild=true ``` 正式 JAR 必须包含 PostgreSQL 驱动,并且不得包含 `sqlite-jdbc` 或 `hibernate-community-dialects`。 @@ -181,12 +181,15 @@ curl -fsSL https://git.example.com/awaioi/ERP/raw/branch/main/install.sh \ Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL 或精简系统只能显式使用 `--no-service` 做开发验收,在线更新也会保持关闭。 -安装器启动后会输出带一次性令牌的局域网地址,例如: +安装器启动后会输出带一次性令牌的访问地址。优先级依次为:命令行 `--public-url`(或 `ERP_PUBLIC_URL`)、HTTPS 服务探测到的公网 IP、局域网 IP。无论使用哪一种方式,都会同时输出仅服务器本机可用的 `Local URL`;公网探测失败时还会明确提示正在回退局域网地址。公网服务器建议显式传入地址,避免 NAT、多网卡或代理环境识别错误: ```text -Setup URL: http://192.168.1.20:8091/?token= +Setup URL: http://38.76.196.225:8091/?token= +Local URL: http://127.0.0.1:8091/?token= ``` +`--public-url` 支持域名、端口、路径和已有查询参数,安装器会安全追加一次性 `token`。使用公网 IP 直连时需要在防火墙或安全组放行 ERP 端口;通过 HTTPS 反向代理安装时,应将公开域名作为 `--public-url`。 + 首次打开该地址进入网页向导,依次完成环境检查、PostgreSQL 地址/端口/库名/账号/密码/SSL 测试、管理员账号/姓名/密码设置、数据库迁移和初始化。项目当前没有 Redis 依赖,因此向导不会显示 Redis 配置项。正式服务真实健康检查通过后,启动器才会原子写入安装锁并物理删除 `installer/` 和 `install.pending`。 PostgreSQL 必须使用专用空数据库,网页中填写的账号必须是该数据库的所有者。该约束保证账号拥有 `public` schema 建表权限,并能持有安装器创建的 `pg_trgm` 扩展;只授予 `CONNECT` 权限不足以完成迁移。 @@ -211,16 +214,18 @@ curl -fsSL https://git.example.com/awaioi/ERP/raw/branch/main/install.sh \ set -e tmp="$(mktemp)" trap 'rm -f -- "$tmp"' EXIT - curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.1/install.sh -o "$tmp" - printf '%s %s\n' '89a3c45e76f500c9475cb596ea29e3518bfafdc59f36dd3c7b316ed3cdd0448c' "$tmp" | sha256sum -c - + curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.5/install.sh -o "$tmp" + printf '%s %s\n' '76917da519895bec815bb5492b12f0894e8cff26d6754cb9b2569b45e52ed83c' "$tmp" | sha256sum -c - sudo -E bash "$tmp" \ --gitea-url http://38.76.196.225:10099 \ --repository awaioi/ERP \ + --version 0.3.5 \ + --public-url http://38.76.196.225:8091 \ --allow-insecure ) ``` -只有 `v0.3.1` Release 发布后这条命令才可下载安装包。固定 tag 和 SHA-256 只用于保护当前 HTTP 引导脚本不被传输途中篡改;Release 资产仍会继续执行 Ed25519 和 SHA-256 双重校验。HTTP 会暴露请求、Release 元数据和可能使用的访问令牌,不得作为生产方案。 +只有 `v0.3.5` Release 发布后这条命令才可下载安装包。固定 tag 和 SHA-256 只用于保护当前 HTTP 引导脚本不被传输途中篡改;Release 资产仍会继续执行 Ed25519 和 SHA-256 双重校验。HTTP 会暴露请求、Release 元数据和可能使用的访问令牌,不得作为长期生产方案。 ### 完整卸载后重装 @@ -231,7 +236,7 @@ curl -fsSL https://git.example.com/awaioi/ERP/raw/branch/main/install.sh \ set -e tmp="$(mktemp)" trap 'rm -f -- "$tmp"' EXIT - curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.1/uninstall.sh -o "$tmp" + curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.5/uninstall.sh -o "$tmp" printf '%s %s\n' '98c56fed2fd4d01874e4ab5a1a4f3ec42ec3e29b315ffd87385a95488d587546' "$tmp" | sha256sum -c - sudo -E bash "$tmp" --purge-database --yes ) @@ -256,11 +261,11 @@ Linux 默认路径: ### HTTPS 反向代理 -正式服务监听 `8090`。Nginx、宝塔、Caddy 或 CDN 终止 HTTPS 后,必须把公网协议和主机转发给 Spring Boot;否则浏览器的同源 API 请求会被误判为跨域,并收到纯文本 `403 Invalid CORS request`,前端表现为“响应非 JSON (HTTP 403)”。Nginx 的代理位置至少包含: +正式安装默认监听 `8091`(可通过 `ERP_SERVER_PORT` 覆盖)。Nginx、宝塔、Caddy 或 CDN 终止 HTTPS 后,必须把公网协议和主机转发给 Spring Boot;否则浏览器的同源 API 请求会被误判为跨域,并收到纯文本 `403 Invalid CORS request`,前端表现为“响应非 JSON (HTTP 403)”。Nginx 的代理位置至少包含: ```nginx location / { - proxy_pass http://127.0.0.1:8090; + proxy_pass http://127.0.0.1:8091; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-Host $host; @@ -275,13 +280,20 @@ location / { ## 在线更新与回滚 -管理员登录后进入: +管理员登录后可从以下任一入口进入: ```text +顶部工具栏 -> 系统更新 +用户菜单 -> 系统更新 +手机导航抽屉 -> 系统更新 应用定制平台 -> 系统更新 ``` -对应前端路由为 `/appdev/update`,后端 API 为 `/api/oa/system-update/*`。更新过程如下: +入口只对 `ADMIN` 角色显示,对应前端路由为 `/appdev/update`,后端 API 为 `/api/oa/system-update/*`。页面会显示当前版本、最新版本、检查时间、Release 更新日志、发布日期、发布文件、Ed25519/SHA-256 校验方式,以及下载、验签、安装、重启和自动回滚阶段。顶部和手机入口发现新版本时会显示版本提示。 + +“更新源设置”可在线修改启用状态、Gitea 地址、`owner/repository`、正式版/预览版通道、私有仓库 Token 和 HTTP 测试开关。设置原子写回 `/etc/kaidi-erp/erp.env`,只允许修改 `OA_UPDATE_*` 白名单,不会覆盖 PostgreSQL 密码等其他配置;Token 永不通过 API 回显,页面只显示“Token 已配置”。公开仓库无需填写 Token,正式环境应使用 HTTPS。 + +更新过程如下: 1. 从 Gitea 读取 stable channel 的最新 Release。 2. 下载归档、`SHA256SUMS` 和 Ed25519 签名(Release 里的独立安装器资产只用于首次安装)。 @@ -295,7 +307,7 @@ location / { 同一安装目录使用操作系统文件锁,不能并发执行两个更新任务。也可以手工触发: ```bash -/opt/kaidi-erp/current/bin/erp-update install 0.2.0 +/opt/kaidi-erp/current/bin/erp-update install 0.3.5 ``` 应用回滚不等于数据库回滚。包含不可逆 Flyway 迁移的版本必须先保证旧应用仍兼容新结构,并建议在安装配置中启用: @@ -336,8 +348,8 @@ base64 < ~/.config/kaidi-erp/release-signing-key.pem | tr -d '\n' ```bash git switch main git pull --ff-only origin main -git tag -a v0.2.0 -m 'Kaidi ERP v0.2.0' -git push origin v0.2.0 +git tag -a v0.3.5 -m 'Kaidi ERP v0.3.5' +git push origin v0.3.5 ``` 发布完成后必须确认 Release 页面存在四个资产,并使用仓库中的 `distribution/release-public-key.pem` 验证签名。私钥与该公钥不匹配时打包脚本会直接失败。 @@ -346,7 +358,7 @@ git push origin v0.2.0 ```bash ERP_RELEASE_PRIVATE_KEY_FILE="$HOME/.config/kaidi-erp/release-signing-key.pem" \ - bash scripts/package-release.sh 0.2.0 + bash scripts/package-release.sh 0.3.5 ``` ## 配置参考 @@ -365,6 +377,7 @@ ERP_RELEASE_PRIVATE_KEY_FILE="$HOME/.config/kaidi-erp/release-signing-key.pem" \ | `OA_UPDATE_CHANNEL` | 更新通道 | `stable` | | `OA_UPDATE_TOKEN` | 私有仓库下载令牌 | 空;公开仓库不需要 | | `OA_UPDATE_ALLOW_INSECURE_HTTP` | 允许 HTTP 更新地址 | `false` | +| `ERP_PUBLIC_URL` | 首次安装向导的公网 URL,等价于 `--public-url` | 自动探测公网 IP | | `OA_SEED_DEMO` | 是否生成演示数据 | 正式安装为 `false` | | `ERP_UPDATE_BACKUP_MODE` | 更新前数据库备份 | `none`,可设 `pg_dump` | | `ERP_UPDATE_HEALTH_TIMEOUT_SECONDS` | 新旧版本健康检查超时 | `120` | @@ -438,7 +451,7 @@ Gitea 仓库尚未发布首个可安装版本,或 Release 缺少四个必需 ### 反向代理后提示“响应非 JSON (HTTP 403)” -应用自身的 401/403 权限错误始终是 JSON。该提示表示 Nginx、WAF 或 Spring CORS 层提前返回了纯文本/HTML。先确认代理目标为 `http://127.0.0.1:8090`,再按“HTTPS 反向代理”一节补齐 `Host` 和 `X-Forwarded-*` 请求头;响应正文为 `Invalid CORS request` 时即可确认是协议/主机转发不完整。 +应用自身的 401/403 权限错误始终是 JSON。该提示表示 Nginx、WAF 或 Spring CORS 层提前返回了纯文本/HTML。先确认代理目标为正式安装端口(默认 `http://127.0.0.1:8091`),再按“HTTPS 反向代理”一节补齐 `Host` 和 `X-Forwarded-*` 请求头;响应正文为 `Invalid CORS request` 时即可确认是协议/主机转发不完整。 ### 签名验证失败 diff --git a/distribution/bin/erp-update b/distribution/bin/erp-update index 974f3a4..2725c5d 100755 --- a/distribution/bin/erp-update +++ b/distribution/bin/erp-update @@ -44,14 +44,23 @@ import json, os, sys, tempfile from datetime import datetime, timezone path, phase, progress, message, version, error = sys.argv[1:] -payload = { +payload = {} +try: + if os.path.getsize(path) <= 64 * 1024: + with open(path, encoding="utf-8") as handle: + existing = json.load(handle) + if isinstance(existing, dict): + payload = existing +except (OSError, json.JSONDecodeError, TypeError, ValueError): + pass +payload.update({ "phase": phase, "progress": int(progress), "message": message, "version": version or None, "error": error or None, "updatedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), -} +}) parent = os.path.dirname(path) or "." fd, tmp = tempfile.mkstemp(prefix=".update-state-", dir=parent, text=True) try: diff --git a/docs/online-install-and-update.md b/docs/online-install-and-update.md index 9efc39b..1d4daf0 100644 --- a/docs/online-install-and-update.md +++ b/docs/online-install-and-update.md @@ -27,8 +27,8 @@ base64 < ~/.config/kaidi-erp/release-signing-key.pem | tr -d '\n' 发布稳定版本: ```bash -git tag v0.2.0 -git push origin v0.2.0 +git tag v0.3.5 +git push origin v0.3.5 ``` ## 首次安装 @@ -44,23 +44,26 @@ curl -fsSL https://git.example.com/awaioi/ERP/raw/branch/main/install.sh \ 命令行只检查并安装 Java 17+、curl、tar、Python 3 和 OpenSSL 3,然后启动独立安装器并输出带一次性 token 的网页地址。数据库、管理员和密码全部在首次网页向导填写;安装器会真实测试 PostgreSQL 15+、数据库所有权、`public` schema 建表权限和 `pg_trgm` 所有权,迁移完成并确认正式服务健康后才写 `install.lock`,随后物理删除安装器目录。 +安装地址优先使用 `--public-url`(或 `ERP_PUBLIC_URL`);未指定时依次尝试探测公网 IP、回退局域网 IP,并始终额外输出 `Local URL`。公网服务器建议显式传入例如 `--public-url http://38.76.196.225:8091`。参数支持 HTTPS 域名、端口、路径和已有查询参数,安装器会安全追加 token,不会用局域网 IP 覆盖显式公网地址。 + 目标 PostgreSQL 必须是专用空数据库,网页中填写的账号必须是该数据库的所有者。只拥有连接权限的账号会在网页连接测试阶段被拒绝,不再等到 Flyway 迁移后才显示笼统错误。 Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL 或精简系统只能显式使用 `--no-service` 做开发验收,在线更新也会保持关闭。 当前 `http://38.76.196.225:10099` 仅用于开发测试,安装器必须同时传入 `--allow-insecure`。在没有 HTTPS 的情况下,必须从固定 tag 下载引导脚本并验证本版本记录的 SHA-256,禁止把可变的 `main` 分支脚本直接管道给 root。HTTP 仍会暴露请求、Release 元数据和 Gitea token,不应作为生产部署方式。 -当前 `v0.3.1` 安装命令: +当前 `v0.3.5` 安装命令: ```bash ( set -e tmp="$(mktemp)" trap 'rm -f -- "$tmp"' EXIT - curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.1/install.sh -o "$tmp" - printf '%s %s\n' '89a3c45e76f500c9475cb596ea29e3518bfafdc59f36dd3c7b316ed3cdd0448c' "$tmp" | sha256sum -c - + curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.5/install.sh -o "$tmp" + printf '%s %s\n' '76917da519895bec815bb5492b12f0894e8cff26d6754cb9b2569b45e52ed83c' "$tmp" | sha256sum -c - sudo -E bash "$tmp" --gitea-url http://38.76.196.225:10099 \ - --repository awaioi/ERP --allow-insecure + --repository awaioi/ERP --version 0.3.5 \ + --public-url http://38.76.196.225:8091 --allow-insecure ) ``` @@ -71,7 +74,7 @@ Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL set -e tmp="$(mktemp)" trap 'rm -f -- "$tmp"' EXIT - curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.1/uninstall.sh -o "$tmp" + curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.5/uninstall.sh -o "$tmp" printf '%s %s\n' '98c56fed2fd4d01874e4ab5a1a4f3ec42ec3e29b315ffd87385a95488d587546' "$tmp" | sha256sum -c - sudo -E bash "$tmp" --purge-database --yes ) @@ -81,7 +84,11 @@ Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL ## 在线更新 -管理员进入“应用定制平台 -> 系统更新”,点击“检查更新”,确认版本和 Release notes 后执行更新。后端启动独立更新助手,更新助手会: +管理员可从顶部工具栏、用户菜单、手机导航抽屉或“应用定制平台 -> 系统更新”进入 `/appdev/update`。入口只对 `ADMIN` 角色显示;发现新版本时顶部和手机入口会显示版本提示。 + +页面的“更新源设置”可保存启用状态、Gitea 地址、`owner/repository`、正式版/预览版通道、可选 Token 和 HTTP 测试开关。配置原子写回 `ERP_CONFIG_FILE` 指向的 `erp.env`,只修改 `OA_UPDATE_*` 白名单;数据库密码等字段保持不变。Token 不会通过 API 回显,公开仓库可以留空。 + +点击“保存并检查”或“检查更新”后,页面会展示当前/最新版本、发布日期、Release 更新日志、发布资产和签名校验方式。安装时持续显示下载、验签、安装、重启和回滚进度;服务重启短暂断开期间页面会自动重连。后端启动独立更新助手,更新助手会: 1. 下载正式归档、`SHA256SUMS` 和签名并验证 Ed25519/SHA-256;独立安装器资产只在首次安装使用。 2. 拒绝路径穿越、符号链接和结构不完整的安装包。 @@ -92,7 +99,7 @@ Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL 更新过程使用操作系统文件锁,同一安装目录同时只允许一个更新任务。手动触发可执行: ```bash -/opt/kaidi-erp/current/bin/erp-update install 0.2.0 +/opt/kaidi-erp/current/bin/erp-update install 0.3.5 ``` 在线更新依赖安装器注册的 systemd 或 launchd 服务来拉起新旧版本。使用 `--no-service` 时后台更新默认关闭;如由其他进程管理器接管,须先确认它会在 ERP 进程退出后自动重启,再手工启用 `OA_UPDATE_ENABLED=true`。健康检查默认最多等待 120 秒、每 2 秒轮询一次,可分别通过 `ERP_UPDATE_HEALTH_TIMEOUT_SECONDS` 和 `ERP_UPDATE_HEALTH_POLL_SECONDS` 调整。 diff --git a/install.sh b/install.sh index 6d4e030..6d9897d 100755 --- a/install.sh +++ b/install.sh @@ -8,6 +8,7 @@ INSTALL_ROOT="${ERP_INSTALL_ROOT:-}" ALLOW_INSECURE="${ERP_UPDATE_ALLOW_INSECURE_HTTP:-0}" NO_SERVICE="${ERP_INSTALL_NO_SERVICE:-0}" TOKEN="${ERP_GITEA_TOKEN:-${OA_UPDATE_TOKEN:-}}" +PUBLIC_URL="${ERP_PUBLIC_URL:-}" PUBLIC_KEY='-----BEGIN PUBLIC KEY----- MCowBQYDK2VwAyEAaErhcY8WZIZvPILmYnfjndVBAdOuWkvhaoHIWqNNdxI= -----END PUBLIC KEY-----' @@ -23,6 +24,7 @@ Usage: install.sh [options] --repository O/R Release repository (default: awaioi/ERP) --version VERSION Install one exact stable release --install-root PATH Override installation directory + --public-url URL Public URL used for the one-time web setup link --allow-insecure Development only: allow plain HTTP release URLs --no-service Start without systemd/launchd; online update stays disabled EOF @@ -34,6 +36,7 @@ while [[ $# -gt 0 ]]; do --repository) [[ $# -ge 2 ]] || fail '--repository requires a value'; REPOSITORY="$2"; shift 2 ;; --version) [[ $# -ge 2 ]] || fail '--version requires a value'; REQUESTED_VERSION="$2"; shift 2 ;; --install-root) [[ $# -ge 2 ]] || fail '--install-root requires a value'; INSTALL_ROOT="$2"; shift 2 ;; + --public-url) [[ $# -ge 2 ]] || fail '--public-url requires a value'; PUBLIC_URL="$2"; shift 2 ;; --allow-insecure) ALLOW_INSECURE=1; shift ;; --no-service) NO_SERVICE=1; shift ;; -h|--help) usage; exit 0 ;; @@ -519,6 +522,74 @@ detect_lan_address() { printf '%s' "${address:-127.0.0.1}" } +is_ip_address() { + python3 - "$1" <<'PY' >/dev/null 2>&1 +import ipaddress, sys +ipaddress.ip_address(sys.argv[1]) +PY +} + +detect_public_address() { + local endpoint address + for endpoint in \ + https://api.ipify.org \ + https://ifconfig.me/ip \ + https://icanhazip.com; do + address="$(curl --silent --show-error --fail --location \ + --proto '=https' --proto-redir '=https' \ + --connect-timeout 3 --max-time 5 "$endpoint" 2>/dev/null \ + | tr -d '[:space:]' | head -c 128 || true)" + if [[ -n "$address" ]] && is_ip_address "$address"; then + printf '%s' "$address" + return 0 + fi + done + return 1 +} + +normalize_public_url() { + python3 - "$1" <<'PY' +import sys +from urllib.parse import urlsplit, urlunsplit + +value = sys.argv[1].strip() +try: + parsed = urlsplit(value) + port = parsed.port +except ValueError: + raise SystemExit("invalid public URL") +if parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname: + raise SystemExit("public URL must use http or https") +if parsed.username is not None or parsed.password is not None: + raise SystemExit("public URL must not contain credentials") +if port is not None and not 1 <= port <= 65535: + raise SystemExit("invalid public URL port") +path = parsed.path or "/" +print(urlunsplit((parsed.scheme.lower(), parsed.netloc, path, parsed.query, parsed.fragment))) +PY +} + +ip_setup_base_url() { + python3 - "$1" "$2" <<'PY' +import ipaddress, sys +address = ipaddress.ip_address(sys.argv[1]) +host = f"[{address}]" if address.version == 6 else str(address) +print(f"http://{host}:{int(sys.argv[2])}/") +PY +} + +append_setup_token() { + python3 - "$1" "$2" <<'PY' +import sys +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +parsed = urlsplit(sys.argv[1]) +query = [(key, value) for key, value in parse_qsl(parsed.query, keep_blank_values=True) if key != "token"] +query.append(("token", sys.argv[2])) +print(urlunsplit((parsed.scheme, parsed.netloc, parsed.path or "/", urlencode(query), parsed.fragment))) +PY +} + main() { detect_platform validate_service_manager @@ -531,9 +602,18 @@ main() { fi [[ -n "$GITEA_BASE_URL" ]] || fail 'Gitea URL is required; use --gitea-url or ERP_GITEA_BASE_URL' validate_download_url "$GITEA_BASE_URL" + if [[ -n "$PUBLIC_URL" ]]; then + case "$PUBLIC_URL" in + http://*|https://*) ;; + *) fail 'public URL must start with http:// or https://' ;; + esac + fi say "Detected $PLATFORM/$ARCH" check_and_install_dependencies + if [[ -n "$PUBLIC_URL" ]]; then + PUBLIC_URL="$(normalize_public_url "$PUBLIC_URL")" || fail 'invalid public URL' + fi TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kaidi-erp-install.XXXXXX")" create_curl_config say 'Downloading and verifying the signed release...' @@ -545,11 +625,24 @@ main() { start_service wait_for_installer - local port="${ERP_SERVER_PORT:-8091}" address - address="$(detect_lan_address)" + local port="${ERP_SERVER_PORT:-8091}" lan_address public_address setup_base local_base lan_base + lan_address="$(detect_lan_address)" + local_base="http://127.0.0.1:${port}/" + lan_base="$(ip_setup_base_url "$lan_address" "$port")" + if [[ -n "$PUBLIC_URL" ]]; then + setup_base="$PUBLIC_URL" + elif public_address="$(detect_public_address)"; then + setup_base="$(ip_setup_base_url "$public_address" "$port")" + else + setup_base="$lan_base" + say 'Public IP detection was unavailable; using the LAN address' + fi say "Kaidi ERP $VERSION installer is running" - say "Setup URL: http://${address}:${port}/?token=${SETUP_TOKEN}" - say "Local URL: http://127.0.0.1:${port}/?token=${SETUP_TOKEN}" + say "Setup URL: $(append_setup_token "$setup_base" "$SETUP_TOKEN")" + say "Local URL: $(append_setup_token "$local_base" "$SETUP_TOKEN")" + if [[ "$lan_address" != "127.0.0.1" && "$lan_base" != "$setup_base" ]]; then + say "LAN URL: $(append_setup_token "$lan_base" "$SETUP_TOKEN")" + fi say 'Complete PostgreSQL and administrator setup in the browser. The installer will remove itself after the formal service is healthy.' } diff --git a/oa-backend/src/main/java/com/kaidi/oa/config/UpdateProperties.java b/oa-backend/src/main/java/com/kaidi/oa/config/UpdateProperties.java index 1c9e6f1..37318de 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/config/UpdateProperties.java +++ b/oa-backend/src/main/java/com/kaidi/oa/config/UpdateProperties.java @@ -15,6 +15,7 @@ public class UpdateProperties { private String token = ""; private String helperCommand = ""; private String stateFile = "./runtime/update-state.json"; + private String configFile = ""; private boolean allowInsecureHttp; private int requestTimeoutSeconds = 15; @@ -74,6 +75,14 @@ public class UpdateProperties { this.stateFile = stateFile; } + public String getConfigFile() { + return configFile; + } + + public void setConfigFile(String configFile) { + this.configFile = configFile; + } + public boolean isAllowInsecureHttp() { return allowInsecureHttp; } diff --git a/oa-backend/src/main/java/com/kaidi/oa/service/SystemUpdateConfigService.java b/oa-backend/src/main/java/com/kaidi/oa/service/SystemUpdateConfigService.java new file mode 100644 index 0000000..5b10ba8 --- /dev/null +++ b/oa-backend/src/main/java/com/kaidi/oa/service/SystemUpdateConfigService.java @@ -0,0 +1,256 @@ +package com.kaidi.oa.service; + +import com.kaidi.oa.common.ApiException; +import com.kaidi.oa.config.UpdateProperties; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.PosixFilePermission; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** Persists the administrator-managed updater source without exposing credentials. */ +@Service +public class SystemUpdateConfigService { + + private static final long MAX_CONFIG_BYTES = 1024 * 1024; + private static final Pattern REPOSITORY_PATTERN = Pattern.compile( + "^[A-Za-z0-9][A-Za-z0-9._-]{0,99}/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$"); + private static final Pattern TOKEN_PATTERN = Pattern.compile("^[A-Za-z0-9._-]{1,512}$"); + private static final Set CHANNELS = Set.of("stable", "preview"); + + private final UpdateProperties properties; + + public SystemUpdateConfigService(UpdateProperties properties) { + this.properties = properties; + } + + public synchronized UpdateConfig get() { + return new UpdateConfig( + properties.isEnabled(), + value(properties.getGiteaBaseUrl()), + value(properties.getRepository()), + normalizedChannel(properties.getChannel()), + properties.getToken() != null && !properties.getToken().isBlank(), + properties.isAllowInsecureHttp() + ); + } + + public synchronized UpdateConfig save(UpdateConfigRequest request) { + if (request == null) { + throw new ApiException(400, "更新配置不能为空"); + } + + String baseUrl = normalizeBaseUrl(request.giteaBaseUrl(), request.allowInsecureHttp(), request.enabled()); + String repository = normalizeRepository(request.repository()); + String channel = normalizedChannel(request.channel()); + String token = value(properties.getToken()); + String suppliedToken = value(request.token()); + if (request.clearToken() && !suppliedToken.isBlank()) { + throw new ApiException(400, "不能同时清除并设置 Gitea Token"); + } + if (request.clearToken()) { + token = ""; + } else if (!suppliedToken.isBlank()) { + if (!TOKEN_PATTERN.matcher(suppliedToken).matches()) { + throw new ApiException(400, "Gitea Token 格式无效"); + } + token = suppliedToken; + } + + LinkedHashMap changes = new LinkedHashMap<>(); + changes.put("OA_UPDATE_ENABLED", Boolean.toString(request.enabled())); + changes.put("OA_UPDATE_GITEA_BASE_URL", baseUrl); + changes.put("OA_UPDATE_REPOSITORY", repository); + changes.put("OA_UPDATE_CHANNEL", channel); + changes.put("OA_UPDATE_TOKEN", token); + changes.put("OA_UPDATE_ALLOW_INSECURE_HTTP", Boolean.toString(request.allowInsecureHttp())); + persist(changes); + + properties.setEnabled(request.enabled()); + properties.setGiteaBaseUrl(baseUrl); + properties.setRepository(repository); + properties.setChannel(channel); + properties.setToken(token); + properties.setAllowInsecureHttp(request.allowInsecureHttp()); + return get(); + } + + private void persist(Map changes) { + String configuredPath = value(properties.getConfigFile()); + if (configuredPath.isBlank()) { + throw new ApiException(503, "当前运行方式未提供可写的 ERP_CONFIG_FILE"); + } + Path target = Path.of(configuredPath).toAbsolutePath().normalize(); + try { + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || !Files.isWritable(target)) { + throw new ApiException(503, "ERP 配置文件不存在或不可写"); + } + if (Files.size(target) > MAX_CONFIG_BYTES) { + throw new ApiException(503, "ERP 配置文件异常过大"); + } + BasicFileAttributes before = Files.readAttributes( + target, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + List original = Files.readAllLines(target, StandardCharsets.UTF_8); + List updated = replaceWhitelistedSettings(original, changes); + byte[] content = (String.join("\n", updated) + "\n").getBytes(StandardCharsets.UTF_8); + atomicWrite(target, content, before); + } catch (ApiException exception) { + throw exception; + } catch (IOException | RuntimeException exception) { + throw new ApiException(503, "无法保存更新配置,请检查 ERP 配置文件权限"); + } + } + + private List replaceWhitelistedSettings(List original, Map changes) { + List result = new ArrayList<>(original.size() + changes.size()); + Set written = new java.util.HashSet<>(); + for (String line : original) { + String matched = null; + for (String key : changes.keySet()) { + if (line.startsWith(key + "=")) { + matched = key; + break; + } + } + if (matched == null) { + result.add(line); + } else if (written.add(matched)) { + result.add(setting(matched, changes.get(matched))); + } + } + for (Map.Entry entry : changes.entrySet()) { + if (written.add(entry.getKey())) { + result.add(setting(entry.getKey(), entry.getValue())); + } + } + return result; + } + + private void atomicWrite(Path target, byte[] content, BasicFileAttributes before) throws IOException { + Path parent = target.getParent(); + if (parent == null || !Files.isDirectory(parent, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("configuration parent is unavailable"); + } + Path temporary = Files.createTempFile(parent, ".erp.env-", ".tmp"); + try { + try { + Set permissions = Files.getPosixFilePermissions( + target, LinkOption.NOFOLLOW_LINKS); + Files.setPosixFilePermissions(temporary, permissions); + } catch (UnsupportedOperationException ignored) { + // Production targets POSIX systems; keep local non-POSIX tests portable. + } + try (FileChannel channel = FileChannel.open( + temporary, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) { + channel.write(ByteBuffer.wrap(content)); + channel.force(true); + } + BasicFileAttributes current = Files.readAttributes( + target, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (current.size() != before.size() || !current.lastModifiedTime().equals(before.lastModifiedTime())) { + throw new IOException("configuration changed concurrently"); + } + try { + Files.move(temporary, target, + StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException exception) { + throw new IOException("atomic configuration replacement is unavailable", exception); + } + } finally { + Files.deleteIfExists(temporary); + } + } + + private static String normalizeBaseUrl(String input, boolean allowInsecureHttp, boolean required) { + String value = value(input).replaceAll("/+$", ""); + if (value.isBlank()) { + if (required) { + throw new ApiException(400, "启用在线更新时必须填写 Gitea 地址"); + } + return ""; + } + URI uri; + try { + uri = URI.create(value); + } catch (IllegalArgumentException exception) { + throw new ApiException(400, "Gitea 地址格式无效"); + } + String scheme = value(uri.getScheme()).toLowerCase(Locale.ROOT); + if (!("https".equals(scheme) || (allowInsecureHttp && "http".equals(scheme)))) { + throw new ApiException(400, "Gitea 地址必须使用 HTTPS"); + } + if (uri.getHost() == null || uri.getUserInfo() != null || uri.getRawQuery() != null + || uri.getRawFragment() != null || uri.getPort() > 65535) { + throw new ApiException(400, "Gitea 地址格式无效"); + } + return value; + } + + private static String normalizeRepository(String input) { + String repository = value(input); + if (!REPOSITORY_PATTERN.matcher(repository).matches()) { + throw new ApiException(400, "仓库格式必须为 owner/repository"); + } + return repository; + } + + private static String normalizedChannel(String input) { + String channel = value(input).toLowerCase(Locale.ROOT); + if (channel.isBlank()) { + return "stable"; + } + if (!CHANNELS.contains(channel)) { + throw new ApiException(400, "更新通道只支持 stable 或 preview"); + } + return channel; + } + + private static String setting(String key, String value) { + if (value.indexOf('\0') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0) { + throw new ApiException(400, "更新配置不能包含换行符"); + } + return key + "='" + value.replace("'", "'\\''") + "'"; + } + + private static String value(String value) { + return value == null ? "" : value.trim(); + } + + public record UpdateConfig( + boolean enabled, + String giteaBaseUrl, + String repository, + String channel, + boolean tokenConfigured, + boolean allowInsecureHttp + ) { + } + + public record UpdateConfigRequest( + boolean enabled, + String giteaBaseUrl, + String repository, + String channel, + String token, + boolean clearToken, + boolean allowInsecureHttp + ) { + } +} diff --git a/oa-backend/src/main/java/com/kaidi/oa/service/SystemUpdateService.java b/oa-backend/src/main/java/com/kaidi/oa/service/SystemUpdateService.java index dd653b5..3108a47 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/service/SystemUpdateService.java +++ b/oa-backend/src/main/java/com/kaidi/oa/service/SystemUpdateService.java @@ -21,13 +21,16 @@ import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.PosixFilePermission; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Objects; +import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Matcher; @@ -43,6 +46,10 @@ public class SystemUpdateService { + "(?:-([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?" + "(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$"); private static final long MAX_STATE_BYTES = 64 * 1024; + private static final int MAX_RELEASE_NOTES_CHARS = 32_000; + private static final int MAX_RELEASE_ASSETS = 32; + private static final int MAX_ASSET_NAME_CHARS = 255; + private static final int MAX_ASSET_URL_CHARS = 4_096; private final UpdateProperties properties; private final ObjectMapper objectMapper; @@ -76,12 +83,33 @@ public class SystemUpdateService { public UpdateStatus status() { UpdateStatus persisted = readHelperState(); - return persisted == null ? state.get() : persisted; + if (persisted != null) { + state.set(persisted); + return persisted; + } + return state.get(); + } + + public void ensureConfigurationMutable() { + if (isActivePhase(status().phase())) { + throw new ApiException(409, "更新进行中,暂时不能修改更新源"); + } + } + + public synchronized void configurationChanged() { + UpdateStatus idle = UpdateStatus.idle(isConfigured(), currentVersion); + state.set(idle); + persistState(idle); } public synchronized UpdateStatus check() { requireConfigured(); - state.set(state.get().withPhase(UpdatePhase.CHECKING, 5, "正在检查 Gitea Release")); + if (isActivePhase(status().phase())) { + throw new ApiException(409, "更新任务正在执行"); + } + UpdateStatus checking = state.get().withPhase(UpdatePhase.CHECKING, 5, "正在检查 Gitea Release"); + state.set(checking); + persistState(checking); try { ReleaseInfo release = fetchLatestRelease(); boolean available = compareVersions(release.version(), currentVersion) > 0; @@ -100,9 +128,12 @@ public class SystemUpdateService { null ); state.set(checked); + persistState(checked); return checked; } catch (RuntimeException e) { - state.set(state.get().failed(safeMessage(e))); + UpdateStatus failed = state.get().failed(safeMessage(e)); + state.set(failed); + persistState(failed); throw e; } } @@ -150,6 +181,7 @@ public class SystemUpdateService { UpdateStatus starting = checked.withPhase(UpdatePhase.STARTING, 1, "更新助手已启动"); state.set(starting); + persistState(starting); process.onExit().thenAccept(completed -> { installRunning.set(false); if (completed.exitValue() != 0) { @@ -182,6 +214,7 @@ public class SystemUpdateService { try { HttpResponse response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + requireSameOrigin(response.uri(), configuredBaseUri(), "Gitea Release API"); if (response.statusCode() == 404) { throw new ApiException(404, "Gitea 尚未发布 Release"); } @@ -213,11 +246,21 @@ public class SystemUpdateService { && "stable".equalsIgnoreCase(properties.getChannel())) { throw new ApiException(502, "稳定频道拒绝预发布版本"); } + if (!root.path("assets").isArray() || root.path("assets").size() > MAX_RELEASE_ASSETS) { + throw new ApiException(502, "Release 文件列表无效"); + } List assets = new ArrayList<>(); for (JsonNode node : root.path("assets")) { + String name = text(node, "name"); + String downloadUrl = text(node, "browser_download_url"); + if (name.isBlank() || name.length() > MAX_ASSET_NAME_CHARS + || downloadUrl.isBlank() || downloadUrl.length() > MAX_ASSET_URL_CHARS) { + throw new ApiException(502, "Release 文件信息无效"); + } + validateAssetUrl(downloadUrl); assets.add(new ReleaseAsset( - text(node, "name"), - text(node, "browser_download_url"), + name, + downloadUrl, node.path("size").asLong(0) )); } @@ -234,7 +277,11 @@ public class SystemUpdateService { // An invalid optional timestamp must not hide an otherwise valid release. } } - return new ReleaseInfo(version, text(root, "body"), publishedAt, List.copyOf(assets)); + String notes = text(root, "body"); + if (notes.length() > MAX_RELEASE_NOTES_CHARS) { + notes = notes.substring(0, MAX_RELEASE_NOTES_CHARS) + "\n\n[发布说明过长,已截断]"; + } + return new ReleaseInfo(version, notes, publishedAt, List.copyOf(assets)); } private void requireConfigured() { @@ -244,16 +291,15 @@ public class SystemUpdateService { if (properties.getGiteaBaseUrl() == null || properties.getGiteaBaseUrl().isBlank()) { throw new ApiException(503, "尚未配置 Gitea 地址"); } - URI uri; - try { - uri = URI.create(properties.getGiteaBaseUrl().trim()); - } catch (IllegalArgumentException e) { - throw new ApiException(500, "Gitea 地址格式无效"); - } + URI uri = configuredBaseUri(); String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT); if (!"https".equals(scheme) && !(properties.isAllowInsecureHttp() && "http".equals(scheme))) { throw new ApiException(503, "更新服务器必须使用 HTTPS"); } + String repository = properties.getRepository() == null ? "" : properties.getRepository().trim(); + if (!repository.matches("^[A-Za-z0-9][A-Za-z0-9._-]{0,99}/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$")) { + throw new ApiException(500, "更新仓库配置无效"); + } } private UpdateStatus readHelperState() { @@ -272,10 +318,15 @@ public class SystemUpdateService { String helperVersion = blankToNull(text(root, "version")); boolean updateAvailable = helperVersion != null && phase != UpdatePhase.SUCCEEDED + && phase != UpdatePhase.UP_TO_DATE && compareVersions(helperVersion, currentVersion) > 0; Instant helperUpdatedAt = parseInstant(text(root, "updatedAt")); + Instant publishedAt = parseInstant(text(root, "publishedAt")); + String releaseNotes = root.path("releaseNotes").isTextual() + ? root.path("releaseNotes").asText() : memory.releaseNotes(); + List assets = readAssets(root.path("assets"), memory.assets()); return new UpdateStatus( - properties.isEnabled(), + isConfigured(), currentVersion, helperVersion, updateAvailable, @@ -283,9 +334,9 @@ public class SystemUpdateService { Math.max(0, Math.min(100, root.path("progress").asInt(0))), text(root, "message"), helperUpdatedAt == null ? memory.checkedAt() : helperUpdatedAt, - memory.publishedAt(), - memory.releaseNotes(), - memory.assets(), + publishedAt == null ? memory.publishedAt() : publishedAt, + releaseNotes, + assets, blankToNull(text(root, "error")) ); } catch (Exception e) { @@ -300,6 +351,137 @@ public class SystemUpdateService { .toAbsolutePath().normalize(); } + private void persistState(UpdateStatus status) { + Path target = statePath(); + Path parent = target.getParent(); + Path temporary = null; + try { + if (parent != null) { + Files.createDirectories(parent); + } + byte[] content = objectMapper.writeValueAsBytes(java.util.Map.ofEntries( + java.util.Map.entry("phase", status.phase().name()), + java.util.Map.entry("progress", status.progress()), + java.util.Map.entry("message", Objects.requireNonNullElse(status.message(), "")), + java.util.Map.entry("version", Objects.requireNonNullElse(status.latestVersion(), "")), + java.util.Map.entry("error", Objects.requireNonNullElse(status.error(), "")), + java.util.Map.entry("updatedAt", Instant.now().toString()), + java.util.Map.entry("publishedAt", status.publishedAt() == null ? "" : status.publishedAt().toString()), + java.util.Map.entry("releaseNotes", Objects.requireNonNullElse(status.releaseNotes(), "")), + java.util.Map.entry("assets", Objects.requireNonNullElse(status.assets(), List.of())) + )); + if (content.length > MAX_STATE_BYTES) { + throw new IOException("update state is too large"); + } + Path directory = parent == null ? Path.of(".").toAbsolutePath().normalize() : parent; + temporary = Files.createTempFile(directory, ".update-state-", ".tmp"); + Files.write(temporary, content, StandardOpenOption.TRUNCATE_EXISTING); + try { + Files.setPosixFilePermissions(temporary, Set.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.GROUP_READ)); + } catch (UnsupportedOperationException ignored) { + // Keep local non-POSIX tests portable. + } + Files.move(temporary, target, + StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + temporary = null; + } catch (Exception exception) { + log.warn("Unable to persist update state {}: {}", target, exception.getMessage()); + } finally { + if (temporary != null) { + try { + Files.deleteIfExists(temporary); + } catch (IOException ignored) { + // Best effort cleanup only. + } + } + } + } + + private List readAssets(JsonNode node, List fallback) { + if (!node.isArray()) { + return fallback == null ? List.of() : fallback; + } + if (node.size() > MAX_RELEASE_ASSETS) { + return List.of(); + } + List assets = new ArrayList<>(); + for (JsonNode asset : node) { + String name = text(asset, "name"); + String downloadUrl = text(asset, "downloadUrl"); + if (name.isBlank() || downloadUrl.isBlank()) { + continue; + } + validateAssetUrl(downloadUrl); + assets.add(new ReleaseAsset(name, downloadUrl, asset.path("size").asLong(0))); + } + return List.copyOf(assets); + } + + private URI configuredBaseUri() { + URI uri; + try { + uri = URI.create(Objects.requireNonNullElse(properties.getGiteaBaseUrl(), "").trim()); + } catch (IllegalArgumentException exception) { + throw new ApiException(500, "Gitea 地址格式无效"); + } + if (uri.getHost() == null || uri.getUserInfo() != null || uri.getRawQuery() != null + || uri.getRawFragment() != null || uri.getPort() > 65535) { + throw new ApiException(500, "Gitea 地址格式无效"); + } + return uri; + } + + private void validateAssetUrl(String value) { + URI asset; + try { + asset = URI.create(value); + } catch (IllegalArgumentException exception) { + throw new ApiException(502, "Release 文件地址无效"); + } + if (asset.getUserInfo() != null || asset.getRawFragment() != null) { + throw new ApiException(502, "Release 文件地址无效"); + } + requireSameOrigin(asset, configuredBaseUri(), "Release 文件"); + } + + private static void requireSameOrigin(URI actual, URI expected, String label) { + String actualScheme = Objects.requireNonNullElse(actual.getScheme(), "").toLowerCase(Locale.ROOT); + String expectedScheme = Objects.requireNonNullElse(expected.getScheme(), "").toLowerCase(Locale.ROOT); + String actualHost = Objects.requireNonNullElse(actual.getHost(), "").toLowerCase(Locale.ROOT); + String expectedHost = Objects.requireNonNullElse(expected.getHost(), "").toLowerCase(Locale.ROOT); + if (!actualScheme.equals(expectedScheme) || !actualHost.equals(expectedHost) + || effectivePort(actual) != effectivePort(expected)) { + throw new ApiException(502, label + "跳转到了未受信任的服务器"); + } + } + + private static int effectivePort(URI uri) { + if (uri.getPort() >= 0) { + return uri.getPort(); + } + return "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80; + } + + private boolean isConfigured() { + return properties.isEnabled() + && properties.getGiteaBaseUrl() != null + && !properties.getGiteaBaseUrl().isBlank() + && properties.getRepository() != null + && !properties.getRepository().isBlank(); + } + + private static boolean isActivePhase(UpdatePhase phase) { + return phase == UpdatePhase.STARTING + || phase == UpdatePhase.DOWNLOADING + || phase == UpdatePhase.VERIFYING + || phase == UpdatePhase.INSTALLING + || phase == UpdatePhase.RESTARTING + || phase == UpdatePhase.ROLLING_BACK; + } + private static String text(JsonNode node, String field) { JsonNode value = node.path(field); return value.isTextual() ? value.asText().trim() : ""; diff --git a/oa-backend/src/main/java/com/kaidi/oa/web/AuthController.java b/oa-backend/src/main/java/com/kaidi/oa/web/AuthController.java index 06789f7..ffb2756 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/web/AuthController.java +++ b/oa-backend/src/main/java/com/kaidi/oa/web/AuthController.java @@ -4,6 +4,7 @@ import com.kaidi.oa.common.ApiException; import com.kaidi.oa.common.ApiResp; import com.kaidi.oa.domain.SysUser; import com.kaidi.oa.service.AuthService; +import com.kaidi.oa.service.AuthorizationService; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.constraints.NotBlank; import org.springframework.web.bind.annotation.GetMapping; @@ -14,6 +15,7 @@ import org.springframework.web.bind.annotation.RestController; import java.util.LinkedHashMap; import java.util.Map; +import java.util.TreeSet; /** * Authentication endpoints. Issues an opaque token on login; the frontend sends @@ -25,10 +27,13 @@ public class AuthController { private final AuthService authService; private final CurrentUserResolver currentUser; + private final AuthorizationService authorizationService; - public AuthController(AuthService authService, CurrentUserResolver currentUser) { + public AuthController(AuthService authService, CurrentUserResolver currentUser, + AuthorizationService authorizationService) { this.authService = authService; this.currentUser = currentUser; + this.authorizationService = authorizationService; } public record LoginRequest(@NotBlank String loginName, @NotBlank String password) { @@ -65,6 +70,7 @@ public class AuthController { map.put("deptId", user.getDeptId()); map.put("title", user.getTitle()); map.put("email", user.getEmail()); + map.put("roles", new TreeSet<>(authorizationService.roleCodesOf(user.getId()))); return map; } } diff --git a/oa-backend/src/main/java/com/kaidi/oa/web/SystemUpdateController.java b/oa-backend/src/main/java/com/kaidi/oa/web/SystemUpdateController.java index 43b55fd..d41c18a 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/web/SystemUpdateController.java +++ b/oa-backend/src/main/java/com/kaidi/oa/web/SystemUpdateController.java @@ -1,12 +1,16 @@ package com.kaidi.oa.web; import com.kaidi.oa.common.ApiResp; +import com.kaidi.oa.service.SystemUpdateConfigService; +import com.kaidi.oa.service.SystemUpdateConfigService.UpdateConfig; +import com.kaidi.oa.service.SystemUpdateConfigService.UpdateConfigRequest; import com.kaidi.oa.service.SystemUpdateService; import com.kaidi.oa.service.SystemUpdateService.UpdateStatus; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -17,9 +21,24 @@ import org.springframework.web.bind.annotation.RestController; public class SystemUpdateController { private final SystemUpdateService updateService; + private final SystemUpdateConfigService configService; - public SystemUpdateController(SystemUpdateService updateService) { + public SystemUpdateController(SystemUpdateService updateService, SystemUpdateConfigService configService) { this.updateService = updateService; + this.configService = configService; + } + + @GetMapping("/config") + public ApiResp config() { + return ApiResp.ok(configService.get()); + } + + @PutMapping("/config") + public ApiResp saveConfig(@Valid @RequestBody UpdateConfigRequest request) { + updateService.ensureConfigurationMutable(); + UpdateConfig saved = configService.save(request); + updateService.configurationChanged(); + return ApiResp.ok(saved); } @GetMapping("/status") diff --git a/oa-backend/src/main/resources/application.yml b/oa-backend/src/main/resources/application.yml index 0a77272..7726495 100644 --- a/oa-backend/src/main/resources/application.yml +++ b/oa-backend/src/main/resources/application.yml @@ -70,4 +70,5 @@ oa: token: ${OA_UPDATE_TOKEN:} helper-command: ${OA_UPDATE_HELPER_COMMAND:} state-file: ${OA_UPDATE_STATE_FILE:./runtime/update-state.json} + config-file: ${ERP_CONFIG_FILE:} allow-insecure-http: ${OA_UPDATE_ALLOW_INSECURE_HTTP:false} diff --git a/oa-backend/src/test/java/com/kaidi/oa/service/SystemUpdateConfigServiceTest.java b/oa-backend/src/test/java/com/kaidi/oa/service/SystemUpdateConfigServiceTest.java new file mode 100644 index 0000000..42bce13 --- /dev/null +++ b/oa-backend/src/test/java/com/kaidi/oa/service/SystemUpdateConfigServiceTest.java @@ -0,0 +1,98 @@ +package com.kaidi.oa.service; + +import com.kaidi.oa.common.ApiException; +import com.kaidi.oa.config.UpdateProperties; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SystemUpdateConfigServiceTest { + + @TempDir + Path tempDir; + + @Test + void atomicallyUpdatesOnlyUpdaterSettingsAndNeverReturnsToken() throws Exception { + Path configFile = tempDir.resolve("erp.env"); + Files.writeString(configFile, """ + OA_DB_PASSWORD='keep-this-secret' + OA_UPDATE_ENABLED='true' + OA_UPDATE_GITEA_BASE_URL='https://old.example' + OA_UPDATE_REPOSITORY='old/ERP' + OA_UPDATE_CHANNEL='stable' + OA_UPDATE_TOKEN='old-token' + OA_UPDATE_ALLOW_INSECURE_HTTP='false' + """); + UpdateProperties properties = properties(configFile); + properties.setToken("old-token"); + SystemUpdateConfigService service = new SystemUpdateConfigService(properties); + + SystemUpdateConfigService.UpdateConfig saved = service.save( + new SystemUpdateConfigService.UpdateConfigRequest( + true, + "http://38.76.196.225:10099/", + "awaioi/ERP", + "stable", + "new-token_123", + false, + true)); + + String persisted = Files.readString(configFile); + assertThat(persisted).contains("OA_DB_PASSWORD='keep-this-secret'"); + assertThat(persisted).contains("OA_UPDATE_GITEA_BASE_URL='http://38.76.196.225:10099'"); + assertThat(persisted).contains("OA_UPDATE_TOKEN='new-token_123'"); + assertThat(saved.tokenConfigured()).isTrue(); + assertThat(saved.toString()).doesNotContain("new-token_123"); + assertThat(properties.getToken()).isEqualTo("new-token_123"); + } + + @Test + void blankTokenKeepsExistingTokenAndExplicitClearRemovesIt() throws Exception { + Path configFile = tempDir.resolve("erp.env"); + Files.writeString(configFile, "OA_DB_PASSWORD='database-secret'\n"); + UpdateProperties properties = properties(configFile); + properties.setToken("existing-token"); + SystemUpdateConfigService service = new SystemUpdateConfigService(properties); + + service.save(request("" , false)); + assertThat(properties.getToken()).isEqualTo("existing-token"); + assertThat(Files.readString(configFile)).contains("OA_UPDATE_TOKEN='existing-token'"); + + SystemUpdateConfigService.UpdateConfig cleared = service.save(request("", true)); + assertThat(cleared.tokenConfigured()).isFalse(); + assertThat(properties.getToken()).isEmpty(); + assertThat(Files.readString(configFile)).contains("OA_UPDATE_TOKEN=''"); + } + + @Test + void rejectsPlainHttpWithoutExplicitOptIn() throws Exception { + Path configFile = tempDir.resolve("erp.env"); + Files.writeString(configFile, "OA_UPDATE_ENABLED='false'\n"); + SystemUpdateConfigService service = new SystemUpdateConfigService(properties(configFile)); + + assertThatThrownBy(() -> service.save(new SystemUpdateConfigService.UpdateConfigRequest( + true, "http://gitea.example", "awaioi/ERP", "stable", "", false, false))) + .isInstanceOf(ApiException.class) + .hasMessageContaining("HTTPS"); + } + + private UpdateProperties properties(Path configFile) { + UpdateProperties properties = new UpdateProperties(); + properties.setEnabled(true); + properties.setGiteaBaseUrl("https://old.example"); + properties.setRepository("awaioi/ERP"); + properties.setChannel("stable"); + properties.setConfigFile(configFile.toString()); + return properties; + } + + private SystemUpdateConfigService.UpdateConfigRequest request(String token, boolean clearToken) { + return new SystemUpdateConfigService.UpdateConfigRequest( + true, "https://gitea.example", "awaioi/ERP", "stable", token, clearToken, false); + } +} diff --git a/oa-backend/src/test/java/com/kaidi/oa/service/SystemUpdateServiceTest.java b/oa-backend/src/test/java/com/kaidi/oa/service/SystemUpdateServiceTest.java index 0e84f7c..ba28c70 100644 --- a/oa-backend/src/test/java/com/kaidi/oa/service/SystemUpdateServiceTest.java +++ b/oa-backend/src/test/java/com/kaidi/oa/service/SystemUpdateServiceTest.java @@ -5,6 +5,7 @@ import com.kaidi.oa.common.ApiException; import com.kaidi.oa.config.UpdateProperties; import com.sun.net.httpserver.HttpServer; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.info.BuildProperties; @@ -22,6 +23,9 @@ import static org.mockito.Mockito.when; class SystemUpdateServiceTest { + @TempDir + Path tempDir; + @Test void comparesReleaseVersionsWithoutLexicographicMistakes() { assertThat(SystemUpdateService.compareVersions("v0.10.0", "0.9.9")).isPositive(); @@ -38,7 +42,8 @@ class SystemUpdateServiceTest { void checksLatestGiteaReleaseAndRequiresSignedAssetSet() throws Exception { HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); server.createContext("/api/v1/repos/awaioi/ERP/releases/latest", exchange -> { - byte[] body = """ + String origin = "http://127.0.0.1:" + exchange.getLocalAddress().getPort(); + byte[] body = (""" { "tag_name": "v0.2.0", "body": "PostgreSQL production release", @@ -46,12 +51,12 @@ class SystemUpdateServiceTest { "prerelease": false, "published_at": "2026-08-03T10:00:00Z", "assets": [ - {"name":"kaidi-erp-0.2.0.tar.gz","browser_download_url":"https://example.test/app","size":123}, - {"name":"SHA256SUMS","browser_download_url":"https://example.test/sums","size":64}, - {"name":"SHA256SUMS.sig","browser_download_url":"https://example.test/sig","size":64} + {"name":"kaidi-erp-0.2.0.tar.gz","browser_download_url":"%s/app","size":123}, + {"name":"SHA256SUMS","browser_download_url":"%s/sums","size":64}, + {"name":"SHA256SUMS.sig","browser_download_url":"%s/sig","size":64} ] } - """.getBytes(StandardCharsets.UTF_8); + """).formatted(origin, origin, origin).getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().add("Content-Type", "application/json"); exchange.sendResponseHeaders(200, body.length); exchange.getResponseBody().write(body); @@ -74,6 +79,68 @@ class SystemUpdateServiceTest { } } + @Test + void persistsReleaseMetadataAcrossServiceRestart() throws Exception { + Path stateFile = Files.createTempFile("system-update-metadata-", ".json"); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/api/v1/repos/awaioi/ERP/releases/latest", exchange -> { + String origin = "http://127.0.0.1:" + exchange.getLocalAddress().getPort(); + byte[] body = (""" + {"tag_name":"v0.4.0","body":"Fix updater UI","draft":false,"prerelease":false, + "published_at":"2026-08-04T09:30:00Z","assets":[ + {"name":"kaidi-erp-0.4.0.tar.gz","browser_download_url":"%s/app","size":123}, + {"name":"SHA256SUMS","browser_download_url":"%s/sums","size":64}, + {"name":"SHA256SUMS.sig","browser_download_url":"%s/sig","size":64}]} + """).formatted(origin, origin, origin).getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + try { + UpdateProperties properties = configuredProperties(server.getAddress().getPort()); + properties.setStateFile(stateFile.toString()); + SystemUpdateService first = service(properties, "0.3.0"); + first.check(); + + SystemUpdateService restarted = service(properties, "0.3.0"); + SystemUpdateService.UpdateStatus restored = restarted.status(); + + assertThat(restored.releaseNotes()).isEqualTo("Fix updater UI"); + assertThat(restored.publishedAt()).isEqualTo(Instant.parse("2026-08-04T09:30:00Z")); + assertThat(restored.assets()).hasSize(3); + assertThat(restored.updateAvailable()).isTrue(); + } finally { + server.stop(0); + Files.deleteIfExists(stateFile); + } + } + + @Test + void rejectsReleaseAssetsFromAnotherOrigin() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/api/v1/repos/awaioi/ERP/releases/latest", exchange -> { + byte[] body = """ + {"tag_name":"v0.4.0","draft":false,"prerelease":false,"assets":[ + {"name":"kaidi-erp-0.4.0.tar.gz","browser_download_url":"https://evil.example/app","size":123}, + {"name":"SHA256SUMS","browser_download_url":"https://evil.example/sums","size":64}, + {"name":"SHA256SUMS.sig","browser_download_url":"https://evil.example/sig","size":64}]} + """.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + try { + SystemUpdateService service = service(configuredProperties(server.getAddress().getPort()), "0.3.0"); + assertThatThrownBy(service::check) + .isInstanceOf(ApiException.class) + .hasMessageContaining("未受信任"); + } finally { + server.stop(0); + } + } + @Test void rejectsPlainHttpUnlessDevelopmentOverrideIsExplicit() { UpdateProperties properties = configuredProperties(1); @@ -112,12 +179,13 @@ class SystemUpdateServiceTest { } } - private static UpdateProperties configuredProperties(int port) { + private UpdateProperties configuredProperties(int port) { UpdateProperties properties = new UpdateProperties(); properties.setEnabled(true); properties.setAllowInsecureHttp(true); properties.setGiteaBaseUrl("http://127.0.0.1:" + port); properties.setRepository("awaioi/ERP"); + properties.setStateFile(tempDir.resolve("update-state-" + System.nanoTime() + ".json").toString()); return properties; } diff --git a/ofbiz-framework/plugins/modern-ui/app/src/data/oaModules.ts b/ofbiz-framework/plugins/modern-ui/app/src/data/oaModules.ts index d10b76d..ee2c573 100644 --- a/ofbiz-framework/plugins/modern-ui/app/src/data/oaModules.ts +++ b/ofbiz-framework/plugins/modern-ui/app/src/data/oaModules.ts @@ -49,6 +49,7 @@ export type OaMenuItem = { /** page archetype the builder implements: list | form | detail | portal | tree | calendar | board | settings | report */ kind: 'list' | 'form' | 'detail' | 'portal' | 'tree' | 'calendar' | 'board' | 'settings' | 'report' path: string + adminOnly?: boolean } export type OaModule = { @@ -98,7 +99,7 @@ export const oaModules: OaModule[] = [ { key: 'workbench', label: '工作台', kind: 'portal', path: '/appdev/workbench' }, { key: 'appmgr', label: '应用管理中心', kind: 'list', path: '/appdev/appmgr' }, { key: 'ops', label: '运维中心', kind: 'settings', path: '/appdev/ops' }, - { key: 'update', label: '系统更新', kind: 'settings', path: '/appdev/update' }, + { key: 'update', label: '系统更新', kind: 'settings', path: '/appdev/update', adminOnly: true }, { key: 'monitor', label: '监测中心', kind: 'report', path: '/appdev/monitor' }, { key: 'aiassist', label: 'AI 助手', kind: 'portal', path: '/appdev/aiassist' }, { key: 'ruleconfig', label: '联动规则配置', kind: 'list', path: '/appdev/ruleconfig' } diff --git a/ofbiz-framework/plugins/modern-ui/app/src/oa/OaAppShell.vue b/ofbiz-framework/plugins/modern-ui/app/src/oa/OaAppShell.vue index b1fec8d..2510e69 100644 --- a/ofbiz-framework/plugins/modern-ui/app/src/oa/OaAppShell.vue +++ b/ofbiz-framework/plugins/modern-ui/app/src/oa/OaAppShell.vue @@ -26,13 +26,20 @@ import { userShortcuts } from './shortcuts' import { useSession, currentUserName, currentDeptName } from './session' -import { oaApi, type OaMessage } from './api' +import { oaApi, type OaMessage, type SystemUpdateStatus } from './api' const route = useRoute() const router = useRouter() const session = useSession() const userInitial = computed(() => currentUserName.value.slice(0, 1) || '我') +const isAdmin = computed(() => session.user.value?.roles?.includes('ADMIN') === true) +const visibleModules = computed(() => oaModules + .map((module) => ({ + ...module, + children: module.children.filter((child) => !child.adminOnly || isAdmin.value) + })) + .filter((module) => module.children.length > 0)) async function handleLogout() { await session.logout() ElMessage.success('已退出登录') @@ -42,6 +49,10 @@ async function handleLogout() { const unreadCount = ref(0) const messages = ref([]) let unreadTimer: ReturnType | undefined +let updateTimer: ReturnType | undefined +const updateStatus = ref(null) +const updateAvailable = computed(() => updateStatus.value?.updateAvailable === true) +const updateVersion = computed(() => updateStatus.value?.latestVersion || '') async function loadUnread() { try { @@ -58,6 +69,14 @@ async function loadMessages() { messages.value = [] } } +async function loadUpdateStatus() { + if (!isAdmin.value) return + try { + updateStatus.value = await oaApi.getSystemUpdateStatus() + } catch { + // The updater must never block the application shell. + } +} async function readAllMessages() { try { await oaApi.markAllMessagesRead() @@ -108,11 +127,14 @@ function msgTagType(type: string): 'warning' | 'danger' | 'success' | 'info' { onMounted(() => { void loadUnread() + void loadUpdateStatus() // 轻量轮询未读数(30s),让铃铛徽标随新待办/退回/办结即时更新。 unreadTimer = setInterval(() => void loadUnread(), 30000) + updateTimer = setInterval(() => void loadUpdateStatus(), 10 * 60 * 1000) }) onBeforeUnmount(() => { if (unreadTimer) clearInterval(unreadTimer) + if (updateTimer) clearInterval(updateTimer) }) const globalQuery = ref('') @@ -157,14 +179,14 @@ const currentSpaceLabel = computed( // The active top module is whichever module owns the current route path. const activeModuleId = computed(() => { - const match = oaModules.find((module) => route.path.startsWith(module.path)) + const match = visibleModules.value.find((module) => route.path.startsWith(module.path)) return match?.id || '' }) // ----- 侧边导航(el-menu)----- // 当前高亮项:优先精确命中某子页路径,否则取最长前缀匹配(兼容 /collab/handle?id= 等子路由)。 const activeMenu = computed(() => { - const paths = oaModules.flatMap((module) => module.children.map((child) => child.path)) + const paths = visibleModules.value.flatMap((module) => module.children.map((child) => child.path)) if (paths.includes(route.path)) return route.path const prefixHit = paths .filter((p) => route.path.startsWith(p)) @@ -253,7 +275,7 @@ function runGlobalSearch() {
应用中心
- - - + + + + 系统更新 + + + {{ userInitial }} @@ -327,6 +358,9 @@ function runGlobalSearch() { {{ currentUserName }}{{ currentDeptName ? ' · ' + currentDeptName : '' }} 个人空间 通讯录 + + {{ updateAvailable ? `系统更新 · ${updateVersion}` : '系统更新' }} + 退出登录 @@ -346,7 +380,7 @@ function runGlobalSearch() { unique-opened @select="onMenuSelect" > - +