feat: 增加 Git 标签清单策略和接口
This commit is contained in:
@@ -64,6 +64,14 @@ ADMIN_REFRESH_TOKEN_EXPIRE_DAYS=7
|
|||||||
# 如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。
|
# 如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。
|
||||||
LICENSE_KEY_ENCRYPTION_SECRET=SimCAE_License_Key_Encryption_2026_ChangeMe
|
LICENSE_KEY_ENCRYPTION_SECRET=SimCAE_License_Key_Encryption_2026_ChangeMe
|
||||||
|
|
||||||
|
# Gitea 标签清单。GITEA_TOKEN 只放服务端,绝不返回给 Launcher。
|
||||||
|
# 策略页勾选“允许 Launcher 生成 Git 标签清单”后,服务端会用这里的配置拉取仓库 tags。
|
||||||
|
GITEA_BASE_URL=https://git.alimzs.com:6443
|
||||||
|
GITEA_TOKEN=7fb1c733e49c6feb50c8fff900d71f7c97ebfa3d
|
||||||
|
GITEA_VERIFY_SSL=false
|
||||||
|
GITEA_TAG_CACHE_TTL_SEC=300
|
||||||
|
GITEA_TIMEOUT_SEC=12
|
||||||
|
|
||||||
# 管理页生成 app_config.json 时使用的客户端默认值。普通 Windows SimCAE 部署通常不用改。
|
# 管理页生成 app_config.json 时使用的客户端默认值。普通 Windows SimCAE 部署通常不用改。
|
||||||
# Linux 部署可以改成:
|
# Linux 部署可以改成:
|
||||||
# CLIENT_MAIN_EXECUTABLE=SimCAE
|
# CLIENT_MAIN_EXECUTABLE=SimCAE
|
||||||
|
|||||||
@@ -226,6 +226,18 @@ MinIO 控制台: http://你的服务器IP:9001/
|
|||||||
| `SIGN_EXPIRE_MIN` | 升级文件下载链接有效期 | 默认 `60` 分钟。 |
|
| `SIGN_EXPIRE_MIN` | 升级文件下载链接有效期 | 默认 `60` 分钟。 |
|
||||||
| `MINIO_CONNECT_TIMEOUT_SEC` / `MINIO_READ_TIMEOUT_SEC` / `MINIO_RETRY_TOTAL` / `MINIO_HEALTH_TIMEOUT_SEC` | MinIO 连接超时与重试 | 默认适合内网部署。 |
|
| `MINIO_CONNECT_TIMEOUT_SEC` / `MINIO_READ_TIMEOUT_SEC` / `MINIO_RETRY_TOTAL` / `MINIO_HEALTH_TIMEOUT_SEC` | MinIO 连接超时与重试 | 默认适合内网部署。 |
|
||||||
|
|
||||||
|
### Git 标签清单参数
|
||||||
|
|
||||||
|
这些字段用于管理后台策略里的“允许 Launcher 生成 Git 标签清单”。开启后,Launcher 会请求服务端生成 `tags.txt`;服务端用这里的 Gitea 配置访问仓库 tags,然后只把整理后的文本返回给 Launcher。
|
||||||
|
|
||||||
|
| 字段 | 是否必填 | 默认能否直接用 | 怎么填 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `GITEA_BASE_URL` | 可不填 | 可以 | Gitea 服务地址,默认 `https://git.alimzs.com:6443`。如果公司 Git 地址变化,就改这里。 |
|
||||||
|
| `GITEA_TOKEN` | 查私有仓库时必填 | 不填只能查公开仓库 | Gitea 访问令牌。只保存在服务端 `.env`,不要写进客户端配置,不要提交 Git,不会返回给 Launcher。 |
|
||||||
|
| `GITEA_VERIFY_SSL` | 可不填 | 可以 | 是否校验 Gitea HTTPS 证书。内网自签证书可保持 `false`;正式可信证书建议改成 `true`。 |
|
||||||
|
| `GITEA_TAG_CACHE_TTL_SEC` | 可不填 | 可以 | tags 缓存时间,默认 `300` 秒,避免每次打开页面或启动 Launcher 都打满 Git 服务。 |
|
||||||
|
| `GITEA_TIMEOUT_SEC` | 可不填 | 可以 | 单次请求 Gitea 的超时时间,默认 `12` 秒。 |
|
||||||
|
|
||||||
### 发布保护参数
|
### 发布保护参数
|
||||||
|
|
||||||
这些字段用于防止上传超大目录导致服务器磁盘、内存压力过大。默认一般不用改。
|
这些字段用于防止上传超大目录导致服务器磁盘、内存压力过大。默认一般不用改。
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ const policyForm = reactive<any>({
|
|||||||
min_supported_version: "",
|
min_supported_version: "",
|
||||||
valid_until: "",
|
valid_until: "",
|
||||||
message: "",
|
message: "",
|
||||||
|
git_tags_enabled: false,
|
||||||
policy_seq: null
|
policy_seq: null
|
||||||
});
|
});
|
||||||
const currentPolicy = reactive<any>({
|
const currentPolicy = reactive<any>({
|
||||||
@@ -95,6 +96,7 @@ const currentPolicy = reactive<any>({
|
|||||||
min_supported_version: "",
|
min_supported_version: "",
|
||||||
valid_until: "2099-12-31T23:59:59Z",
|
valid_until: "2099-12-31T23:59:59Z",
|
||||||
disabled_versions: [],
|
disabled_versions: [],
|
||||||
|
git_tags_enabled: false,
|
||||||
message: "",
|
message: "",
|
||||||
policy_seq: null,
|
policy_seq: null,
|
||||||
saved: false
|
saved: false
|
||||||
@@ -139,6 +141,10 @@ const releaseManifestError = ref("");
|
|||||||
const installRootDetectMessage = ref("");
|
const installRootDetectMessage = ref("");
|
||||||
const installRootDialogVisible = ref(false);
|
const installRootDialogVisible = ref(false);
|
||||||
const installRootDialogReason = ref("");
|
const installRootDialogReason = ref("");
|
||||||
|
const gitTagsPreview = ref("");
|
||||||
|
const gitTagsSummary = ref("");
|
||||||
|
const gitTagsLoading = ref(false);
|
||||||
|
const gitTagsError = ref("");
|
||||||
let dashboardResizeObserver: ResizeObserver | null = null;
|
let dashboardResizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
const enabledChannels = computed(() =>
|
const enabledChannels = computed(() =>
|
||||||
@@ -693,6 +699,42 @@ async function loadPolicy() {
|
|||||||
disabled_versions: data?.disabled_versions || []
|
disabled_versions: data?.disabled_versions || []
|
||||||
});
|
});
|
||||||
policyDisabledText.value = (data?.disabled_versions || []).join(", ");
|
policyDisabledText.value = (data?.disabled_versions || []).join(", ");
|
||||||
|
if (policyForm.git_tags_enabled) {
|
||||||
|
await loadGitTagsPreview(false);
|
||||||
|
} else {
|
||||||
|
gitTagsPreview.value = "";
|
||||||
|
gitTagsSummary.value = "";
|
||||||
|
gitTagsError.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGitTagsPreview(forceRefresh = true) {
|
||||||
|
gitTagsLoading.value = true;
|
||||||
|
gitTagsError.value = "";
|
||||||
|
try {
|
||||||
|
const data = await adminRequest<any>(
|
||||||
|
`/admin/git/tags?force_refresh=${forceRefresh ? "true" : "false"}`
|
||||||
|
);
|
||||||
|
gitTagsPreview.value = data?.tags_text || "";
|
||||||
|
gitTagsSummary.value = `仓库 ${data?.repo_count || 0} 个,标签 ${data?.tag_count || 0} 个,生成时间 ${data?.generated_at || "未知"}`;
|
||||||
|
} catch (error) {
|
||||||
|
gitTagsPreview.value = "";
|
||||||
|
gitTagsSummary.value = "";
|
||||||
|
gitTagsError.value = friendlyError(error);
|
||||||
|
ElMessage.error(`获取 Git 标签失败:${gitTagsError.value}`);
|
||||||
|
} finally {
|
||||||
|
gitTagsLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePolicyGitTagsChange(value: boolean) {
|
||||||
|
if (value) {
|
||||||
|
await loadGitTagsPreview(true);
|
||||||
|
} else {
|
||||||
|
gitTagsPreview.value = "";
|
||||||
|
gitTagsSummary.value = "";
|
||||||
|
gitTagsError.value = "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function savePolicy() {
|
async function savePolicy() {
|
||||||
@@ -1407,6 +1449,9 @@ test_file/*
|
|||||||
<el-descriptions-item label="离线启动">
|
<el-descriptions-item label="离线启动">
|
||||||
<el-tag :type="currentPolicy.offline_allowed ? 'success' : 'warning'">{{ currentPolicy.offline_allowed ? "允许" : "禁止" }}</el-tag>
|
<el-tag :type="currentPolicy.offline_allowed ? 'success' : 'warning'">{{ currentPolicy.offline_allowed ? "允许" : "禁止" }}</el-tag>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="Git 标签清单">
|
||||||
|
<el-tag :type="currentPolicy.git_tags_enabled ? 'success' : 'info'">{{ currentPolicy.git_tags_enabled ? "生成" : "不生成" }}</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="最低支持版本">{{ currentPolicy.min_supported_version || "无" }}</el-descriptions-item>
|
<el-descriptions-item label="最低支持版本">{{ currentPolicy.min_supported_version || "无" }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="有效期">{{ currentPolicy.valid_until || "未设置" }}</el-descriptions-item>
|
<el-descriptions-item label="有效期">{{ currentPolicy.valid_until || "未设置" }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="禁用版本" :span="3">{{ currentPolicyDisabledText }}</el-descriptions-item>
|
<el-descriptions-item label="禁用版本" :span="3">{{ currentPolicyDisabledText }}</el-descriptions-item>
|
||||||
@@ -1423,6 +1468,26 @@ test_file/*
|
|||||||
<el-checkbox v-model="policyForm.force_update">强制更新</el-checkbox>
|
<el-checkbox v-model="policyForm.force_update">强制更新</el-checkbox>
|
||||||
<el-checkbox v-model="policyForm.allow_rollback">允许回滚</el-checkbox>
|
<el-checkbox v-model="policyForm.allow_rollback">允许回滚</el-checkbox>
|
||||||
<el-checkbox v-model="policyForm.offline_allowed">允许离线启动</el-checkbox>
|
<el-checkbox v-model="policyForm.offline_allowed">允许离线启动</el-checkbox>
|
||||||
|
<el-checkbox v-model="policyForm.git_tags_enabled" @change="handlePolicyGitTagsChange">允许 Launcher 生成 Git 标签清单</el-checkbox>
|
||||||
|
</div>
|
||||||
|
<div v-if="policyForm.git_tags_enabled" class="git-tags-preview" v-loading="gitTagsLoading">
|
||||||
|
<div class="card-header-row">
|
||||||
|
<div>
|
||||||
|
<strong>Git 标签清单预览</strong>
|
||||||
|
<div class="field-help">服务端使用 Gitea Token 拉取仓库和标签,页面只展示整理后的结果,不会暴露 Token。</div>
|
||||||
|
</div>
|
||||||
|
<el-button :loading="gitTagsLoading" @click="loadGitTagsPreview(true)">刷新标签</el-button>
|
||||||
|
</div>
|
||||||
|
<el-alert
|
||||||
|
v-if="gitTagsError"
|
||||||
|
class="mt-sm"
|
||||||
|
type="error"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
:title="gitTagsError"
|
||||||
|
/>
|
||||||
|
<div v-if="gitTagsSummary" class="field-help">{{ gitTagsSummary }}</div>
|
||||||
|
<pre class="code-block git-tags-code">{{ gitTagsPreview || "尚未获取 Git 标签清单" }}</pre>
|
||||||
</div>
|
</div>
|
||||||
<el-input v-model="policyForm.message" type="textarea" :rows="3" placeholder="策略消息,可空" />
|
<el-input v-model="policyForm.message" type="textarea" :rows="3" placeholder="策略消息,可空" />
|
||||||
<el-button class="mt-sm" type="primary" @click="savePolicy">保存策略</el-button>
|
<el-button class="mt-sm" type="primary" @click="savePolicy">保存策略</el-button>
|
||||||
@@ -2083,6 +2148,15 @@ test_file/*
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.git-tags-preview {
|
||||||
|
padding: 14px;
|
||||||
|
margin: 16px 0;
|
||||||
|
background: linear-gradient(135deg, #f8fbff, #fff);
|
||||||
|
border: 1px solid rgb(64 158 255 / 18%);
|
||||||
|
border-left: 4px solid #2563eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.card-header-row {
|
.card-header-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2313,6 +2387,12 @@ test_file/*
|
|||||||
min-height: 96px;
|
min-height: 96px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.git-tags-code {
|
||||||
|
min-height: 160px;
|
||||||
|
max-height: 320px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.metric-grid,
|
.metric-grid,
|
||||||
.form-grid,
|
.form-grid,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.api.routes import (
|
|||||||
client_update,
|
client_update,
|
||||||
crash_api,
|
crash_api,
|
||||||
frontend,
|
frontend,
|
||||||
|
git_tags,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -31,5 +32,6 @@ def include_api_routes(app: FastAPI):
|
|||||||
app.include_router(admin_logs.router)
|
app.include_router(admin_logs.router)
|
||||||
app.include_router(admin_crash_report.router)
|
app.include_router(admin_crash_report.router)
|
||||||
app.include_router(admin_device.router)
|
app.include_router(admin_device.router)
|
||||||
|
app.include_router(git_tags.router)
|
||||||
app.include_router(client_update.router)
|
app.include_router(client_update.router)
|
||||||
app.include_router(crash_api.router)
|
app.include_router(crash_api.router)
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ def admin_save_policy(body: PolicySaveRequest, auth=Depends(require_permission("
|
|||||||
valid_until,
|
valid_until,
|
||||||
body.min_supported_version.strip(),
|
body.min_supported_version.strip(),
|
||||||
json.dumps(disabled, ensure_ascii=False),
|
json.dumps(disabled, ensure_ascii=False),
|
||||||
|
bool(body.git_tags_enabled),
|
||||||
body.message.strip(),
|
body.message.strip(),
|
||||||
)
|
)
|
||||||
if result == "channel_not_found":
|
if result == "channel_not_found":
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ async def check_update(request: Request, body: CheckUpdateRequest = Body(...)):
|
|||||||
"latest_version": latest_ver,
|
"latest_version": latest_ver,
|
||||||
"min_supported_version": settings["min_supported_version"],
|
"min_supported_version": settings["min_supported_version"],
|
||||||
"disabled_versions": settings["disabled_versions"],
|
"disabled_versions": settings["disabled_versions"],
|
||||||
|
"git_tags_enabled": settings["git_tags_enabled"],
|
||||||
"message": message,
|
"message": message,
|
||||||
"signature_alg": "RSA-2048-SHA256",
|
"signature_alg": "RSA-2048-SHA256",
|
||||||
"key_id": SIGNING_KEY_ID,
|
"key_id": SIGNING_KEY_ID,
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
|
||||||
|
|
||||||
|
from app.core.security import require_permission
|
||||||
|
from app.repositories import policy_repository
|
||||||
|
from app.schemas.client_update import GitTagsRequest
|
||||||
|
from app.services.common_service import policy_row_to_dict
|
||||||
|
from app.services.git_tags_service import load_git_tags
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["git-tags"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/git/tags")
|
||||||
|
def admin_git_tags(
|
||||||
|
force_refresh: bool = Query(False),
|
||||||
|
auth=Depends(require_permission("policy:view")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return load_git_tags(force_refresh=force_refresh)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=f"获取 Git 标签失败:{exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/git/tags")
|
||||||
|
def client_git_tags(request: Request, body: GitTagsRequest = Body(...)):
|
||||||
|
identity = request.state.device_identity
|
||||||
|
if identity["app_id"] != body.app_id or identity["channel"] != body.channel:
|
||||||
|
raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配")
|
||||||
|
|
||||||
|
policy = policy_row_to_dict(policy_repository.get_policy(body.app_id, body.channel))
|
||||||
|
if not policy["git_tags_enabled"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail={"error": "git_tags_disabled", "msg": "当前策略未允许生成 Git 标签清单"},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = load_git_tags(force_refresh=False)
|
||||||
|
return {
|
||||||
|
"generated_at": payload["generated_at"],
|
||||||
|
"repo_count": payload["repo_count"],
|
||||||
|
"tag_count": payload["tag_count"],
|
||||||
|
"tags_text": payload["tags_text"],
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=f"获取 Git 标签失败:{exc}") from exc
|
||||||
@@ -17,6 +17,7 @@ def save_policy(
|
|||||||
valid_until: str,
|
valid_until: str,
|
||||||
min_supported_version: str,
|
min_supported_version: str,
|
||||||
disabled_versions_json: str,
|
disabled_versions_json: str,
|
||||||
|
git_tags_enabled: bool,
|
||||||
message: str,
|
message: str,
|
||||||
) -> tuple[str, int | None]:
|
) -> tuple[str, int | None]:
|
||||||
conn = db.get_conn()
|
conn = db.get_conn()
|
||||||
@@ -36,6 +37,7 @@ def save_policy(
|
|||||||
valid_until,
|
valid_until,
|
||||||
min_supported_version,
|
min_supported_version,
|
||||||
disabled_versions_json,
|
disabled_versions_json,
|
||||||
|
int(bool(git_tags_enabled)),
|
||||||
message,
|
message,
|
||||||
app_id,
|
app_id,
|
||||||
channel,
|
channel,
|
||||||
@@ -43,13 +45,13 @@ def save_policy(
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO version_policies(policy_seq,force_update,allow_rollback,offline_allowed,valid_until,
|
INSERT INTO version_policies(policy_seq,force_update,allow_rollback,offline_allowed,valid_until,
|
||||||
min_supported_version,disabled_versions,message,app_id,channel)
|
min_supported_version,disabled_versions,git_tags_enabled,message,app_id,channel)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?,?)
|
VALUES(?,?,?,?,?,?,?,?,?,?,?)
|
||||||
ON CONFLICT(app_id,channel) DO UPDATE SET policy_seq=excluded.policy_seq,
|
ON CONFLICT(app_id,channel) DO UPDATE SET policy_seq=excluded.policy_seq,
|
||||||
force_update=excluded.force_update,allow_rollback=excluded.allow_rollback,
|
force_update=excluded.force_update,allow_rollback=excluded.allow_rollback,
|
||||||
offline_allowed=excluded.offline_allowed,valid_until=excluded.valid_until,
|
offline_allowed=excluded.offline_allowed,valid_until=excluded.valid_until,
|
||||||
min_supported_version=excluded.min_supported_version,disabled_versions=excluded.disabled_versions,
|
min_supported_version=excluded.min_supported_version,disabled_versions=excluded.disabled_versions,
|
||||||
message=excluded.message,updated_at=datetime('now')
|
git_tags_enabled=excluded.git_tags_enabled,message=excluded.message,updated_at=datetime('now')
|
||||||
""",
|
""",
|
||||||
values,
|
values,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ class DownloadReportRequest(BaseModel):
|
|||||||
files: list[dict]
|
files: list[dict]
|
||||||
|
|
||||||
|
|
||||||
|
class GitTagsRequest(BaseModel):
|
||||||
|
app_id: str
|
||||||
|
channel: str
|
||||||
|
|
||||||
|
|
||||||
class DeviceIssueRequest(BaseModel):
|
class DeviceIssueRequest(BaseModel):
|
||||||
app_id: str
|
app_id: str
|
||||||
channel: str
|
channel: str
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ class PolicySaveRequest(BaseModel):
|
|||||||
valid_until: str = ""
|
valid_until: str = ""
|
||||||
min_supported_version: str = ""
|
min_supported_version: str = ""
|
||||||
disabled_versions: list[str] = Field(default_factory=list)
|
disabled_versions: list[str] = Field(default_factory=list)
|
||||||
|
git_tags_enabled: bool = False
|
||||||
message: str = ""
|
message: str = ""
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ def policy_row_to_dict(row) -> dict:
|
|||||||
"valid_until": "2099-12-31T23:59:59Z",
|
"valid_until": "2099-12-31T23:59:59Z",
|
||||||
"min_supported_version": "",
|
"min_supported_version": "",
|
||||||
"disabled_versions": [],
|
"disabled_versions": [],
|
||||||
|
"git_tags_enabled": False,
|
||||||
"message": "",
|
"message": "",
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
@@ -27,6 +28,7 @@ def policy_row_to_dict(row) -> dict:
|
|||||||
"valid_until": row["valid_until"],
|
"valid_until": row["valid_until"],
|
||||||
"min_supported_version": row["min_supported_version"] or "",
|
"min_supported_version": row["min_supported_version"] or "",
|
||||||
"disabled_versions": disabled if isinstance(disabled, list) else [],
|
"disabled_versions": disabled if isinstance(disabled, list) else [],
|
||||||
|
"git_tags_enabled": bool(row["git_tags_enabled"]) if "git_tags_enabled" in row.keys() else False,
|
||||||
"message": row["message"] or "",
|
"message": row["message"] or "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import ssl
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
_CACHE: dict[str, Any] = {
|
||||||
|
"expires_at": 0.0,
|
||||||
|
"payload": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _env_bool(name: str, default: bool) -> bool:
|
||||||
|
value = os.getenv(name)
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def _base_url() -> str:
|
||||||
|
return os.getenv("GITEA_BASE_URL", "https://git.alimzs.com:6443").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _token() -> str:
|
||||||
|
return os.getenv("GITEA_TOKEN", "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _request_json(path: str) -> Any:
|
||||||
|
headers = {"Accept": "application/json"}
|
||||||
|
token = _token()
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"token {token}"
|
||||||
|
context = None
|
||||||
|
if not _env_bool("GITEA_VERIFY_SSL", False):
|
||||||
|
context = ssl._create_unverified_context()
|
||||||
|
req = urllib.request.Request(_base_url() + path, headers=headers)
|
||||||
|
timeout = float(os.getenv("GITEA_TIMEOUT_SEC", "12"))
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout, context=context) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _paged(path: str) -> list[Any]:
|
||||||
|
result: list[Any] = []
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
sep = "&" if "?" in path else "?"
|
||||||
|
data = _request_json(f"{path}{sep}limit=100&page={page}")
|
||||||
|
if isinstance(data, dict) and "data" in data:
|
||||||
|
data = data.get("data") or []
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise RuntimeError("Gitea API returned an unexpected response.")
|
||||||
|
result.extend(data)
|
||||||
|
page += 1
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_list() -> list[dict[str, Any]]:
|
||||||
|
if _token():
|
||||||
|
repos = _paged("/api/v1/user/repos")
|
||||||
|
else:
|
||||||
|
repos = _paged("/api/v1/repos/search")
|
||||||
|
return [repo for repo in repos if isinstance(repo, dict) and repo.get("full_name")]
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_tags(full_name: str) -> list[str]:
|
||||||
|
owner, name = full_name.split("/", 1)
|
||||||
|
owner_q = urllib.parse.quote(owner, safe="")
|
||||||
|
name_q = urllib.parse.quote(name, safe="")
|
||||||
|
tags = _paged(f"/api/v1/repos/{owner_q}/{name_q}/tags")
|
||||||
|
return [str(item.get("name")) for item in tags if isinstance(item, dict) and item.get("name")]
|
||||||
|
|
||||||
|
|
||||||
|
def format_tags_text(repositories: list[dict[str, Any]], generated_at: str) -> str:
|
||||||
|
lines = [
|
||||||
|
"# SimCAE Git Tag List",
|
||||||
|
f"# Generated at: {generated_at}",
|
||||||
|
f"# Source: {_base_url()}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for repo in repositories:
|
||||||
|
visibility = "private" if repo.get("private") else "public"
|
||||||
|
lines.append(f"[{repo['full_name']}]")
|
||||||
|
lines.append(f"default_branch={repo.get('default_branch') or ''}")
|
||||||
|
lines.append(f"visibility={visibility}")
|
||||||
|
tags = repo.get("tags") or []
|
||||||
|
if tags:
|
||||||
|
lines.extend(str(tag) for tag in tags)
|
||||||
|
else:
|
||||||
|
lines.append("(no tags)")
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines).rstrip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def load_git_tags(force_refresh: bool = False) -> dict[str, Any]:
|
||||||
|
ttl = max(0, int(os.getenv("GITEA_TAG_CACHE_TTL_SEC", "300") or "300"))
|
||||||
|
now = time.time()
|
||||||
|
if not force_refresh and _CACHE["payload"] and now < float(_CACHE["expires_at"]):
|
||||||
|
return _CACHE["payload"]
|
||||||
|
|
||||||
|
generated_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||||
|
repositories: list[dict[str, Any]] = []
|
||||||
|
for repo in _repo_list():
|
||||||
|
full_name = str(repo["full_name"])
|
||||||
|
tags = _repo_tags(full_name)
|
||||||
|
repositories.append(
|
||||||
|
{
|
||||||
|
"full_name": full_name,
|
||||||
|
"private": bool(repo.get("private")),
|
||||||
|
"default_branch": repo.get("default_branch") or "",
|
||||||
|
"html_url": repo.get("html_url") or "",
|
||||||
|
"tags": tags,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
payload = {
|
||||||
|
"generated_at": generated_at,
|
||||||
|
"repo_count": len(repositories),
|
||||||
|
"tag_count": sum(len(repo["tags"]) for repo in repositories),
|
||||||
|
"repositories": repositories,
|
||||||
|
"tags_text": format_tags_text(repositories, generated_at),
|
||||||
|
}
|
||||||
|
_CACHE["payload"] = payload
|
||||||
|
_CACHE["expires_at"] = now + ttl
|
||||||
|
return payload
|
||||||
@@ -55,6 +55,9 @@ async def lifespan(app: FastAPI):
|
|||||||
license_columns = {row[1] for row in cur.execute("PRAGMA table_info(licenses)").fetchall()}
|
license_columns = {row[1] for row in cur.execute("PRAGMA table_info(licenses)").fetchall()}
|
||||||
if "license_key_cipher" not in license_columns:
|
if "license_key_cipher" not in license_columns:
|
||||||
cur.execute("ALTER TABLE licenses ADD COLUMN license_key_cipher TEXT NOT NULL DEFAULT ''")
|
cur.execute("ALTER TABLE licenses ADD COLUMN license_key_cipher TEXT NOT NULL DEFAULT ''")
|
||||||
|
policy_columns = {row[1] for row in cur.execute("PRAGMA table_info(version_policies)").fetchall()}
|
||||||
|
if "git_tags_enabled" not in policy_columns:
|
||||||
|
cur.execute("ALTER TABLE version_policies ADD COLUMN git_tags_enabled INTEGER NOT NULL DEFAULT 0")
|
||||||
audit_columns = {row[1] for row in cur.execute("PRAGMA table_info(admin_audit_logs)").fetchall()}
|
audit_columns = {row[1] for row in cur.execute("PRAGMA table_info(admin_audit_logs)").fetchall()}
|
||||||
if "actor_username" not in audit_columns:
|
if "actor_username" not in audit_columns:
|
||||||
cur.execute("ALTER TABLE admin_audit_logs ADD COLUMN actor_username TEXT NOT NULL DEFAULT ''")
|
cur.execute("ALTER TABLE admin_audit_logs ADD COLUMN actor_username TEXT NOT NULL DEFAULT ''")
|
||||||
|
|||||||
@@ -319,6 +319,7 @@ MinIO 控制台: http://你的服务器IP:9001/
|
|||||||
| `ADMIN_JWT_SECRET` | 必填 | 可以 | 管理后台 JWT 签名密钥。正式部署建议改成随机长字符串,并长期保持不变;改掉后旧登录 token 会失效。 |
|
| `ADMIN_JWT_SECRET` | 必填 | 可以 | 管理后台 JWT 签名密钥。正式部署建议改成随机长字符串,并长期保持不变;改掉后旧登录 token 会失效。 |
|
||||||
| `ADMIN_TOKEN` | 必填 | 可以 | 服务端兜底令牌。管理后台不再用它直接登录;它仍用于 `ADMIN_JWT_SECRET` 未设置时的默认签名密钥,以及 `CRASH_ADMIN_TOKEN` 为空时的崩溃报告管理兜底令牌。 |
|
| `ADMIN_TOKEN` | 必填 | 可以 | 服务端兜底令牌。管理后台不再用它直接登录;它仍用于 `ADMIN_JWT_SECRET` 未设置时的默认签名密钥,以及 `CRASH_ADMIN_TOKEN` 为空时的崩溃报告管理兜底令牌。 |
|
||||||
| `LICENSE_KEY_ENCRYPTION_SECRET` | 必填 | 可以 | 后台授权列表显示 License Key 时使用的加密密钥。正式部署建议修改,并且部署后长期保持不变;如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。 |
|
| `LICENSE_KEY_ENCRYPTION_SECRET` | 必填 | 可以 | 后台授权列表显示 License Key 时使用的加密密钥。正式部署建议修改,并且部署后长期保持不变;如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。 |
|
||||||
|
| `GITEA_BASE_URL` / `GITEA_TOKEN` | 可不填 | 可以 | 策略页勾选“允许 Launcher 生成 Git 标签清单”时使用。`GITEA_TOKEN` 只保存在服务端,用来访问 Gitea 仓库和 tags,不会返回给客户端。只查公开仓库时可不填 Token;要查私有仓库必须填写。 |
|
||||||
| `MINIO_ACCESS_KEY` | 必填 | 可以 | MinIO 用户名。默认可试跑,正式环境建议改。 |
|
| `MINIO_ACCESS_KEY` | 必填 | 可以 | MinIO 用户名。默认可试跑,正式环境建议改。 |
|
||||||
| `MINIO_SECRET_KEY` | 必填 | 可以 | MinIO 密码。默认可试跑,正式环境建议改。 |
|
| `MINIO_SECRET_KEY` | 必填 | 可以 | MinIO 密码。默认可试跑,正式环境建议改。 |
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ CREATE TABLE IF NOT EXISTS version_policies (
|
|||||||
valid_until TEXT NOT NULL,
|
valid_until TEXT NOT NULL,
|
||||||
min_supported_version TEXT NOT NULL DEFAULT '',
|
min_supported_version TEXT NOT NULL DEFAULT '',
|
||||||
disabled_versions TEXT NOT NULL DEFAULT '[]',
|
disabled_versions TEXT NOT NULL DEFAULT '[]',
|
||||||
|
git_tags_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
message TEXT NOT NULL DEFAULT '',
|
message TEXT NOT NULL DEFAULT '',
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
updated_at TEXT DEFAULT (datetime('now')),
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
|
||||||
def fake_request(**kwargs):
|
def fake_request(**kwargs):
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
@@ -95,6 +97,63 @@ def test_admin_login_license_and_device_issue(server_module):
|
|||||||
asyncio.run(run_admin_login_license_and_device_issue(server_module))
|
asyncio.run(run_admin_login_license_and_device_issue(server_module))
|
||||||
|
|
||||||
|
|
||||||
|
async def run_git_tags_policy_gate(server_module, monkeypatch):
|
||||||
|
async with server_module.lifespan(server_module.app):
|
||||||
|
from app.api.routes import admin_policy, client_update, git_tags
|
||||||
|
from app.schemas.client_update import DeviceIssueRequest, GitTagsRequest
|
||||||
|
from app.schemas.policy import PolicySaveRequest
|
||||||
|
|
||||||
|
add_app("gitapp", "Git App")
|
||||||
|
license_key = create_license("gitapp", "stable")
|
||||||
|
issue_data = await client_update.issue_device(
|
||||||
|
fake_request(),
|
||||||
|
DeviceIssueRequest(
|
||||||
|
app_id="gitapp",
|
||||||
|
channel="stable",
|
||||||
|
license_key=license_key,
|
||||||
|
installation_id="install-git-tags-000001",
|
||||||
|
machine_hash="cd" * 32,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
request = fake_request(state={"device_identity": issue_data["identity"]})
|
||||||
|
body = GitTagsRequest(app_id="gitapp", channel="stable")
|
||||||
|
|
||||||
|
try:
|
||||||
|
git_tags.client_git_tags(request, body)
|
||||||
|
assert False, "Git tags should be blocked before the policy is enabled"
|
||||||
|
except HTTPException as exc:
|
||||||
|
assert exc.status_code == 403
|
||||||
|
assert exc.detail["error"] == "git_tags_disabled"
|
||||||
|
|
||||||
|
admin_policy.admin_save_policy(
|
||||||
|
PolicySaveRequest(
|
||||||
|
app_id="gitapp",
|
||||||
|
channel="stable",
|
||||||
|
valid_until="2099-12-31T23:59:59Z",
|
||||||
|
git_tags_enabled=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
git_tags,
|
||||||
|
"load_git_tags",
|
||||||
|
lambda force_refresh=False: {
|
||||||
|
"generated_at": "2026-07-20T00:00:00Z",
|
||||||
|
"repo_count": 1,
|
||||||
|
"tag_count": 2,
|
||||||
|
"tags_text": "[owner/repo]\nv1.0.0\nv1.0.1\n",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = git_tags.client_git_tags(request, body)
|
||||||
|
assert result["repo_count"] == 1
|
||||||
|
assert result["tag_count"] == 2
|
||||||
|
assert "v1.0.1" in result["tags_text"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_git_tags_policy_gate(server_module, monkeypatch):
|
||||||
|
asyncio.run(run_git_tags_policy_gate(server_module, monkeypatch))
|
||||||
|
|
||||||
|
|
||||||
async def run_publish_payload_creates_version(server_module, tmp_path):
|
async def run_publish_payload_creates_version(server_module, tmp_path):
|
||||||
async with server_module.lifespan(server_module.app):
|
async with server_module.lifespan(server_module.app):
|
||||||
from app.api.routes import admin_version
|
from app.api.routes import admin_version
|
||||||
|
|||||||
Reference in New Issue
Block a user