1. 状态同步加 20 秒节流、超时缩短到 8 秒;Gitea 改用 runs 列表接口一次批量取回全部任务状态,在途任务并发补查作业,消除逐任务串行 API 请求导致的后台/我的构建/详情页慢。 2. 停止时先区分 404:运行已删除则本地标记取消;Gitea 1.27 无取消 API 时,若后台配置了 Gitea 网页账号密码则模拟网页登录取消,否则回退手动链接提示。 3. 后台 Gitea 连接设置新增网页登录账号/密码两个可选项。
805 lines
32 KiB
Python
805 lines
32 KiB
Python
import io
|
||
import json
|
||
import os
|
||
import re
|
||
import time
|
||
import uuid
|
||
from datetime import timedelta
|
||
from pathlib import Path
|
||
from urllib.parse import quote
|
||
|
||
import base64
|
||
import pyzipper
|
||
from PIL import Image
|
||
|
||
from django.contrib import messages
|
||
from django.contrib.auth.decorators import login_required
|
||
from django.core.exceptions import PermissionDenied
|
||
from django.core.files.base import ContentFile
|
||
from django.db.models import Q
|
||
from django.http import (
|
||
HttpResponse,
|
||
JsonResponse,
|
||
HttpResponseForbidden,
|
||
Http404,
|
||
)
|
||
from django.shortcuts import render
|
||
from django.utils import timezone
|
||
from django.views.decorators.csrf import csrf_exempt
|
||
|
||
from . import app_settings, build_backends, versions as rdeskVersions
|
||
from .forms import GenerateForm
|
||
from .models import GithubRun, STATUS_LABELS, STATUS_BADGE
|
||
|
||
# 终态状态集合
|
||
TERMINAL_STATUSES = ('success', 'failure', 'cancelled', 'timed_out', 'skipped')
|
||
# 正常构建约 30~45 分钟;超过该时长平台仍显示进行中的,视为 runner 离线/作业僵死
|
||
STALE_BUILD_HOURS = 6
|
||
# 列表页状态同步节流:同一在途任务两次主动查询的最小间隔,避免每次刷页面都串行请求平台
|
||
REFRESH_THROTTLE_SECONDS = 20
|
||
# 列表/仪表盘同步用短超时,平台抖动时不能拖垮页面
|
||
REFRESH_TIMEOUT = 8
|
||
_refresh_stamps = {}
|
||
|
||
|
||
def _refresh_due(run_pk):
|
||
now = time.monotonic()
|
||
ts = _refresh_stamps.get(run_pk, 0)
|
||
if now - ts >= REFRESH_THROTTLE_SECONDS:
|
||
# 发起前先占位,避免并发请求堆积
|
||
_refresh_stamps[run_pk] = now
|
||
return True
|
||
return False
|
||
|
||
|
||
def refresh_active_run(gh_run, *, force=False, timeout=REFRESH_TIMEOUT):
|
||
"""向构建平台同步一次任务状态,状态落库后返回 True;网络异常或仍在进行则 False。
|
||
|
||
同时兜底两种平台侧的僵死:
|
||
- Gitea 前置作业失败后可复用工作流父作业/无匹配标签的作业长期挂起;
|
||
- 自建 runner 离线导致运行一直 in_progress(超过 STALE_BUILD_HOURS 判超时)。
|
||
|
||
force=False 时按 REFRESH_THROTTLE_SECONDS 节流(列表/仪表盘用);
|
||
force=True 跳过节流立即查询(详情页、停止操作后用),但同样刷新节流戳,
|
||
使紧接着的列表打开不再重复请求。
|
||
"""
|
||
if gh_run.is_finished() or not gh_run.github_run_id:
|
||
return False
|
||
if not force and not _refresh_due(gh_run.pk):
|
||
return False
|
||
_refresh_stamps[gh_run.pk] = time.monotonic()
|
||
backend = build_backends.get_backend(gh_run.backend or 'github')
|
||
try:
|
||
info = backend.get_run(gh_run.github_run_id, timeout=timeout)
|
||
except Exception as e:
|
||
print(f"查询构建状态出错:{e}")
|
||
return False
|
||
if info is None:
|
||
return False
|
||
return _apply_run_info(gh_run, info)
|
||
|
||
|
||
def _apply_run_info(gh_run, info):
|
||
"""把平台返回的状态落库;终态与 6 小时僵死兜底均在此。返回是否有变化。"""
|
||
new_status = info.status if info.finished else None
|
||
if new_status is None and timezone.now() - gh_run.created_at > timedelta(hours=STALE_BUILD_HOURS):
|
||
new_status = 'timed_out'
|
||
if new_status and new_status != gh_run.status:
|
||
gh_run.status = new_status
|
||
gh_run.save(update_fields=['status', 'updated_at'])
|
||
return True
|
||
return False
|
||
|
||
|
||
def refresh_active_runs(gh_runs, *, force=False):
|
||
"""并发同步一批在途任务(列表页/仪表盘),整页只承受一次平台往返的耗时。
|
||
|
||
Gitea 优先用 runs 列表接口一次取回全部状态(在途任务再补 jobs 探测);
|
||
其他后端或批量接口不可用时退化为并发逐个查询。
|
||
"""
|
||
due = []
|
||
for run in gh_runs:
|
||
if run.is_finished() or not run.github_run_id:
|
||
continue
|
||
if _refresh_due(run.pk) or force:
|
||
_refresh_stamps[run.pk] = time.monotonic()
|
||
due.append(run)
|
||
if not due:
|
||
return
|
||
|
||
# 按后端分组批量
|
||
groups = {}
|
||
for run in due:
|
||
groups.setdefault(run.backend or 'github', []).append(run)
|
||
for backend_name, runs in groups.items():
|
||
backend = build_backends.get_backend(backend_name)
|
||
get_runs_batch = getattr(backend, 'get_runs_batch', None)
|
||
infos = None
|
||
if get_runs_batch is not None:
|
||
try:
|
||
infos = get_runs_batch(
|
||
[r.github_run_id for r in runs], timeout=REFRESH_TIMEOUT)
|
||
except Exception as e:
|
||
print(f"批量查询构建状态出错:{e}")
|
||
infos = None
|
||
if infos is None:
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
workers = min(8, len(runs))
|
||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||
list(pool.map(lambda r: refresh_active_run(r, force=force), runs))
|
||
continue
|
||
missing = [run for run in runs if infos.get(str(run.github_run_id)) is None]
|
||
if missing:
|
||
# 列表窗口里没有的运行(已删除或超出最近 50 条):并发逐个查询兜底
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
workers = min(8, len(missing))
|
||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||
list(pool.map(lambda r: refresh_active_run(r, force=True), missing))
|
||
for run in runs:
|
||
info = infos.get(str(run.github_run_id))
|
||
if info is not None:
|
||
_apply_run_info(run, info)
|
||
|
||
|
||
def _webhook_denied(request):
|
||
"""配置了 WEBHOOK_SECRET 时,校验 GitHub Actions 回调请求头;未配置则放行(兼容旧工作流)。"""
|
||
secret = app_settings.get_value('WEBHOOK_SECRET')
|
||
if secret and request.headers.get('X-Webhook-Secret') != secret:
|
||
return HttpResponseForbidden('无效的 Webhook 密钥')
|
||
return None
|
||
|
||
|
||
def _status_extra(status_code):
|
||
return (
|
||
STATUS_LABELS.get(status_code, status_code),
|
||
STATUS_BADGE.get(status_code, 'badge-secondary'),
|
||
)
|
||
|
||
|
||
def _can_access_run(request, run):
|
||
"""任务提交者本人或管理员可访问;历史任务无归属人时登录用户均可访问。"""
|
||
if request.user.is_staff:
|
||
return True
|
||
return not run.created_by_id or run.created_by_id == request.user.id
|
||
|
||
|
||
def generate_custom_client(params, full_url, user=None):
|
||
"""
|
||
网页表单与 JSON API 共用的核心生成逻辑。
|
||
|
||
Args:
|
||
params: 包含全部配置项的字典(键名与 GenerateForm 字段一致)
|
||
full_url: 本服务的完整 URL(协议 + 主机名)
|
||
user: 提交任务的登录用户(可为 None,如匿名 API 调用)
|
||
|
||
Returns:
|
||
成功时包含 success/uuid/filename/platform/log_url;
|
||
失败时包含 success=False、error(中文提示)及 status_code。
|
||
"""
|
||
user_secret = params.get('sh_secret_field', '')
|
||
selfhosted = (app_settings.get_value('SH_SECRET') == user_secret)
|
||
platform = params.get('platform', 'windows')
|
||
version = params.get('version', '1.4.9')
|
||
delayFix = params.get('delayFix', True)
|
||
xOffline = params.get('xOffline', False)
|
||
hidecm = params.get('hidecm', False)
|
||
removeNewVersionNotif = params.get('removeNewVersionNotif', False)
|
||
server = params.get('serverIP', '')
|
||
serverPort = params.get('serverPort', '')
|
||
key = params.get('key', '')
|
||
apiServer = params.get('apiServer', '')
|
||
urlLink = params.get('urlLink', '')
|
||
downloadLink = params.get('downloadLink', '')
|
||
if not server:
|
||
server = 'rs-ny.rustdesk.com' # RustDesk 默认服务器
|
||
if not serverPort:
|
||
serverPort = '21116' # RustDesk 默认会合端口
|
||
if not key:
|
||
key = 'OeVuKk5nlHiXp+APNn0Y3pC1Iwpwn44JGqrQCsWqmBw=' # RustDesk 默认公钥
|
||
if not apiServer:
|
||
apiServer = server+":21114"
|
||
if not urlLink:
|
||
urlLink = "https://rustdesk.com"
|
||
if not downloadLink:
|
||
downloadLink = "https://rustdesk.com/download"
|
||
direction = params.get('direction', 'both')
|
||
installation = params.get('installation', 'installationY')
|
||
settings = params.get('settings', 'settingsY')
|
||
appname = params.get('appname', '')
|
||
if not appname:
|
||
appname = "rustdesk"
|
||
filename = params.get('exename', 'rustdesk')
|
||
compname = params.get('compname', '')
|
||
if not compname:
|
||
compname = "Purslane Ltd"
|
||
androidappid = params.get('androidappid', '')
|
||
if not androidappid:
|
||
androidappid = "com.carriez.flutter_hbb"
|
||
compname = compname.replace("&","\\&")
|
||
permPass = params.get('permanentPassword', '')
|
||
theme = params.get('theme', 'system')
|
||
themeDorO = params.get('themeDorO', 'default')
|
||
passApproveMode = params.get('passApproveMode', 'password-click')
|
||
denyLan = params.get('denyLan', False)
|
||
enableDirectIP = params.get('enableDirectIP', False)
|
||
autoClose = params.get('autoClose', False)
|
||
permissionsDorO = params.get('permissionsDorO', 'default')
|
||
permissionsType = params.get('permissionsType', 'custom')
|
||
enableKeyboard = params.get('enableKeyboard', True)
|
||
enableClipboard = params.get('enableClipboard', True)
|
||
enableFileTransfer = params.get('enableFileTransfer', True)
|
||
enableAudio = params.get('enableAudio', True)
|
||
enableTCP = params.get('enableTCP', True)
|
||
enableRemoteRestart = params.get('enableRemoteRestart', True)
|
||
enableRecording = params.get('enableRecording', True)
|
||
enableBlockingInput = params.get('enableBlockingInput', True)
|
||
enableRemoteModi = params.get('enableRemoteModi', False)
|
||
removeWallpaper = params.get('removeWallpaper', True)
|
||
defaultManual = params.get('defaultManual', '')
|
||
overrideManual = params.get('overrideManual', '')
|
||
enablePrinter = params.get('enablePrinter', True)
|
||
enableCamera = params.get('enableCamera', True)
|
||
enableTerminal = params.get('enableTerminal', True)
|
||
|
||
if all(char.isascii() for char in filename):
|
||
filename = re.sub(r'[^\w\s-]', '_', filename).strip()
|
||
filename = filename.replace(" ","_")
|
||
else:
|
||
filename = "rustdesk"
|
||
if not all(char.isascii() for char in appname):
|
||
appname = "rustdesk"
|
||
myuuid = str(uuid.uuid4())
|
||
|
||
try:
|
||
iconfile = params.get('iconfile')
|
||
if not iconfile:
|
||
iconfile = params.get('iconbase64')
|
||
iconlink_url, iconlink_uuid, iconlink_file = save_png(iconfile,myuuid,full_url,"icon.png")
|
||
except Exception:
|
||
print("获取图标失败,使用默认图标")
|
||
iconlink_url = "false"
|
||
iconlink_uuid = "false"
|
||
iconlink_file = "false"
|
||
try:
|
||
logofile = params.get('logofile')
|
||
if not logofile:
|
||
logofile = params.get('logobase64')
|
||
logolink_url, logolink_uuid, logolink_file = save_png(logofile,myuuid,full_url,"logo.png")
|
||
except Exception:
|
||
print("获取 Logo 失败")
|
||
logolink_url = "false"
|
||
logolink_uuid = "false"
|
||
logolink_file = "false"
|
||
try:
|
||
privacyfile = params.get('privacyfile')
|
||
if not privacyfile:
|
||
privacyfile = params.get('privacybase64')
|
||
privacylink_url, privacylink_uuid, privacylink_file = save_png(privacyfile,myuuid,full_url,"privacy.png")
|
||
except Exception:
|
||
print("获取隐私屏图片失败")
|
||
privacylink_url = "false"
|
||
privacylink_uuid = "false"
|
||
privacylink_file = "false"
|
||
|
||
### 生成 custom.txt 配置 JSON,作为工作流输入
|
||
decodedCustom = {}
|
||
if direction != "Both":
|
||
decodedCustom['conn-type'] = direction
|
||
if installation == "installationN":
|
||
decodedCustom['disable-installation'] = 'Y'
|
||
if settings == "settingsN":
|
||
decodedCustom['disable-settings'] = 'Y'
|
||
if appname.lower() != "rustdesk" and appname != "":
|
||
decodedCustom['app-name'] = appname
|
||
decodedCustom['override-settings'] = {}
|
||
decodedCustom['default-settings'] = {}
|
||
if permPass != "":
|
||
decodedCustom['password'] = permPass
|
||
if theme != "system":
|
||
if themeDorO == "default":
|
||
if platform == "windows-x86":
|
||
decodedCustom['default-settings']['allow-darktheme'] = 'Y' if theme == "dark" else 'N'
|
||
else:
|
||
decodedCustom['default-settings']['theme'] = theme
|
||
elif themeDorO == "override":
|
||
if platform == "windows-x86":
|
||
decodedCustom['override-settings']['allow-darktheme'] = 'Y' if theme == "dark" else 'N'
|
||
else:
|
||
decodedCustom['override-settings']['theme'] = theme
|
||
decodedCustom['enable-lan-discovery'] = 'N' if denyLan else 'Y'
|
||
#decodedCustom['direct-server'] = 'Y' if enableDirectIP else 'N'
|
||
decodedCustom['allow-auto-disconnect'] = 'Y' if autoClose else 'N'
|
||
|
||
if permissionsDorO == "default":
|
||
decodedCustom['default-settings']['access-mode'] = permissionsType
|
||
decodedCustom['default-settings']['enable-keyboard'] = 'Y' if enableKeyboard else 'N'
|
||
decodedCustom['default-settings']['enable-clipboard'] = 'Y' if enableClipboard else 'N'
|
||
decodedCustom['default-settings']['enable-file-transfer'] = 'Y' if enableFileTransfer else 'N'
|
||
decodedCustom['default-settings']['enable-audio'] = 'Y' if enableAudio else 'N'
|
||
decodedCustom['default-settings']['enable-tunnel'] = 'Y' if enableTCP else 'N'
|
||
decodedCustom['default-settings']['enable-remote-restart'] = 'Y' if enableRemoteRestart else 'N'
|
||
decodedCustom['default-settings']['enable-record-session'] = 'Y' if enableRecording else 'N'
|
||
decodedCustom['default-settings']['enable-block-input'] = 'Y' if enableBlockingInput else 'N'
|
||
decodedCustom['default-settings']['allow-remote-config-modification'] = 'Y' if enableRemoteModi else 'N'
|
||
decodedCustom['default-settings']['direct-server'] = 'Y' if enableDirectIP else 'N'
|
||
decodedCustom['default-settings']['verification-method'] = 'use-permanent-password' if hidecm else 'use-both-passwords'
|
||
decodedCustom['default-settings']['approve-mode'] = passApproveMode
|
||
decodedCustom['default-settings']['allow-hide-cm'] = 'Y' if hidecm else 'N'
|
||
decodedCustom['default-settings']['allow-remove-wallpaper'] = 'Y' if removeWallpaper else 'N'
|
||
decodedCustom['default-settings']['enable-remote-printer'] = 'Y' if enablePrinter else 'N'
|
||
decodedCustom['default-settings']['enable-camera'] = 'Y' if enableCamera else 'N'
|
||
decodedCustom['default-settings']['enable-terminal'] = 'Y' if enableTerminal else 'N'
|
||
|
||
|
||
else:
|
||
decodedCustom['override-settings']['access-mode'] = permissionsType
|
||
decodedCustom['override-settings']['enable-keyboard'] = 'Y' if enableKeyboard else 'N'
|
||
decodedCustom['override-settings']['enable-clipboard'] = 'Y' if enableClipboard else 'N'
|
||
decodedCustom['override-settings']['enable-file-transfer'] = 'Y' if enableFileTransfer else 'N'
|
||
decodedCustom['override-settings']['enable-audio'] = 'Y' if enableAudio else 'N'
|
||
decodedCustom['override-settings']['enable-tunnel'] = 'Y' if enableTCP else 'N'
|
||
decodedCustom['override-settings']['enable-remote-restart'] = 'Y' if enableRemoteRestart else 'N'
|
||
decodedCustom['override-settings']['enable-record-session'] = 'Y' if enableRecording else 'N'
|
||
decodedCustom['override-settings']['enable-block-input'] = 'Y' if enableBlockingInput else 'N'
|
||
decodedCustom['override-settings']['allow-remote-config-modification'] = 'Y' if enableRemoteModi else 'N'
|
||
decodedCustom['override-settings']['direct-server'] = 'Y' if enableDirectIP else 'N'
|
||
decodedCustom['override-settings']['verification-method'] = 'use-permanent-password' if hidecm else 'use-both-passwords'
|
||
decodedCustom['override-settings']['approve-mode'] = passApproveMode
|
||
decodedCustom['override-settings']['allow-hide-cm'] = 'Y' if hidecm else 'N'
|
||
decodedCustom['override-settings']['allow-remove-wallpaper'] = 'Y' if removeWallpaper else 'N'
|
||
decodedCustom['override-settings']['enable-remote-printer'] = 'Y' if enablePrinter else 'N'
|
||
decodedCustom['override-settings']['enable-camera'] = 'Y' if enableCamera else 'N'
|
||
decodedCustom['override-settings']['enable-terminal'] = 'Y' if enableTerminal else 'N'
|
||
if direction == 'incoming':
|
||
decodedCustom['override-settings']['custom-rendezvous-server'] = server
|
||
decodedCustom['override-settings']['api-server'] = apiServer
|
||
|
||
if defaultManual:
|
||
for line in defaultManual.splitlines():
|
||
if '=' in line:
|
||
k, value = line.split('=', 1)
|
||
decodedCustom['default-settings'][k.strip()] = value.strip()
|
||
|
||
if overrideManual:
|
||
for line in overrideManual.splitlines():
|
||
if '=' in line:
|
||
k, value = line.split('=', 1)
|
||
decodedCustom['override-settings'][k.strip()] = value.strip()
|
||
|
||
decodedCustomJson = json.dumps(decodedCustom)
|
||
|
||
string_bytes = decodedCustomJson.encode("ascii")
|
||
base64_bytes = base64.b64encode(string_bytes)
|
||
encodedCustom = base64_bytes.decode("ascii")
|
||
|
||
#### 触发构建平台工作流(GitHub / Gitea 可在后台「构建平台配置」切换)
|
||
backend = build_backends.get_backend()
|
||
workflow_file = backend.workflow_file_for(platform, selfhosted=bool(selfhosted))
|
||
|
||
inputs_raw = {
|
||
"server":server,
|
||
"serverPort":serverPort,
|
||
"key":key,
|
||
"apiServer":apiServer,
|
||
"custom":encodedCustom,
|
||
"uuid":myuuid,
|
||
"iconlink_url":iconlink_url,
|
||
"iconlink_uuid":iconlink_uuid,
|
||
"iconlink_file":iconlink_file,
|
||
"logolink_url":logolink_url,
|
||
"logolink_uuid":logolink_uuid,
|
||
"logolink_file":logolink_file,
|
||
"privacylink_url":privacylink_url,
|
||
"privacylink_uuid":privacylink_uuid,
|
||
"privacylink_file":privacylink_file,
|
||
"appname":appname,
|
||
"genurl":app_settings.get_value('GENURL'),
|
||
"urlLink":urlLink,
|
||
"downloadLink":downloadLink,
|
||
"delayFix": 'true' if delayFix else 'false',
|
||
"rdgen":'true',
|
||
"xOffline": 'true' if xOffline else 'false',
|
||
"removeNewVersionNotif": 'true' if removeNewVersionNotif else 'false',
|
||
"compname": compname,
|
||
"androidappid":androidappid,
|
||
"filename":filename
|
||
}
|
||
|
||
# ZIP_PASSWORD 为空时 pyzipper 的 AES 加密会直接抛 RuntimeError(500),
|
||
# 这里前置校验,返回可操作的中文提示(网页与 API 共用此函数)。
|
||
zip_password = app_settings.get_value('ZIP_PASSWORD')
|
||
if not zip_password.strip():
|
||
return {
|
||
"success": False,
|
||
"error": (
|
||
"配置压缩包密码(ZIP_PASSWORD)未配置,无法加密构建配置包。"
|
||
"请联系管理员在后台「GitHub 构建配置 - 回调与加密」中设置,"
|
||
"并确保与 GitHub 仓库 Secret「ZIP_PASSWORD」完全一致。"
|
||
),
|
||
"status_code": 500,
|
||
}
|
||
|
||
temp_json_path = f"data_{uuid.uuid4()}.json"
|
||
zip_filename = f"secrets_{uuid.uuid4()}.zip"
|
||
zip_path = "temp_zips/%s" % (zip_filename)
|
||
Path("temp_zips").mkdir(parents=True, exist_ok=True)
|
||
|
||
with open(temp_json_path, "w") as f:
|
||
json.dump(inputs_raw, f)
|
||
|
||
with pyzipper.AESZipFile(zip_path, 'w', compression=pyzipper.ZIP_LZMA, encryption=pyzipper.WZ_AES) as zf:
|
||
zf.setpassword(zip_password.encode())
|
||
zf.write(temp_json_path, arcname="secrets.json")
|
||
|
||
if os.path.exists(temp_json_path):
|
||
os.remove(temp_json_path)
|
||
|
||
zipJson = {}
|
||
zipJson['url'] = full_url
|
||
zipJson['file'] = zip_filename
|
||
|
||
zip_url = json.dumps(zipJson)
|
||
|
||
dispatch_inputs = {
|
||
"version": version,
|
||
"zip_url": zip_url,
|
||
}
|
||
# 保存提交时的配置快照,供终态任务「一键重试」。
|
||
# 仅保留 JSON 安全类型(上传文件对象跳过——网页提交时图片已转为
|
||
# iconbase64/logobase64/privacybase64 data URL 字符串,不会丢失)。
|
||
config_snapshot = {}
|
||
for _k, _v in params.items():
|
||
if isinstance(_v, (str, int, float, bool)) or _v is None:
|
||
config_snapshot[_k] = _v
|
||
|
||
new_github_run = GithubRun(
|
||
uuid=myuuid,
|
||
status="Starting generator...please wait",
|
||
platform=platform,
|
||
filename=filename,
|
||
config_data=config_snapshot,
|
||
backend=backend.name,
|
||
created_by=(user if user is not None and getattr(user, 'is_authenticated', False) else None),
|
||
)
|
||
try:
|
||
dispatch_result = backend.dispatch(workflow_file, dispatch_inputs, timeout=20)
|
||
new_github_run.github_run_id = dispatch_result.run_id
|
||
new_github_run.status = "in_progress"
|
||
new_github_run.save()
|
||
|
||
return {
|
||
"success": True,
|
||
"uuid": myuuid,
|
||
"filename": filename,
|
||
"platform": platform,
|
||
"log_url": dispatch_result.html_url,
|
||
"run": new_github_run,
|
||
}
|
||
except build_backends.base.BackendError as e:
|
||
return {
|
||
"success": False,
|
||
"error": e.message,
|
||
"status_code": 502
|
||
}
|
||
except Exception as e:
|
||
return {
|
||
"success": False,
|
||
"error": f"触发构建服务时发生未预期的错误:{str(e)}",
|
||
"status_code": 502
|
||
}
|
||
|
||
|
||
def _get_run_status(uuid_val):
|
||
"""
|
||
查询构建状态(网页与 JSON API 共用)。
|
||
"""
|
||
try:
|
||
gh_run = GithubRun.objects.get(uuid=uuid_val)
|
||
except GithubRun.DoesNotExist:
|
||
return {"found": False}
|
||
|
||
# 历史任务按其记录的构建平台查询,平台切换后不影响在途任务
|
||
backend = build_backends.get_backend(gh_run.backend or 'github')
|
||
github_log_url = backend.log_url(gh_run.github_run_id) if gh_run.github_run_id else ''
|
||
|
||
refresh_active_run(gh_run, force=True, timeout=12)
|
||
|
||
return {
|
||
"found": True,
|
||
"status": gh_run.status,
|
||
"github_log_url": github_log_url,
|
||
"gh_run": gh_run
|
||
}
|
||
|
||
|
||
@login_required
|
||
def generator_view(request):
|
||
# 版本下拉始终刷新为官方仓库最新 tags(内部带缓存与内置兜底,不会拖慢页面)
|
||
version_choices = rdeskVersions.version_choices()
|
||
if request.method == 'POST':
|
||
form = GenerateForm(request.POST, request.FILES)
|
||
form.fields['version'].choices = version_choices
|
||
if form.is_valid():
|
||
params = form.cleaned_data
|
||
full_url = f"{app_settings.get_value('PROTOCOL')}://{request.get_host()}"
|
||
result = generate_custom_client(params, full_url, user=request.user)
|
||
if result['success']:
|
||
status_label, status_badge = _status_extra("Starting generator...please wait")
|
||
return render(request, 'waiting.html', {
|
||
'filename': result['filename'],
|
||
'uuid': result['uuid'],
|
||
'status': "Starting generator...please wait",
|
||
'status_label': status_label,
|
||
'status_badge': status_badge,
|
||
'platform': result['platform'],
|
||
'log_url': result['log_url'],
|
||
'backend_label': result['run'].backend_label,
|
||
'backend_badge_class': result['run'].backend_badge_class,
|
||
})
|
||
else:
|
||
messages.error(request, result['error'])
|
||
else:
|
||
messages.error(request, "表单校验未通过,请根据下方提示修正后重新提交。")
|
||
else:
|
||
form = GenerateForm()
|
||
form.fields['version'].choices = version_choices
|
||
|
||
_, version_source, version_ts = rdeskVersions.get_versions()
|
||
profiles = request.user.saved_configs.all().values('id', 'name')
|
||
return render(request, 'generator.html', {
|
||
'form': form,
|
||
'profiles': profiles,
|
||
'version_source': version_source,
|
||
})
|
||
|
||
|
||
@login_required
|
||
def check_for_file(request):
|
||
filename = request.GET.get('filename')
|
||
uuid_val = request.GET.get('uuid')
|
||
platform = request.GET.get('platform')
|
||
|
||
result = _get_run_status(uuid_val)
|
||
if not result['found']:
|
||
raise Http404("未找到对应的构建任务")
|
||
|
||
gh_run = result['gh_run']
|
||
if not _can_access_run(request, gh_run):
|
||
raise PermissionDenied("无权查看该构建任务")
|
||
|
||
github_log_url = result['github_log_url']
|
||
status_label, status_badge = _status_extra(gh_run.status)
|
||
|
||
context = {
|
||
'filename': filename,
|
||
'uuid': uuid_val,
|
||
'platform': platform,
|
||
'status': gh_run.status,
|
||
'status_label': status_label,
|
||
'status_badge': status_badge,
|
||
'log_url': github_log_url,
|
||
'backend_label': gh_run.backend_label,
|
||
'backend_badge_class': gh_run.backend_badge_class,
|
||
}
|
||
|
||
if gh_run.status == "success":
|
||
return render(request, 'generated.html', context)
|
||
elif gh_run.status in ['failure', 'cancelled', 'timed_out', 'skipped', 'action_required']:
|
||
return render(request, 'failure.html', context)
|
||
else:
|
||
return render(request, 'waiting.html', context)
|
||
|
||
|
||
@login_required
|
||
def download(request):
|
||
filename = request.GET.get('filename', '')
|
||
uuid_val = request.GET.get('uuid', '')
|
||
if not filename or not uuid_val:
|
||
raise Http404
|
||
|
||
gh_run = GithubRun.objects.filter(uuid=uuid_val).first()
|
||
if gh_run is None:
|
||
raise Http404("未找到对应的构建任务")
|
||
if not _can_access_run(request, gh_run):
|
||
raise PermissionDenied("无权下载该文件")
|
||
|
||
# 防路径穿越:最终路径必须位于 exe/<uuid>/ 目录内
|
||
base_dir = os.path.abspath(os.path.join('exe', uuid_val))
|
||
file_path = os.path.abspath(os.path.join(base_dir, os.path.basename(filename)))
|
||
if not file_path.startswith(base_dir + os.sep) or not os.path.isfile(file_path):
|
||
raise Http404("文件不存在或尚未生成完成")
|
||
|
||
from django.http import FileResponse
|
||
return FileResponse(
|
||
open(file_path, 'rb'),
|
||
content_type='application/octet-stream',
|
||
as_attachment=True,
|
||
filename=os.path.basename(filename),
|
||
)
|
||
|
||
|
||
def get_png(request):
|
||
"""供 GitHub Actions 工作流下载构建所用的图片资源。"""
|
||
filename = request.GET.get('filename', '')
|
||
uuid_val = request.GET.get('uuid', '')
|
||
if not filename or not uuid_val:
|
||
raise Http404
|
||
|
||
base_dir = os.path.abspath(os.path.join('png', uuid_val))
|
||
file_path = os.path.abspath(os.path.join(base_dir, os.path.basename(filename)))
|
||
if not file_path.startswith(base_dir + os.sep) or not os.path.isfile(file_path):
|
||
raise Http404("图片不存在")
|
||
|
||
from django.http import FileResponse
|
||
return FileResponse(open(file_path, 'rb'), content_type='image/png')
|
||
|
||
|
||
@csrf_exempt
|
||
def update_github_run(request):
|
||
"""GitHub Actions 回调:更新构建状态。"""
|
||
denied = _webhook_denied(request)
|
||
if denied:
|
||
return denied
|
||
try:
|
||
data = json.loads(request.body)
|
||
except (json.JSONDecodeError, ValueError):
|
||
return HttpResponse(status=400)
|
||
myuuid = data.get('uuid')
|
||
mystatus = data.get('status')
|
||
if not myuuid or not mystatus:
|
||
return HttpResponse(status=400)
|
||
GithubRun.objects.filter(Q(uuid=myuuid)).update(status=mystatus)
|
||
return HttpResponse('')
|
||
|
||
|
||
def resize_and_encode_icon(imagefile):
|
||
maxWidth = 200
|
||
try:
|
||
with io.BytesIO() as image_buffer:
|
||
for chunk in imagefile.chunks():
|
||
image_buffer.write(chunk)
|
||
image_buffer.seek(0)
|
||
|
||
img = Image.open(image_buffer)
|
||
imgcopy = img.copy()
|
||
except (IOError, OSError):
|
||
raise ValueError("上传的文件不是有效的图片格式。")
|
||
|
||
# 无需缩放时直接返回
|
||
if img.size[0] <= maxWidth:
|
||
with io.BytesIO() as image_buffer:
|
||
imgcopy.save(image_buffer, format=imagefile.content_type.split('/')[1])
|
||
image_buffer.seek(0)
|
||
return_image = ContentFile(image_buffer.read(), name=imagefile.name)
|
||
return base64.b64encode(return_image.read())
|
||
|
||
# 等比例缩放
|
||
wpercent = (maxWidth / float(img.size[0]))
|
||
hsize = int((float(img.size[1]) * float(wpercent)))
|
||
|
||
# LANCZOS 高质量重采样
|
||
imgcopy = imgcopy.resize((maxWidth, hsize), Image.Resampling.LANCZOS)
|
||
|
||
with io.BytesIO() as resized_image_buffer:
|
||
imgcopy.save(resized_image_buffer, format=imagefile.content_type.split('/')[1])
|
||
resized_image_buffer.seek(0)
|
||
|
||
resized_imagefile = ContentFile(resized_image_buffer.read(), name=imagefile.name)
|
||
|
||
resized64 = base64.b64encode(resized_imagefile.read())
|
||
return resized64
|
||
|
||
# 以下接口供外部来源(如自建 RustDesk API 服务器)调用
|
||
@csrf_exempt
|
||
def startgh(request):
|
||
denied = _webhook_denied(request)
|
||
if denied:
|
||
return denied
|
||
data_ = json.loads(request.body)
|
||
#### 触发构建平台工作流(GitHub / Gitea 按后台配置切换)
|
||
backend = build_backends.get_backend()
|
||
workflow_file = 'generator-' + str(data_.get('platform')) + '.yml'
|
||
inputs = {
|
||
"server":data_.get('server'),
|
||
"key":data_.get('key'),
|
||
"apiServer":data_.get('apiServer'),
|
||
"custom":data_.get('custom'),
|
||
"uuid":data_.get('uuid'),
|
||
"iconlink":data_.get('iconlink'),
|
||
"logolink":data_.get('logolink'),
|
||
"appname":data_.get('appname'),
|
||
"extras":data_.get('extras'),
|
||
"filename":data_.get('filename')
|
||
}
|
||
try:
|
||
result = backend.dispatch(workflow_file, inputs, timeout=20)
|
||
print(result)
|
||
except build_backends.base.BackendError as e:
|
||
# 历史行为:本端点无论成败都返回 204,仅记录日志
|
||
print(f"startgh 触发失败:{e.message}")
|
||
return HttpResponse(status=204)
|
||
|
||
def save_png(file, uuid, domain, name):
|
||
file_save_path = "png/%s/%s" % (uuid, name)
|
||
Path("png/%s" % uuid).mkdir(parents=True, exist_ok=True)
|
||
|
||
if isinstance(file, str): # base64 字符串
|
||
try:
|
||
header, encoded = file.split(';base64,')
|
||
decoded_img = base64.b64decode(encoded)
|
||
file = ContentFile(decoded_img, name=name) # 类文件对象
|
||
except ValueError:
|
||
print("base64 数据无效")
|
||
return None
|
||
except Exception as e:
|
||
print(f"base64 解码出错:{e}")
|
||
return None
|
||
|
||
with open(file_save_path, "wb+") as f:
|
||
for chunk in file.chunks():
|
||
f.write(chunk)
|
||
return domain, uuid, name
|
||
|
||
@csrf_exempt
|
||
def save_custom_client(request):
|
||
"""GitHub Actions 回调:上传构建产物。"""
|
||
denied = _webhook_denied(request)
|
||
if denied:
|
||
return denied
|
||
file = request.FILES.get('file')
|
||
myuuid = request.POST.get('uuid')
|
||
if not file or not myuuid:
|
||
return HttpResponse("缺少文件或任务 UUID", status=400)
|
||
file_save_path = "exe/%s/%s" % (myuuid, os.path.basename(file.name))
|
||
Path("exe/%s" % myuuid).mkdir(parents=True, exist_ok=True)
|
||
with open(file_save_path, "wb+") as f:
|
||
for chunk in file.chunks():
|
||
f.write(chunk)
|
||
|
||
return HttpResponse("文件保存成功")
|
||
|
||
@csrf_exempt
|
||
def cleanup_secrets(request):
|
||
"""GitHub Actions 回调:清理加密的临时配置包。"""
|
||
denied = _webhook_denied(request)
|
||
if denied:
|
||
return denied
|
||
try:
|
||
data = json.loads(request.body)
|
||
except (json.JSONDecodeError, ValueError):
|
||
return HttpResponse("请求体不是有效的 JSON", status=400)
|
||
my_uuid = data.get('uuid')
|
||
|
||
if not my_uuid:
|
||
return HttpResponse("缺少任务 UUID", status=400)
|
||
|
||
temp_dir = os.path.join('temp_zips')
|
||
if not os.path.isdir(temp_dir):
|
||
return HttpResponse("清理完成", status=200)
|
||
|
||
for filename in os.listdir(temp_dir):
|
||
if my_uuid in filename and filename.endswith('.zip'):
|
||
file_path = os.path.join(temp_dir, filename)
|
||
try:
|
||
os.remove(file_path)
|
||
print(f"已删除 {file_path}")
|
||
except OSError as e:
|
||
print(f"删除文件失败:{e}")
|
||
|
||
return HttpResponse("清理完成", status=200)
|
||
|
||
def get_zip(request):
|
||
"""供 GitHub Actions 工作流下载 AES 加密的构建配置包。"""
|
||
filename = request.GET.get('filename', '')
|
||
base_dir = os.path.abspath('temp_zips')
|
||
file_path = os.path.abspath(os.path.join(base_dir, os.path.basename(filename)))
|
||
if not file_path.startswith(base_dir + os.sep) or not os.path.isfile(file_path):
|
||
return HttpResponseForbidden("无效的文件名")
|
||
from django.http import FileResponse
|
||
return FileResponse(
|
||
open(file_path, 'rb'),
|
||
content_type='application/zip',
|
||
as_attachment=True,
|
||
filename=os.path.basename(filename),
|
||
)
|