Files
rdgen/rdgenerator/views.py
T
naeeo d78c071818 构建状态增加平台终态只读核对,修复回调丢失后任务长期卡在构建中
工作流在脚本收尾前失败(拉取/编译/runner 异常)或 GENURL 不可达时,
结束回调会永久丢失,而等待页 30 秒轮询与列表页此前只读本地库,
任务只能等 6 小时本地超时,平台侧已失败也无法及时反映。

- GitHub/Gitea 后端新增只读 get_run(),仅在平台报告终态时返回结论,
  在途、非 200、网络异常、坏 JSON 一律返回 None;不取消、不写平台
- 等待页 30 秒轮询同步核对一次真实状态:20 秒节流、6 秒超时、异常静默
- 我的构建/后台仪表盘改为后台守护线程核对,页面渲染零等待
- 仅做非终态到终态的条件更新,回调抢先到达时不会被覆盖
- 停止/删除纯本地语义、6 小时本地超时兜底等现有逻辑保持不变
2026-09-30 12:46:05 +08:00

815 lines
32 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 threading
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 离线/作业僵死。
# 状态以构建脚本结束回调为第一来源;回调丢失时由 get_run 只读核对平台终态兜底,
# 超时判断只做本地兜底。
STALE_BUILD_HOURS = 6
# 主动核对节流:同一在途任务两次平台查询的最小间隔,避免轮询/刷页面打满 API
REFRESH_THROTTLE_SECONDS = 20
# 平台查询短超时,平台抖动时等待页轮询最多多等这几秒,不拖垮页面
REFRESH_TIMEOUT = 6
_refresh_stamps = {}
_refresh_lock = threading.Lock()
def _claim_refresh_slot(run_pk):
"""节流:到间隔才占位成功;先占位再请求,避免并发页面/线程重复打平台。"""
now = time.monotonic()
with _refresh_lock:
if now - _refresh_stamps.get(run_pk, 0) >= REFRESH_THROTTLE_SECONDS:
_refresh_stamps[run_pk] = now
return True
return False
def _apply_remote_terminal(gh_run, info):
"""平台明确终态且本地仍在途时落库(条件更新,避免覆盖刚到达的回调)。返回是否变化。"""
if info is None or not info.finished:
return False
new_status = info.status
if new_status not in TERMINAL_STATUSES or new_status == gh_run.status:
return False
updated = GithubRun.objects.filter(pk=gh_run.pk) \
.exclude(status__in=TERMINAL_STATUSES) \
.update(status=new_status, updated_at=timezone.now())
if updated:
gh_run.status = new_status
return bool(updated)
def refresh_active_run(gh_run):
"""同步向构建平台核对单个在途任务(等待页 30 秒轮询 / 状态接口用)。
20 秒节流、6 秒超时;任何网络异常静默。仅当平台报告终态时落库,
工作流结束回调仍是第一状态来源,本核对只兜底回调丢失。
"""
if gh_run.is_finished() or not gh_run.github_run_id:
return False
if not _claim_refresh_slot(gh_run.pk):
return False
try:
backend = build_backends.get_backend(gh_run.backend or 'github')
info = backend.get_run(gh_run.github_run_id, timeout=REFRESH_TIMEOUT)
except Exception as e:
print(f"核对构建状态出错:{e}")
return False
return _apply_remote_terminal(gh_run, info)
def _refresh_runs_in_background(run_pks):
"""后台线程体:逐个核对终态;与请求/渲染完全解耦,异常不影响任何页面。"""
try:
for pk in run_pks:
gh_run = GithubRun.objects.filter(pk=pk).first()
if gh_run is None or gh_run.is_finished() or not gh_run.github_run_id:
continue
try:
backend = build_backends.get_backend(gh_run.backend or 'github')
info = backend.get_run(gh_run.github_run_id, timeout=REFRESH_TIMEOUT)
_apply_remote_terminal(gh_run, info)
except Exception as e:
print(f"后台核对构建状态出错:{e}")
finally:
# 子线程使用独立数据库连接,结束时必须关闭
from django.db import connections
connections.close_all()
def schedule_refresh_for_runs(gh_runs):
"""列表/仪表盘用:为在途任务安排后台核对,页面渲染零等待。
与等待页共用 20 秒节流;无在途任务或刚核对过时不创建线程。
"""
due_pks = [
r.pk for r in gh_runs
if not r.is_finished() and r.github_run_id and _claim_refresh_slot(r.pk)
]
if not due_pks:
return
threading.Thread(
target=_refresh_runs_in_background, args=(due_pks,), daemon=True).start()
def mark_stale_builds(gh_runs=None):
"""纯本地兜底:在途任务超过 STALE_BUILD_HOURS 未收到结束回调的,标记为超时。
不产生任何网络请求。传入任务列表时只处理这些任务;不传则扫描全表。
"""
cutoff = timezone.now() - timedelta(hours=STALE_BUILD_HOURS)
qs = GithubRun.objects.exclude(status__in=TERMINAL_STATUSES)
if gh_runs is not None:
ids = [r.pk for r in gh_runs if not r.is_finished()]
qs = qs.filter(pk__in=ids)
qs.filter(created_at__lt=cutoff).update(
status='timed_out', updated_at=timezone.now())
def mark_run_stale_if_needed(gh_run):
"""单个任务的纯本地超时兜底(详情/状态接口用)。"""
if not gh_run.is_finished() and gh_run.created_at < \
timezone.now() - timedelta(hours=STALE_BUILD_HOURS):
gh_run.status = 'timed_out'
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 ''
# 状态以构建脚本结束回调为准;此处先做平台终态只读核对(20 秒节流、6 秒超时,
# 回调丢失时能及时发现平台侧已失败/取消),再做纯本地的 6 小时超时兜底
refresh_active_run(gh_run)
mark_run_stale_if_needed(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),
)