From d154381895925f9b81679e4eed6446c927515e25 Mon Sep 17 00:00:00 2001 From: youngS Date: Fri, 17 Sep 2021 16:23:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BB=A3=E7=A0=81=EF=BC=8C?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0cli.py=20=E4=B8=8A=E4=BC=A0=E8=BF=9B=E5=BA=A6?= =?UTF-8?q?=E6=9D=A1=EF=BC=8C=E4=BF=AE=E5=A4=8D=E7=AD=BE=E5=90=8D=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fir_client/src/components/FirLogin.vue | 2 +- fir_client/src/components/apps/FirApps.vue | 3 +- .../components/user/FirUserProfileInfo.vue | 3 +- fir_client/src/utils/index.js | 27 +++++- fir_ser/api/utils/baseutils.py | 14 ++++ fir_ser/api/utils/storage/aliyunApi.py | 45 +++++++++- fir_ser/api/utils/utils.py | 3 +- fir_ser/api/views/download.py | 65 ++++++--------- fir_ser/cli.py | 78 +++++++++++++++--- fir_ser/config.py | 10 ++- fir_ser/files/embedded.mobileprovision | Bin 9515 -> 12749 bytes 11 files changed, 190 insertions(+), 60 deletions(-) diff --git a/fir_client/src/components/FirLogin.vue b/fir_client/src/components/FirLogin.vue index c23ad93..97a9f03 100644 --- a/fir_client/src/components/FirLogin.vue +++ b/fir_client/src/components/FirLogin.vue @@ -116,7 +116,7 @@ }, cptch: {"cptch_image": '', "cptch_key": '', "length": 8}, activeName: 'username', - allow_ways: {'third':{}}, + allow_ways: {'third': {}}, rutitle: '', rctitle: '', register_enable: false, diff --git a/fir_client/src/components/apps/FirApps.vue b/fir_client/src/components/apps/FirApps.vue index ce7f609..d1fe605 100644 --- a/fir_client/src/components/apps/FirApps.vue +++ b/fir_client/src/components/apps/FirApps.vue @@ -819,7 +819,8 @@ } let certinfo = { 'upload_key': upload_key, - 'upload_token': upload_token + 'upload_token': upload_token, + 'app_info': analyseappinfo }; if (analyseappinfo.storage === 1) { // eslint-disable-next-line no-unused-vars,no-unreachable diff --git a/fir_client/src/components/user/FirUserProfileInfo.vue b/fir_client/src/components/user/FirUserProfileInfo.vue index 1024ea6..f3f10e7 100644 --- a/fir_client/src/components/user/FirUserProfileInfo.vue +++ b/fir_client/src/components/user/FirUserProfileInfo.vue @@ -247,7 +247,8 @@ -
+

绑定第三方账户 diff --git a/fir_client/src/utils/index.js b/fir_client/src/utils/index.js index 35eebe7..23684e2 100644 --- a/fir_client/src/utils/index.js +++ b/fir_client/src/utils/index.js @@ -125,6 +125,24 @@ export function dataURLtoFile(dataurl, filename) {//将base64转换为文件 return new File([u8arr], filename, {type: mime}); } +function get_file_type(upload_key) { + if (upload_key) { + const key_list = upload_key.split('.'); + if (key_list.length > 2) { + return key_list[key_list.length - 2] + } + } +} + +function get_filename(app_info, upload_key) { + if (app_info) { + let app_type = app_info.type; + if (app_type === 'iOS' || app_info === 'Android') { + return app_info.appname + '-' + app_info.version + '-' + app_info.short + '.' + get_file_type(upload_key) + } + } +} + export function uploadaliyunoss(file, certinfo, app, successcallback, processcallback) { let token = certinfo.upload_token; let uploadFileClient = new app.oss({ @@ -163,12 +181,19 @@ export function uploadaliyunoss(file, certinfo, app, successcallback, processcal uploadFileClient = client; } - const options = { + let options = { progress, parallel: 10, partSize: 1024 * 1024, timeout: 600000, }; + let filename = get_filename(certinfo.app_info, certinfo.upload_key); + if (filename) { + options['headers'] = { + 'Content-Disposition': 'attachment; filename="' + encodeURIComponent(filename) + '"', + 'Cache-Control': '' + } + } if (currentCheckpoint) { options.checkpoint = currentCheckpoint; diff --git a/fir_ser/api/utils/baseutils.py b/fir_ser/api/utils/baseutils.py index 97d7bc1..dc824ad 100644 --- a/fir_ser/api/utils/baseutils.py +++ b/fir_ser/api/utils/baseutils.py @@ -214,3 +214,17 @@ def check_app_password(app_password, password): if app_password.lower() != password.strip().lower(): return None return True + + +def get_filename_form_file(filename): + file_id_list = filename.split('.') + if file_id_list[-1] in ['ipa', 'apk']: + app_release_obj = AppReleaseInfo.objects.filter(release_id='.'.join(file_id_list[0:-1])).first() + if app_release_obj: + app_obj = app_release_obj.app_id + if app_obj.type == 0: + f_type = '.apk' + else: + f_type = '.ipa' + return f"{app_obj.name}-{app_release_obj.app_version}-{app_obj.short}{f_type}" + return filename diff --git a/fir_ser/api/utils/storage/aliyunApi.py b/fir_ser/api/utils/storage/aliyunApi.py index 749241a..e4b7f90 100644 --- a/fir_ser/api/utils/storage/aliyunApi.py +++ b/fir_ser/api/utils/storage/aliyunApi.py @@ -15,6 +15,10 @@ import re import hashlib import time import logging +from oss2 import SizedFileAdapter, determine_part_size +from oss2.models import PartInfo + +from api.utils.baseutils import get_filename_form_file logger = logging.getLogger(__name__) @@ -197,10 +201,13 @@ class AliYunOss(object): return self.del_file(old_filename) def upload_file(self, local_file_full_path): + return self.multipart_upload_file(local_file_full_path) + if os.path.isfile(local_file_full_path): filename = os.path.basename(local_file_full_path) headers = { - 'Content-Disposition': 'attachment; filename="%s"' % filename + 'Content-Disposition': 'attachment; filename="%s"' % filename, + 'Cache-Control': '' } self.bucket.put_object_from_file(filename, local_file_full_path, headers) # with open(local_file_full_path, 'rb') as fileobj: @@ -218,3 +225,39 @@ class AliYunOss(object): if not os.path.exists(dir_path): os.makedirs(dir_path) return self.bucket.get_object_to_file(name, local_file_full_path) + + def multipart_upload_file(self, local_file_full_path): + if os.path.isfile(local_file_full_path): + total_size = os.path.getsize(local_file_full_path) + # determine_part_size方法用于确定分片大小。 + part_size = determine_part_size(total_size, preferred_size=1024 * 1024 * 10) + filename = os.path.basename(local_file_full_path) + headers = { + 'Content-Disposition': 'attachment; filename="%s"' % get_filename_form_file(filename).encode( + "utf-8").decode("latin1"), + 'Cache-Control': '' + } + # 初始化分片。 + # 如需在初始化分片时设置文件存储类型,请在init_multipart_upload中设置相关headers,参考如下。 + # headers = dict() + # headers["x-oss-storage-class"] = "Standard" + upload_id = self.bucket.init_multipart_upload(filename, headers=headers).upload_id + parts = [] + + # 逐个上传分片。 + with open(local_file_full_path, 'rb') as f: + part_number = 1 + offset = 0 + while offset < total_size: + num_to_upload = min(part_size, total_size - offset) + # 调用SizedFileAdapter(fileobj, size)方法会生成一个新的文件对象,重新计算起始追加位置。 + result = self.bucket.upload_part(filename, upload_id, part_number, + SizedFileAdapter(f, num_to_upload)) + parts.append(PartInfo(part_number, result.etag)) + + offset += num_to_upload + part_number += 1 + self.bucket.complete_multipart_upload(filename, upload_id, parts) + return True + else: + logger.error(f"file {local_file_full_path} is not file") diff --git a/fir_ser/api/utils/utils.py b/fir_ser/api/utils/utils.py index 154b917..cd423ba 100644 --- a/fir_ser/api/utils/utils.py +++ b/fir_ser/api/utils/utils.py @@ -7,7 +7,7 @@ import os, datetime, random import binascii from fir_ser.settings import SERVER_DOMAIN, CAPTCHA_LENGTH, MEDIA_ROOT, CACHE_KEY_TEMPLATE from api.models import APPSuperSignUsedInfo, APPToDeveloper, \ - UDIDsyncDeveloper, UserInfo, AppReleaseInfo, AppScreenShot, Token, DeveloperDevicesID + UDIDsyncDeveloper, UserInfo, AppReleaseInfo, AppScreenShot, Token, DeveloperDevicesID, Apps from api.utils.storage.caches import get_app_d_count_by_app_id from api.utils.storage.localApi import LocalStorage from api.utils.storage.storage import Storage @@ -15,7 +15,6 @@ from api.utils.tempcaches import TmpCache from api.utils.TokenManager import generate_numeric_token_of_length, generate_alphanumeric_token_of_length, make_token, \ verify_token from api.utils.sendmsg.sendmsg import SendMessage -from django.db.models import Sum from captcha.models import CaptchaStore from captcha.helpers import captcha_image_url from api.utils.storage.caches import consume_user_download_times diff --git a/fir_ser/api/views/download.py b/fir_ser/api/views/download.py index 822c58b..3895e94 100644 --- a/fir_ser/api/views/download.py +++ b/fir_ser/api/views/download.py @@ -21,12 +21,23 @@ from api.utils.serializer import AppsShortSerializer from api.models import Apps, AppReleaseInfo, APPToDeveloper, APPSuperSignUsedInfo from django.http import FileResponse import logging -from api.utils.baseutils import get_profile_full_path, get_app_domain_name +from api.utils.baseutils import get_profile_full_path, get_app_domain_name, get_filename_form_file from api.utils.throttle import VisitShortThrottle, InstallShortThrottle, InstallThrottle1, InstallThrottle2 logger = logging.getLogger(__name__) +def file_response(stream, filename, content_type): + return FileResponse(stream, as_attachment=True, + filename=filename, + content_type=content_type) + + +def mobileprovision_file_response(file_path): + return file_response(open(file_path, 'rb'), make_random_uuid() + '.mobileprovision', + "application/x-apple-aspen-config") + + class DownloadView(APIView): """ 文件下载接口,适用于本地存储和所有plist文件下载 @@ -64,10 +75,7 @@ class DownloadView(APIView): ios_plist_bytes = make_resigned(storage.get_download_url(filename.split('.')[0] + ".ipa"), storage.get_download_url(release_obj.icon_url), bundle_id, app_version, name) - response = FileResponse(ios_plist_bytes) - response['Content-Type'] = "application/x-plist" - response['Content-Disposition'] = 'attachment; filename=' + make_random_uuid() - return response + return file_response(ios_plist_bytes, make_random_uuid(), "application/x-plist") res.msg = "plist release_id error" elif f_type == 'mobileconifg': release_obj = AppReleaseInfo.objects.filter(release_id=filename.split('.')[0]).first() @@ -76,10 +84,8 @@ class DownloadView(APIView): app_obj = release_obj.app_id ios_udid_mobile_config = make_sign_udid_mobile_config(udid_url, app_obj.app_id, app_obj.bundle_id, app_obj.name) - response = FileResponse(ios_udid_mobile_config) - response['Content-Type'] = "application/x-apple-aspen-config" - response['Content-Disposition'] = 'attachment; filename=' + make_random_uuid() + '.mobileconfig' - return response + return file_response(ios_udid_mobile_config, make_random_uuid() + '.mobileconfig', + "application/x-apple-aspen-config") res.msg = "mobile_config release_id error" elif f_type == 'mobileprovision': release_obj = AppReleaseInfo.objects.filter(release_id=filename.split('.')[0]).first() @@ -90,51 +96,34 @@ class DownloadView(APIView): if not app_super_obj: file_path = settings.DEFAULT_MOBILEPROVISION.get("supersign").get('path') - if os.path.isfile(file_path): - response = FileResponse(open(file_path, 'rb')) - else: - response = FileResponse() + if file_path and os.path.isfile(file_path): + return mobileprovision_file_response(file_path) else: developer_obj = app_super_obj.developerid file_path = get_profile_full_path(developer_obj, release_obj.app_id) if os.path.isfile(file_path): - response = FileResponse(open(file_path, 'rb')) + return mobileprovision_file_response(file_path) else: file_path = settings.DEFAULT_MOBILEPROVISION.get("supersign").get('path') - if os.path.isfile(file_path): - response = FileResponse(open(file_path, 'rb')) - else: - response = FileResponse() - response['Content-Type'] = "application/x-apple-aspen-config" - response['Content-Disposition'] = 'attachment; filename=' + make_random_uuid() + '.mobileprovision' - return response + if file_path and os.path.isfile(file_path): + return mobileprovision_file_response(file_path) + res.msg = "mobile_provision release_id error" - elif f_type == 'dmobileprovision': + elif f_type == 'dmobileprovision': # 企业签名安装信任跳转 release_obj = AppReleaseInfo.objects.filter(release_id=filename.split('.')[0]).first() if release_obj: - file_path = settings.DEFAULT_MOBILEPROVISION.get("supersign").get('path') - if os.path.isfile(file_path): - response = FileResponse(open(file_path, 'rb')) - else: - response = FileResponse() - response['Content-Type'] = "application/x-apple-aspen-config" - response['Content-Disposition'] = 'attachment; filename=' + make_random_uuid() + '.mobileprovision' - return response + file_path = settings.DEFAULT_MOBILEPROVISION.get("enterprise").get('path') + if file_path and os.path.isfile(file_path): + return mobileprovision_file_response(file_path) res.msg = "d_mobile_provision release_id error" - else: file_path = os.path.join(settings.MEDIA_ROOT, filename) try: if os.path.isfile(file_path): - response = FileResponse(open(file_path, 'rb')) - else: - response = FileResponse() + return FileResponse(open(file_path, 'rb'), as_attachment=True, + filename=get_filename_form_file(filename)) except Exception as e: logger.error(f"read {file_path} failed Exception:{e}") - response = FileResponse() - response['content_type'] = "application/octet-stream" - response['Content-Disposition'] = 'attachment; filename=' + filename - return response res.code = 1004 res.msg = "token校验失败" diff --git a/fir_ser/cli.py b/fir_ser/cli.py index 111745f..3c1102d 100644 --- a/fir_ser/cli.py +++ b/fir_ser/cli.py @@ -10,7 +10,10 @@ import random import re import json import zipfile -from requests_toolbelt import MultipartEncoder + +from oss2 import determine_part_size, SizedFileAdapter +from oss2.models import PartInfo +from requests_toolbelt.multipart.encoder import MultipartEncoderMonitor import oss2 import requests from androguard.core.bytecodes import apk @@ -24,24 +27,65 @@ pip install androguard ''' -def upload_aliyunoss(access_key_id, access_key_secret, security_token, endpoint, bucket, file_path, file_name): +def progress(percent, width=50): + if percent >= 1: + percent = 1 + show_str = ('[%%-%ds]' % width) % (int(width * percent) * '#') + print('\r%s %d%% ' % (show_str, int(100 * percent)), file=sys.stdout, flush=True, end='') + + +def local_upload_callback(monitor): + progress(monitor.bytes_read / monitor.len, width=100) + + +def qiniu_progress_callback(upload_size, total_size): + progress(upload_size / total_size, width=100) + + +def alioss_progress_callback_fun(offset, total_size): + def progress_callback(upload_size, now_part_size): + percent = (offset + upload_size) / total_size # 接收的比例 + progress(percent, width=100) + + return progress_callback + + +def upload_aliyunoss(access_key_id, access_key_secret, security_token, endpoint, bucket, local_file_full_path, filename, + headers=None): stsauth = oss2.StsAuth(access_key_id, access_key_secret, security_token) bucket = oss2.Bucket(stsauth, endpoint, bucket) - with open(file_path, 'rb') as fileobj: - bucket.put_object(file_name, fileobj) + total_size = os.path.getsize(local_file_full_path) + # determine_part_size方法用于确定分片大小。 + part_size = determine_part_size(total_size, preferred_size=1024 * 1024 * 10) + upload_id = bucket.init_multipart_upload(filename, headers=headers).upload_id + parts = [] + # 逐个上传分片。 + + with open(local_file_full_path, 'rb') as f: + part_number = 1 + offset = 0 + while offset < total_size: + num_to_upload = min(part_size, total_size - offset) + result = bucket.upload_part(filename, upload_id, part_number, + SizedFileAdapter(f, num_to_upload), + progress_callback=alioss_progress_callback_fun(offset, total_size)) + parts.append(PartInfo(part_number, result.etag)) + offset += num_to_upload + part_number += 1 + bucket.complete_multipart_upload(filename, upload_id, parts) print("数据上传存储成功.") def upload_qiniuyunoss(key, token, file_path): from qiniu import put_file - ret, info = put_file(token, key, file_path) + ret, info = put_file(token, key, file_path, progress_handler=qiniu_progress_callback) if info.status_code == 200: print("数据上传存储成功.") else: raise AssertionError(info.text) -class FLY_CLI_SER(object): +class FLYCliSer(object): def __init__(self, fly_cli_domain, fly_cli_token): self.fly_cli_domain = fly_cli_domain @@ -72,7 +116,7 @@ class FLY_CLI_SER(object): def upload_local_storage(self, upload_key, upload_token, app_id, file_path): url = '%s/api/v2/fir/server/upload' % self.fly_cli_domain - m = MultipartEncoder(fields={ + m = MultipartEncoderMonitor.from_fields(fields={ 'file': (os.path.basename(file_path), open(file_path, 'rb'), 'application/octet-stream'), 'certinfo': json.dumps( { @@ -82,7 +126,7 @@ class FLY_CLI_SER(object): 'app_id': app_id, } ), - }) + }, callback=local_upload_callback) header = { 'Content-Type': m.content_type, } @@ -99,6 +143,16 @@ class FLY_CLI_SER(object): icon_path = appobj.make_app_png(icon_path=appinfo.get("icon_path", None)) bundle_id = appinfo.get("bundle_id") upcretsdata = self.get_upload_token(bundle_id, appinfo.get("type")) + if appinfo.get('iOS', '') == 'iOS': + f_type = '.ipa' + else: + f_type = '.apk' + filename = "%s-%s-%s%s" % (appinfo.get('labelname'), appinfo.get('version'), upcretsdata.get('short'), f_type) + headers = { + 'Content-Disposition': 'attachment; filename="%s"' % filename.encode( + "utf-8").decode("latin1"), + 'Cache-Control': '' + } if upcretsdata['storage'] == 1: # 七牛云存储 upload_qiniuyunoss(upcretsdata['png_key'], upcretsdata['png_token'], icon_path) @@ -113,7 +167,7 @@ class FLY_CLI_SER(object): file_auth = upcretsdata['upload_token'] upload_aliyunoss(file_auth['access_key_id'], file_auth['access_key_secret'], file_auth['security_token'], - file_auth['endpoint'], file_auth['bucket'], app_path, upcretsdata['upload_key']) + file_auth['endpoint'], file_auth['bucket'], app_path, upcretsdata['upload_key'], headers) elif upcretsdata['storage'] == 3: # 本地存储 self.upload_local_storage(upcretsdata['png_key'], upcretsdata['png_token'], upcretsdata['app_uuid'], @@ -267,9 +321,9 @@ class AppInfo(object): if __name__ == '__main__': - fly_cli_domain = 'https://fly.harmonygames.cn' - fly_cli_token = '4baf717aff0011ea80c000163e069e27x2gRvmf2035rP3YWITJxvF4VVE32M55AuVHxOvSbTgdwgF54UeXFzrNLkP1lgUID' - fly_obj = FLY_CLI_SER(fly_cli_domain, fly_cli_token) + fly_cli_domain = 'https://app.hehelucky.cn' + fly_cli_token = 'f63d05d2bb0511eb9fa400163e069e27oTa51Q3N6rJT5yjddCrkmgiFeKRQNa0L3TctPLTYd0CrdIqgO3wfYCKsFnJysQXJ' + fly_obj = FLYCliSer(fly_cli_domain, fly_cli_token) if len(sys.argv) != 2: raise ValueError('参数有误') app_path = sys.argv[1] diff --git a/fir_ser/config.py b/fir_ser/config.py index 42c8b52..d8397cf 100644 --- a/fir_ser/config.py +++ b/fir_ser/config.py @@ -3,10 +3,12 @@ # project: 7月 # author: NinEveN # date: 2021/7/19 +import os API_DOMAIN = "https://app.hehelucky.cn" WEB_DOMAIN = "https://app.hehelucky.cn" -MOBILEPROVISION = "https://ali-static.jappstore.com/embedded2.mobileprovision" +MOBILEPROVISION = "https://ali-static.jappstore.com/embedded3.mobileprovision" +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) class BASECONF(object): @@ -231,12 +233,14 @@ class IPACONF(object): # 默认描述文件路径或者下载路径,用户企业签名或者超级签名 跳转 [设置 - 通用 - 描述文件|设备管理] 页面 # 如果配置了path路径,则走路径,如果配置了url,则走URL,path 优先级大于url优先级 'enterprise': { - 'url': MOBILEPROVISION + 'url': MOBILEPROVISION, + # 'path': os.path.join(BASE_DIR,'files', 'embedded.mobileprovision'), }, 'supersign': { # 超级签名,如果self 为True,则默认用自己的描述文件,否则同企业配置顺序一致,自己的配置文件有时候有问题 'self': False, - 'url': MOBILEPROVISION + 'url': MOBILEPROVISION, + # 'path': os.path.join(BASE_DIR,'files', 'embedded.mobileprovision'), } } diff --git a/fir_ser/files/embedded.mobileprovision b/fir_ser/files/embedded.mobileprovision index e72944635330acb0070c6201564d6a27a6c367dc..5006a2bff936e9e0da09cb789a26c8e6fcf85eac 100644 GIT binary patch literal 12749 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 delta 4842 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 K{hOH=PW?Ah`+-FO