Files
rdgen/rdgenerator/views.py
T
naeeo 4fd930aa0b feat: 同步构建真实状态并支持在列表停止在途任务
- Gitea 运行未结束时探测作业级结论,修复前置作业失败后可复用工作流父作业/等待标签作业长期挂起导致状态永远构建中

- 打开我的构建/管理仪表盘时主动向构建平台同步在途任务状态;创建超 6 小时仍在途的任务判超时

- 构建中任务列表增加停止按钮(Gitea 需 1.28+ 取消 API,旧版本提示去运行页手动 Cancel),终态任务保留删除

- GitHub/Gitea 后端统一 cancel_run 接口
2026-09-29 23:45:25 +08:00

727 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import io
import json
import os
import re
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
def refresh_active_run(gh_run):
"""向构建平台同步一次任务状态,状态落库后返回 True;网络异常或仍在进行则 False。
同时兜底两种平台侧的僵死:
- Gitea 前置作业失败后可复用工作流父作业/无匹配标签的作业长期挂起;
- 自建 runner 离线导致运行一直 in_progress(超过 STALE_BUILD_HOURS 判超时)。
"""
if gh_run.is_finished() or not gh_run.github_run_id:
return False
backend = build_backends.get_backend(gh_run.backend or 'github')
try:
info = backend.get_run(gh_run.github_run_id)
except Exception as e:
print(f"查询构建状态出错:{e}")
return False
if info is None:
return False
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 _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)
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),
)