更新
This commit is contained in:
@@ -100,6 +100,7 @@ export interface OssCredentialsResponse {
|
||||
host?: string
|
||||
cdn_domain?: string
|
||||
key_prefix?: string
|
||||
object_key?: string
|
||||
max_size?: number
|
||||
duration?: number
|
||||
expired_time?: number
|
||||
@@ -111,8 +112,10 @@ export interface OssCredentialsResponse {
|
||||
}
|
||||
}
|
||||
|
||||
export type OssDirectUploadType = 'video' | 'voice' | 'desktop_package'
|
||||
|
||||
/** 申请 STS 临时凭证 */
|
||||
export function getOssCredentials(params: { type: 'video' }) {
|
||||
export function getOssCredentials(params: { type: OssDirectUploadType; name?: string }) {
|
||||
return request.post({
|
||||
url: '/upload/ossCredentials',
|
||||
params
|
||||
@@ -121,7 +124,7 @@ export function getOssCredentials(params: { type: 'video' }) {
|
||||
|
||||
/** 直传完成回执:写 file 表 + HEAD 校验 */
|
||||
export function confirmOssUpload(params: {
|
||||
type: 'video'
|
||||
type: OssDirectUploadType
|
||||
key: string
|
||||
name: string
|
||||
size: number
|
||||
|
||||
@@ -51,8 +51,10 @@ import useAppStore from '@/stores/modules/app'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import feedback from '@/utils/feedback'
|
||||
import {
|
||||
DirectUploadApiError,
|
||||
DirectUploadFallbackError,
|
||||
uploadVideoDirectToCos
|
||||
uploadDirectToCos,
|
||||
type DirectUploadType
|
||||
} from '@/utils/oss-direct-upload'
|
||||
|
||||
export default defineComponent({
|
||||
@@ -83,7 +85,7 @@ export default defineComponent({
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 视频直传到 OSS(绕开服务器中转,仅 type=video 生效)
|
||||
// 直传到对象存储,绕开服务器中转
|
||||
direct: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
@@ -102,8 +104,10 @@ export default defineComponent({
|
||||
const visible = ref(false)
|
||||
const fileList = ref<any[]>([])
|
||||
|
||||
// 仅 video/voice + direct 时才接管 http-request
|
||||
const useDirect = computed(() => props.direct && ['video', 'voice'].includes(props.type))
|
||||
const directTypes: DirectUploadType[] = ['video', 'voice', 'desktop_package']
|
||||
const useDirect = computed(
|
||||
() => props.direct && directTypes.includes(props.type as DirectUploadType)
|
||||
)
|
||||
|
||||
const handleProgress = () => {
|
||||
visible.value = true
|
||||
@@ -131,7 +135,10 @@ export default defineComponent({
|
||||
fileList.value = []
|
||||
emit('allSuccess')
|
||||
}
|
||||
feedback.msgError(`${file.name}文件上传失败`)
|
||||
if (!(event instanceof DirectUploadApiError)) {
|
||||
const message = event instanceof Error ? event.message : ''
|
||||
feedback.msgError(message || `${file.name}文件上传失败`)
|
||||
}
|
||||
uploadRefs.value?.abort(file)
|
||||
visible.value = false
|
||||
emit('change', file)
|
||||
@@ -153,18 +160,20 @@ export default defineComponent({
|
||||
return '.wmv,.avi,.mpg,.mpeg,.3gp,.mov,.mp4,.flv,.rmvb,.mkv'
|
||||
case 'voice':
|
||||
return '.mp3,.wav,.wma,.m4a,.aac,.amr'
|
||||
case 'desktop_package':
|
||||
return '.exe,.zip'
|
||||
default:
|
||||
return '*'
|
||||
}
|
||||
})
|
||||
|
||||
// 走 COS 直传:成功时模拟老接口的响应 envelope,失败/降级时回到默认 XHR
|
||||
// 走 COS 直传:成功时模拟老接口的响应 envelope
|
||||
const httpRequest = async (options: UploadRequestOptions) => {
|
||||
visible.value = true
|
||||
try {
|
||||
const data = await uploadVideoDirectToCos({
|
||||
const data = await uploadDirectToCos({
|
||||
file: options.file,
|
||||
type: props.type as any,
|
||||
type: props.type as DirectUploadType,
|
||||
cid: Number((options.data as any)?.cid ?? 0),
|
||||
onProgress(info) {
|
||||
// 触发 ElUpload 内部进度(保持与默认上传一致的体验)
|
||||
@@ -178,6 +187,12 @@ export default defineComponent({
|
||||
;(options as any).onSuccess?.({ code: RequestCodeEnum.SUCCESS, msg: 'ok', data })
|
||||
} catch (err: any) {
|
||||
if (err instanceof DirectUploadFallbackError) {
|
||||
if (props.type === 'desktop_package') {
|
||||
;(options as any).onError?.(
|
||||
new Error('当前未启用腾讯云 COS,安装包无法直传,请配置 COS 后重试')
|
||||
)
|
||||
return
|
||||
}
|
||||
feedback.msgWarning('当前存储不支持直传,已切换为普通上传')
|
||||
await defaultXhrUpload(options)
|
||||
return
|
||||
|
||||
@@ -3,10 +3,11 @@ import COS from 'cos-js-sdk-v5'
|
||||
import {
|
||||
confirmOssUpload,
|
||||
getOssCredentials,
|
||||
type OssCredentialsResponse
|
||||
type OssCredentialsResponse,
|
||||
type OssDirectUploadType
|
||||
} from '@/api/file'
|
||||
|
||||
export type DirectUploadType = 'video'
|
||||
export type DirectUploadType = OssDirectUploadType
|
||||
|
||||
export interface DirectUploadProgress {
|
||||
/** 0-100 */
|
||||
@@ -37,8 +38,17 @@ export interface DirectUploadOptions {
|
||||
const SLICE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
const ASYNC_LIMIT = 3
|
||||
|
||||
async function callDirectUploadApi<T>(request: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await request()
|
||||
} catch (error) {
|
||||
// request 拦截器已经展示过接口/网络错误,上传组件只负责收口失败状态
|
||||
throw new DirectUploadApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function buildKey(prefix: string, file: File): string {
|
||||
const ext = (file.name.split('.').pop() || 'mp4').toLowerCase()
|
||||
const ext = (file.name.split('.').pop() || 'bin').toLowerCase()
|
||||
const ts = Date.now()
|
||||
const rand = Math.random().toString(36).slice(2, 10)
|
||||
return `${prefix}${ts}-${rand}.${ext}`
|
||||
@@ -48,8 +58,13 @@ function buildKey(prefix: string, file: File): string {
|
||||
* 直传到腾讯云 COS(含 STS 凭证申请、分片上传、回执)
|
||||
* 不支持降级 / fallback=true 时抛错,由调用方决定走老链路。
|
||||
*/
|
||||
export async function uploadVideoDirectToCos(options: DirectUploadOptions): Promise<DirectUploadResult> {
|
||||
const credentials: OssCredentialsResponse = await getOssCredentials({ type: options.type })
|
||||
export async function uploadDirectToCos(options: DirectUploadOptions): Promise<DirectUploadResult> {
|
||||
const credentials: OssCredentialsResponse = await callDirectUploadApi(() =>
|
||||
getOssCredentials({
|
||||
type: options.type,
|
||||
name: options.file.name
|
||||
})
|
||||
)
|
||||
|
||||
if (credentials.fallback) {
|
||||
const handled = options.onFallback?.(credentials.provider) ?? false
|
||||
@@ -66,7 +81,7 @@ export async function uploadVideoDirectToCos(options: DirectUploadOptions): Prom
|
||||
|
||||
if (credentials.max_size && options.file.size > credentials.max_size) {
|
||||
const mb = Math.round(credentials.max_size / 1024 / 1024)
|
||||
throw new Error(`视频体积超出上限(${mb}MB)`)
|
||||
throw new Error(`文件体积超出上限(${mb}MB)`)
|
||||
}
|
||||
|
||||
const cred = credentials.credentials
|
||||
@@ -85,7 +100,7 @@ export async function uploadVideoDirectToCos(options: DirectUploadOptions): Prom
|
||||
}
|
||||
})
|
||||
|
||||
const key = buildKey(credentials.key_prefix, options.file)
|
||||
const key = credentials.object_key || buildKey(credentials.key_prefix, options.file)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
cos.uploadFile(
|
||||
@@ -117,14 +132,16 @@ export async function uploadVideoDirectToCos(options: DirectUploadOptions): Prom
|
||||
)
|
||||
})
|
||||
|
||||
const confirmed = await confirmOssUpload({
|
||||
type: options.type,
|
||||
key,
|
||||
name: options.file.name,
|
||||
size: options.file.size,
|
||||
content_type: options.file.type || '',
|
||||
cid: options.cid ?? 0
|
||||
})
|
||||
const confirmed = await callDirectUploadApi(() =>
|
||||
confirmOssUpload({
|
||||
type: options.type,
|
||||
key,
|
||||
name: options.file.name,
|
||||
size: options.file.size,
|
||||
content_type: options.file.type || '',
|
||||
cid: options.cid ?? 0
|
||||
})
|
||||
)
|
||||
|
||||
options.onProgress?.({ percent: 100, loaded: options.file.size, total: options.file.size, speed: 0 })
|
||||
|
||||
@@ -140,3 +157,14 @@ export class DirectUploadFallbackError extends Error {
|
||||
this.provider = provider
|
||||
}
|
||||
}
|
||||
|
||||
/** 请求层已经展示过错误,避免 ElUpload 再弹一条通用失败提示。 */
|
||||
export class DirectUploadApiError extends Error {
|
||||
readonly originalError: unknown
|
||||
|
||||
constructor(error: unknown) {
|
||||
super('')
|
||||
this.name = 'DirectUploadApiError'
|
||||
this.originalError = error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
<code>一键打包</code>
|
||||
产物一致的安装包,并填入打包目录中的 SHA-256。Windows 推荐使用
|
||||
Setup.exe,用户点击“立即更新”后会自动安装并重启;macOS 继续使用 ZIP。
|
||||
安装包通常超过 200MB,优先传到对象存储 / CDN 后粘贴地址。
|
||||
安装包通常超过 200MB,本页上传按钮会直传到已配置的腾讯云 COS;也可以
|
||||
自行上传到其他对象存储 / CDN 后粘贴地址。
|
||||
</div>
|
||||
</el-alert>
|
||||
<div class="text-xl font-medium mb-[20px]">升级策略</div>
|
||||
@@ -125,7 +126,9 @@
|
||||
<el-form-item label="上传安装包">
|
||||
<div>
|
||||
<upload
|
||||
type="file"
|
||||
v-perms="['setting.desktop_workstation/setConfig']"
|
||||
type="desktop_package"
|
||||
direct
|
||||
:limit="1"
|
||||
:multiple="false"
|
||||
:show-progress="true"
|
||||
@@ -136,8 +139,8 @@
|
||||
<el-button type="primary" plain>选择安装包并上传</el-button>
|
||||
</upload>
|
||||
<div class="form-tips">
|
||||
仅建议上传较小的包。大文件请先传到对象存储,再把地址和 SHA-256
|
||||
填到上方。Windows 自动安装程序必须使用 HTTPS 地址并开启证书校验。
|
||||
安装包将分片直传腾讯云 COS,不经过业务服务器(支持 EXE / ZIP,最大
|
||||
2GB)。Windows 自动安装程序必须使用 HTTPS 地址并开启证书校验。
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
__all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"]
|
||||
|
||||
# Single source of truth for runtime, package, installer, and executable versions.
|
||||
__version__ = "1.2.0"
|
||||
__version__ = "1.4.0"
|
||||
|
||||
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
|
||||
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
namespace app\adminapi\controller;
|
||||
|
||||
|
||||
use app\common\cache\AdminAuthCache;
|
||||
use app\common\service\DirectUploadService;
|
||||
use app\common\service\UploadService;
|
||||
use Exception;
|
||||
@@ -86,7 +87,12 @@ class UploadController extends BaseAdminController
|
||||
{
|
||||
$type = trim((string)$this->request->post('type', 'video'));
|
||||
try {
|
||||
$result = DirectUploadService::issueCredentials($type);
|
||||
$this->assertDirectUploadPermission($type);
|
||||
$result = DirectUploadService::issueCredentials(
|
||||
$type,
|
||||
$this->adminId,
|
||||
trim((string)$this->request->post('name', ''))
|
||||
);
|
||||
return $this->success('ok', $result);
|
||||
} catch (Exception $e) {
|
||||
return $this->fail($e->getMessage());
|
||||
@@ -100,8 +106,10 @@ class UploadController extends BaseAdminController
|
||||
public function ossConfirm()
|
||||
{
|
||||
try {
|
||||
$type = trim((string)$this->request->post('type', 'video'));
|
||||
$this->assertDirectUploadPermission($type);
|
||||
$result = DirectUploadService::confirm([
|
||||
'type' => trim((string)$this->request->post('type', 'video')),
|
||||
'type' => $type,
|
||||
'key' => trim((string)$this->request->post('key', '')),
|
||||
'name' => trim((string)$this->request->post('name', '')),
|
||||
'size' => (int)$this->request->post('size', 0),
|
||||
@@ -115,4 +123,22 @@ class UploadController extends BaseAdminController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装包属于发布能力,不能沿用普通素材上传的“登录即放行”。
|
||||
* @throws Exception
|
||||
*/
|
||||
private function assertDirectUploadPermission(string $type): void
|
||||
{
|
||||
if ($type !== DirectUploadService::TYPE_DESKTOP_PACKAGE
|
||||
|| (int)($this->adminInfo['root'] ?? 0) === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$permissions = (new AdminAuthCache($this->adminId))->getAdminUri() ?? [];
|
||||
$permissions = array_map('strtolower', $permissions);
|
||||
if (!in_array('setting.desktop_workstation/setconfig', $permissions, true)) {
|
||||
throw new Exception('权限不足,无法上传医生工作站安装包');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ class DirectUploadService
|
||||
/** 视频允许的扩展名(沿用 config/project.file_video) */
|
||||
public const TYPE_VIDEO = 'video';
|
||||
public const TYPE_VOICE = 'voice';
|
||||
public const TYPE_DESKTOP_PACKAGE = 'desktop_package';
|
||||
|
||||
/** 默认凭证有效期 30 分钟 */
|
||||
public const DEFAULT_DURATION = 1800;
|
||||
@@ -28,6 +29,7 @@ class DirectUploadService
|
||||
private const MAX_SIZE = [
|
||||
self::TYPE_VIDEO => 2 * 1024 * 1024 * 1024, // 2GB
|
||||
self::TYPE_VOICE => 500 * 1024 * 1024, // 500MB
|
||||
self::TYPE_DESKTOP_PACKAGE => 2 * 1024 * 1024 * 1024, // 2GB
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -36,7 +38,7 @@ class DirectUploadService
|
||||
* @return array
|
||||
* @throws Exception
|
||||
*/
|
||||
public static function issueCredentials(string $type): array
|
||||
public static function issueCredentials(string $type, int $adminId = 0, string $name = ''): array
|
||||
{
|
||||
if (!isset(self::MAX_SIZE[$type])) {
|
||||
throw new Exception('不支持的上传类型: ' . $type);
|
||||
@@ -54,9 +56,27 @@ class DirectUploadService
|
||||
throw new Exception('腾讯云 COS 配置不完整');
|
||||
}
|
||||
|
||||
$keyPrefix = self::buildKeyPrefix($type);
|
||||
$keyPrefix = self::buildKeyPrefix($type, $adminId);
|
||||
$objectKey = '';
|
||||
// 兼容前后端错峰发布:旧 uploader 只传 type,不传 name。
|
||||
// 新 uploader 仍使用更严格的单对象授权;旧版则限制在当前管理员当天目录,
|
||||
// 并在 confirm 阶段校验文件名、扩展名与实际对象。
|
||||
if ($type === self::TYPE_DESKTOP_PACKAGE && trim($name) !== '') {
|
||||
$extension = strtolower((string)pathinfo($name, PATHINFO_EXTENSION));
|
||||
$objectKey = $keyPrefix
|
||||
. (int)round(microtime(true) * 1000)
|
||||
. '-'
|
||||
. bin2hex(random_bytes(8))
|
||||
. ($extension !== '' ? '.' . $extension : '');
|
||||
self::validateFileExtension($type, $objectKey, $name);
|
||||
}
|
||||
$engine = new QcloudEngine($storageConfig);
|
||||
$sts = $engine->getStsCredentials($keyPrefix, self::MAX_SIZE[$type], self::DEFAULT_DURATION);
|
||||
$sts = $engine->getStsCredentials(
|
||||
$objectKey !== '' ? $objectKey : $keyPrefix,
|
||||
self::MAX_SIZE[$type],
|
||||
self::DEFAULT_DURATION,
|
||||
$objectKey !== ''
|
||||
);
|
||||
|
||||
return [
|
||||
'provider' => 'qcloud',
|
||||
@@ -66,6 +86,7 @@ class DirectUploadService
|
||||
'host' => $sts['host'],
|
||||
'cdn_domain' => rtrim((string)($storageConfig['domain'] ?? ''), '/'),
|
||||
'key_prefix' => $keyPrefix,
|
||||
'object_key' => $objectKey,
|
||||
'max_size' => self::MAX_SIZE[$type],
|
||||
'duration' => self::DEFAULT_DURATION,
|
||||
'expired_time' => $sts['expiredTime'],
|
||||
@@ -93,8 +114,7 @@ class DirectUploadService
|
||||
}
|
||||
|
||||
$key = ltrim((string)($params['key'] ?? ''), '/');
|
||||
$allowedPrefix = self::buildKeyPrefix($type);
|
||||
if ($key === '' || strpos($key, $allowedPrefix) !== 0) {
|
||||
if (!self::isAllowedObjectKey($type, $key, (int)($params['admin_id'] ?? 0))) {
|
||||
throw new Exception('对象 Key 非法');
|
||||
}
|
||||
|
||||
@@ -112,6 +132,7 @@ class DirectUploadService
|
||||
if ($name === '') {
|
||||
$name = basename($key);
|
||||
}
|
||||
self::validateFileExtension($type, $key, $name);
|
||||
if (strlen($name) > 128) {
|
||||
$name = substr($name, 0, 123) . substr($name, -5);
|
||||
}
|
||||
@@ -137,9 +158,16 @@ class DirectUploadService
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildKeyPrefix(string $type): string
|
||||
private static function buildKeyPrefix(string $type, int $adminId = 0): string
|
||||
{
|
||||
return 'uploads/' . $type . '/' . date('Ymd') . '/';
|
||||
$prefix = 'uploads/' . $type . '/';
|
||||
if ($type === self::TYPE_DESKTOP_PACKAGE) {
|
||||
if ($adminId <= 0) {
|
||||
throw new Exception('安装包上传账号无效');
|
||||
}
|
||||
$prefix .= $adminId . '/';
|
||||
}
|
||||
return $prefix . date('Ymd') . '/';
|
||||
}
|
||||
|
||||
private static function resolveFileType(string $type): int
|
||||
@@ -150,4 +178,46 @@ class DirectUploadService
|
||||
default => FileEnum::FILE_TYPE,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 桌面安装包是可执行文件,只允许发布流程所需的 EXE / ZIP。
|
||||
*/
|
||||
private static function validateFileExtension(string $type, string $key, string $name): void
|
||||
{
|
||||
if ($type !== self::TYPE_DESKTOP_PACKAGE) {
|
||||
return;
|
||||
}
|
||||
|
||||
$nameExtension = strtolower((string)pathinfo($name, PATHINFO_EXTENSION));
|
||||
$keyExtension = strtolower((string)pathinfo($key, PATHINFO_EXTENSION));
|
||||
$allowedExtensions = ['exe', 'zip'];
|
||||
if (!in_array($nameExtension, $allowedExtensions, true)
|
||||
|| $nameExtension !== $keyExtension) {
|
||||
throw new Exception('桌面安装包仅支持 EXE 或 ZIP 文件');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装包 Key 绑定上传管理员,并兼容跨午夜完成的上传。
|
||||
*/
|
||||
private static function isAllowedObjectKey(string $type, string $key, int $adminId): bool
|
||||
{
|
||||
if ($key === '') {
|
||||
return false;
|
||||
}
|
||||
if ($type !== self::TYPE_DESKTOP_PACKAGE) {
|
||||
return strpos($key, self::buildKeyPrefix($type)) === 0;
|
||||
}
|
||||
if ($adminId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ownerPrefix = 'uploads/' . self::TYPE_DESKTOP_PACKAGE . '/' . $adminId . '/';
|
||||
if (strpos($key, $ownerPrefix) !== 0) {
|
||||
return false;
|
||||
}
|
||||
$date = substr($key, strlen($ownerPrefix), 8);
|
||||
return in_array($date, [date('Ymd'), date('Ymd', time() - 86400)], true)
|
||||
&& substr($key, strlen($ownerPrefix) + 8, 1) === '/';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,13 +116,19 @@ class Qcloud extends Server
|
||||
|
||||
/**
|
||||
* @notes 获取 STS 临时凭证(用于浏览器直传)
|
||||
* @param string $keyPrefix 资源前缀,如 uploads/video/20260508/
|
||||
* @param string $keyScope 资源前缀或完整对象 Key
|
||||
* @param int $maxSizeBytes 单文件大小上限(字节)
|
||||
* @param int $durationSeconds 凭证有效期(秒)
|
||||
* @param bool $exactObject 是否只授权单个对象 Key
|
||||
* @return array {credentials, expiredTime, requestId}
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getStsCredentials(string $keyPrefix, int $maxSizeBytes, int $durationSeconds = 1800): array
|
||||
public function getStsCredentials(
|
||||
string $keyScope,
|
||||
int $maxSizeBytes,
|
||||
int $durationSeconds = 1800,
|
||||
bool $exactObject = false
|
||||
): array
|
||||
{
|
||||
$bucket = $this->config['bucket'];
|
||||
// bucket 形如 likeadmin-1300000000,appId 即末段
|
||||
@@ -137,15 +143,19 @@ class Qcloud extends Server
|
||||
|
||||
$shortBucket = substr($bucket, 0, strrpos($bucket, '-'));
|
||||
$region = $this->config['region'];
|
||||
$prefix = ltrim($keyPrefix, '/');
|
||||
if ($prefix === '' || substr($prefix, -1) !== '/') {
|
||||
$prefix = $prefix . '/';
|
||||
$scope = ltrim($keyScope, '/');
|
||||
if ($scope === '') {
|
||||
throw new Exception('COS 授权对象不能为空');
|
||||
}
|
||||
if (!$exactObject && substr($scope, -1) !== '/') {
|
||||
$scope .= '/';
|
||||
}
|
||||
|
||||
$duration = max(900, min($durationSeconds, 7200));
|
||||
|
||||
// 自行构造 policy:对象级写动作收紧 + bucket 级 ListMultipartUploads(cos-js-sdk-v5 续传探测必需)
|
||||
$objectArn = sprintf('qcs::cos:%s:uid/%s:%s/%s*', $region, $appId, $bucket, $prefix);
|
||||
$objectResource = $exactObject ? $scope : $scope . '*';
|
||||
$objectArn = sprintf('qcs::cos:%s:uid/%s:%s/%s', $region, $appId, $bucket, $objectResource);
|
||||
$bucketArn = sprintf('qcs::cos:%s:uid/%s:%s/*', $region, $appId, $bucket);
|
||||
|
||||
$policy = [
|
||||
|
||||
@@ -4,7 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
use app\adminapi\logic\setting\DesktopWorkstationLogic;
|
||||
use app\adminapi\logic\setting\DesktopWorkstationLogic;
|
||||
use app\common\service\DirectUploadService;
|
||||
|
||||
function desktopUpdateExpect(bool $condition, string $message): void
|
||||
{
|
||||
@@ -92,14 +93,69 @@ desktopUpdateExpect(
|
||||
|
||||
$adminView = file_get_contents(dirname(__DIR__, 2) . '/admin/src/views/setting/desktop_workstation/index.vue');
|
||||
desktopUpdateExpect(is_string($adminView), 'admin view source is readable');
|
||||
desktopUpdateExpect(
|
||||
desktopUpdateExpect(
|
||||
str_contains($adminView, 'setting.desktop_workstation/setConfig')
|
||||
&& str_contains($adminView, 'force_update')
|
||||
&& str_contains($adminView, 'inno_setup'),
|
||||
'admin page can save force-update and Inno Setup configuration'
|
||||
&& str_contains($adminView, 'inno_setup')
|
||||
&& str_contains($adminView, 'type="desktop_package"')
|
||||
&& str_contains($adminView, 'direct'),
|
||||
'admin page saves update settings and sends installers through direct upload'
|
||||
);
|
||||
|
||||
$migration = file_get_contents(
|
||||
|
||||
$directUploadReflection = new ReflectionClass(DirectUploadService::class);
|
||||
$validateExtension = $directUploadReflection->getMethod('validateFileExtension');
|
||||
$validateExtension->invoke(
|
||||
null,
|
||||
DirectUploadService::TYPE_DESKTOP_PACKAGE,
|
||||
'uploads/desktop_package/7/' . date('Ymd') . '/package.exe',
|
||||
'DoctorWorkstation.EXE'
|
||||
);
|
||||
$invalidExtensionRejected = false;
|
||||
try {
|
||||
$validateExtension->invoke(
|
||||
null,
|
||||
DirectUploadService::TYPE_DESKTOP_PACKAGE,
|
||||
'uploads/desktop_package/7/' . date('Ymd') . '/package.php',
|
||||
'package.php'
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
$invalidExtensionRejected = true;
|
||||
}
|
||||
desktopUpdateExpect($invalidExtensionRejected, 'desktop direct upload rejects non-EXE/ZIP files');
|
||||
|
||||
$validateObjectKey = $directUploadReflection->getMethod('isAllowedObjectKey');
|
||||
$ownedKey = 'uploads/desktop_package/7/' . date('Ymd') . '/package.exe';
|
||||
desktopUpdateExpect(
|
||||
$validateObjectKey->invoke(null, DirectUploadService::TYPE_DESKTOP_PACKAGE, $ownedKey, 7) === true
|
||||
&& $validateObjectKey->invoke(null, DirectUploadService::TYPE_DESKTOP_PACKAGE, $ownedKey, 8) === false,
|
||||
'desktop package keys are bound to the issuing admin'
|
||||
);
|
||||
|
||||
$uploadController = file_get_contents(dirname(__DIR__) . '/app/adminapi/controller/UploadController.php');
|
||||
$qcloudEngine = file_get_contents(dirname(__DIR__) . '/app/common/service/storage/engine/Qcloud.php');
|
||||
$directUploadService = file_get_contents(
|
||||
dirname(__DIR__) . '/app/common/service/DirectUploadService.php'
|
||||
);
|
||||
desktopUpdateExpect(
|
||||
is_string($uploadController)
|
||||
&& str_contains($uploadController, 'assertDirectUploadPermission')
|
||||
&& str_contains($uploadController, 'setting.desktop_workstation/setconfig'),
|
||||
'desktop package credentials and confirmation require publish permission'
|
||||
);
|
||||
desktopUpdateExpect(
|
||||
is_string($qcloudEngine)
|
||||
&& str_contains($qcloudEngine, 'bool $exactObject = false')
|
||||
&& str_contains($qcloudEngine, '$exactObject ? $scope : $scope .'),
|
||||
'COS credentials can be restricted to one server-issued object key'
|
||||
);
|
||||
desktopUpdateExpect(
|
||||
is_string($directUploadService)
|
||||
&& str_contains($directUploadService, "trim(\$name) !== ''")
|
||||
&& str_contains($directUploadService, '$objectKey !== \'\''),
|
||||
'desktop credentials remain compatible with uploaders that do not send a filename'
|
||||
);
|
||||
|
||||
$migration = file_get_contents(
|
||||
dirname(__DIR__) . '/sql/1.9.20260821/add_desktop_workstation_update_menu.sql'
|
||||
);
|
||||
desktopUpdateExpect(is_string($migration), 'menu migration is readable');
|
||||
|
||||
Reference in New Issue
Block a user