import io import json import os import re import uuid 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.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 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 '' if gh_run.status not in ['success', 'failure', 'cancelled', 'timed_out', 'skipped'] and gh_run.github_run_id: try: info = backend.get_run(gh_run.github_run_id) if info is not None and info.finished: gh_run.status = info.status gh_run.save() except Exception as e: print(f"查询构建状态出错:{e}") 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, }) 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, } 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// 目录内 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), )