fix:重写MinIO上传-使用uni.request+实际文件sha256签名,去掉XHR

This commit is contained in:
97694732@qq.com 2026-06-05 11:24:23 +08:00
parent b9dc96dd15
commit 8de04e8d7c

View File

@ -1,23 +1,14 @@
/**
* MinIO 文件上传工具
* 使用 AWS Signature V4 签名通过 PUT 直传到 MinIO
* 使用 AWS Signature V4 签名通过 uni.request PUT 直传到 MinIO
*
* 签名算法与 curl/fetch 完全一致
* SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date
* x-amz-content-sha256 = 实际文件内容sha256
*/
import sha256 from 'js-sha256'
// ====== Polyfill: atob (某些 App 环境不支持) ======
if (typeof atob === 'undefined') {
var atob = function (input) {
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
var output = ''
input = input.replace(/=+$/, '')
for (var bc = 0, bs = 0, buffer, i = 0; buffer = input.charAt(i++); ~buffer && (bs = bc % 4 ? bs * 64 + buffer : buffer, bc++ % 4) ? output += String.fromCharCode(255 & bs >> (-2 * bc & 6)) : 0) {
buffer = chars.indexOf(buffer)
}
return output
}
}
// ====== MinIO 配置 ======
const CFG = {
endpoint: 'shxy.image.yushutx.com',
@ -43,22 +34,15 @@ function hexToBytes(hex) {
}
function bytesToHex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
return Array.from(bytes).map(function (b) { return (b >> 4).toString(16) + (b & 0xf).toString(16) }).join('')
}
/**
* HMAC-SHA256 返回 Uint8Array用于签名链
*/
function hmacRaw(key, msg) {
const hexResult = sha256.hmac(key, msg)
return hexToBytes(hexResult)
return hexToBytes(sha256.hmac(key, msg))
}
/**
* 生成 AWS V4 签名密钥链
*/
function getSignatureKey(secretKey, dateStamp, region, service) {
let k = hmacRaw('AWS4' + secretKey, dateStamp)
var k = hmacRaw('AWS4' + secretKey, dateStamp)
k = hmacRaw(k, region)
k = hmacRaw(k, service)
return hmacRaw(k, 'aws4_request')
@ -68,51 +52,38 @@ function sha256Hex(str) {
return sha256(str)
}
function sha256Bytes(bytes) {
return sha256(bytes)
}
// ====== 时间同步 ======
/** 缓存服务器时间偏移量(毫秒) */
var _timeOffset = 0
var _timeSyncing = false
/**
* MinIO 服务器同步时间
* 通过 PUT 请求根路径获取 Date 响应头计算客户端与服务端的时间差
*/
function syncServerTime() {
return new Promise(function (resolve) {
if (_timeSyncing) {
setTimeout(function () { resolve(getServerDate()) }, 500)
setTimeout(function () { resolve(new Date(Date.now() + _timeOffset)) }, 500)
return
}
_timeSyncing = true
var url = CFG.protocol + '://' + CFG.endpoint + '/'
console.log('【MinIO】同步服务器时间:', url)
uni.request({
url: url,
url: CFG.protocol + '://' + CFG.endpoint + '/',
method: 'PUT',
success: function (res) {
var serverDateStr = null
// 尝试从多个地方取 Date 头
if (res.header) {
serverDateStr = res.header.Date || res.header.date || res.header['Date']
}
if (res.headers) {
serverDateStr = serverDateStr || res.headers.Date || res.headers.date || res.headers['Date']
}
if (serverDateStr) {
var serverMs = new Date(serverDateStr).getTime()
if (serverMs) {
_timeOffset = serverMs - Date.now()
console.log('【MinIO】时间同步完成服务器偏移:', _timeOffset, 'ms, 服务器时间:', serverDateStr)
var d = res.header ? (res.header.Date || res.header.date) : null
d = d || (res.headers ? (res.headers.Date || res.headers.date) : null)
if (d) {
var ms = new Date(d).getTime()
if (ms) {
_timeOffset = ms - Date.now()
}
} else {
console.warn('【MinIO】未获取到Date响应头, header:', JSON.stringify(res.header || res.headers))
}
_timeSyncing = false
resolve(getServerDate())
resolve(new Date(Date.now() + _timeOffset))
},
fail: function (err) {
console.warn('【MinIO】时间同步请求失败:', JSON.stringify(err))
fail: function () {
_timeSyncing = false
resolve(new Date())
}
@ -120,344 +91,180 @@ function syncServerTime() {
})
}
/**
* 获取对齐服务器时间后的当前时间
*/
function getServerDate() {
return new Date(Date.now() + _timeOffset)
}
// ====== AWS V4 签名 ======
/**
* PUT 请求构建 Authorization 签名头
*/
function buildAuthHeader(objectKey, date, contentType) {
const dateStr = date.getFullYear() + pad(date.getMonth() + 1) + pad(date.getDate())
const amzDate = dateStr + 'T' + pad(date.getHours()) + pad(date.getMinutes()) + pad(date.getSeconds()) + 'Z'
function buildAuthHeader(objectKey, date, contentType, contentSha256) {
var dateStr = date.getFullYear() + pad(date.getMonth() + 1) + pad(date.getDate())
var amzDate = dateStr + 'T' + pad(date.getHours()) + pad(date.getMinutes()) + pad(date.getSeconds()) + 'Z'
const host = CFG.endpoint
const canonicalUri = '/' + CFG.bucket + '/' + objectKey
var host = CFG.endpoint
var canonicalUri = '/' + CFG.bucket + '/' + objectKey
const signedHeadersMap = {
'content-type': contentType || 'image/jpeg',
host: host,
'x-amz-content-sha256': 'UNSIGNED-PAYLOAD',
'x-amz-date': amzDate
}
var signedHeadersList = ['content-type', 'host', 'x-amz-content-sha256', 'x-amz-date']
var canonicalHeaders =
'content-type:' + contentType + '\n' +
'host:' + host + '\n' +
'x-amz-content-sha256:' + contentSha256 + '\n' +
'x-amz-date:' + amzDate + '\n'
var signedHeadersStr = signedHeadersList.join(';')
const signedHeadersList = Object.keys(signedHeadersMap).sort()
const canonicalHeaders = signedHeadersList.map(k => k + ':' + signedHeadersMap[k] + '\n').join('')
const signedHeadersStr = signedHeadersList.join(';')
var canonicalRequest =
'PUT\n' + canonicalUri + '\n\n' + canonicalHeaders + signedHeadersStr + '\n' + contentSha256
const canonicalRequest = [
'PUT',
canonicalUri,
'',
canonicalHeaders,
signedHeadersStr,
'UNSIGNED-PAYLOAD'
].join('\n')
var credentialScope = dateStr + '/' + CFG.region + '/s3/aws4_request'
var stringToSign = 'AWS4-HMAC-SHA256\n' + amzDate + '\n' + credentialScope + '\n' + sha256Hex(canonicalRequest)
const credentialScope = [dateStr, CFG.region, 's3', 'aws4_request'].join('/')
const stringToSign = [
'AWS4-HMAC-SHA256',
amzDate,
credentialScope,
sha256Hex(canonicalRequest)
].join('\n')
var signingKey = getSignatureKey(CFG.secretKey, dateStr, CFG.region, 's3')
var signature = bytesToHex(hmacRaw(signingKey, stringToSign))
const signingKey = getSignatureKey(CFG.secretKey, dateStr, CFG.region, 's3')
const signature = bytesToHex(hmacRaw(signingKey, stringToSign))
const authHeader = 'AWS4-HMAC-SHA256 Credential=' + CFG.accessKey + '/' + credentialScope +
var authHeader = 'AWS4-HMAC-SHA256 Credential=' + CFG.accessKey + '/' + credentialScope +
', SignedHeaders=' + signedHeadersStr + ', Signature=' + signature
return { authHeader, amzDate, host }
return { authHeader: authHeader, amzDate: amzDate }
}
// ====== 平台兼容工具 ======
// ====== 文件读取先读base64再转Uint8Array用于计算sha256 ======
/**
* 获取当前平台可用的 XHR 构造函数
* HBuilder App: plus.net.XMLHttpRequest
* H5 / 小程序: XMLHttpRequest
*/
function getXHR() {
if (typeof plus !== 'undefined' && plus.net && plus.net.XMLHttpRequest) {
return plus.net.XMLHttpRequest
}
if (typeof XMLHttpRequest !== 'undefined') {
return XMLHttpRequest
}
return null
}
/**
* 在当前平台发送 PUT 二进制请求到 MinIO
*/
function minioPut(url, arrayBuffer, contentType, authHeader, amzDate) {
return new Promise(function (resolve, reject) {
var XHRClass = getXHR()
if (XHRClass) {
var xhr = new XHRClass()
xhr.open('PUT', url, true)
xhr.setRequestHeader('Authorization', authHeader)
xhr.setRequestHeader('x-amz-content-sha256', 'UNSIGNED-PAYLOAD')
xhr.setRequestHeader('x-amz-date', amzDate)
xhr.setRequestHeader('Content-Type', contentType)
xhr.onload = function () {
if (xhr.status === 200) {
console.log('【MinIO上传】成功:', url)
resolve(url)
} else {
console.error('【MinIO上传】失败, HTTP:', xhr.status, xhr.responseText || xhr.response)
reject(new Error('上传失败: HTTP ' + xhr.status))
}
}
xhr.onerror = function () {
console.error('【MinIO上传】网络错误')
reject(new Error('上传网络错误'))
}
xhr.ontimeout = function () {
reject(new Error('上传超时'))
}
xhr.timeout = 120000
xhr.send(arrayBuffer)
} else {
// 无可用 XHR → 使用 uni.request + base64 body
uni.request({
url: url,
method: 'PUT',
header: {
'Authorization': authHeader,
'x-amz-content-sha256': 'UNSIGNED-PAYLOAD',
'x-amz-date': amzDate,
'Content-Type': contentType
},
data: arrayBuffer,
success: function (res) {
if (res.statusCode === 200) {
resolve(url)
} else {
reject(new Error('上传失败: HTTP ' + res.statusCode))
}
},
fail: function (err) {
reject(new Error('上传网络错误: ' + JSON.stringify(err)))
}
})
}
})
}
// ====== 文件读取(兼容 uniapp 多端) ======
/**
* 读取本地图片为 base64
* 优先级plus.io FileReader uni.getFileSystemManager XHR
*/
function readFileAsBase64(filePath) {
return new Promise((resolve, reject) => {
if (typeof plus !== 'undefined' && plus.io && plus.io.FileReader) {
// HBuilder App 原生:通过 resolveLocalFileSystemURL 获取文件入口
plus.io.resolveLocalFileSystemURL(filePath, function (entry) {
entry.file(function (file) {
const reader = new plus.io.FileReader()
reader.onloadend = function (e) {
var base64 = e.target.result
// 如果返回 data:image/... 格式,去掉前缀
if (base64.indexOf(',') > -1) {
base64 = base64.split(',')[1]
}
resolve(base64)
}
reader.onerror = function () {
reject(new Error('plus.io.FileReader 读取失败'))
}
reader.readAsDataURL(file)
}, function () {
reject(new Error('获取文件对象失败'))
})
}, function (err) {
console.warn('resolveLocalFileSystemURL 失败,尝试 getFileSystemManager:', JSON.stringify(err))
tryGetFileSystemManager(filePath, resolve, reject)
})
} else {
tryGetFileSystemManager(filePath, resolve, reject)
}
})
}
/**
* 尝试通过 uni.getFileSystemManager 读取文件uni-app 支持
*/
function tryGetFileSystemManager(filePath, resolve, reject) {
try {
const fs = uni.getFileSystemManager()
if (fs && fs.readFile) {
fs.readFile({
filePath: filePath,
encoding: 'base64',
success: function (res) {
resolve(res.data)
},
fail: function () {
console.warn('getFileSystemManager 失败,回退到 XHR')
readViaXHR(filePath, resolve, reject)
}
})
} else {
readViaXHR(filePath, resolve, reject)
}
} catch (e) {
readViaXHR(filePath, resolve, reject)
}
}
/**
* 通过 XMLHttpRequest 读取本地文件H5 / 开发环境
*/
function readViaXHR(filePath, resolve, reject) {
try {
var XHRClass = getXHR()
if (!XHRClass) {
reject(new Error('当前环境不支持XHR'))
return
}
var xhr = new XHRClass()
xhr.open('GET', filePath, true)
if (typeof Blob !== 'undefined' && typeof FileReader !== 'undefined') {
xhr.responseType = 'blob'
xhr.onload = function () {
var reader = new FileReader()
reader.onload = function () {
var base64 = reader.result.split(',')[1] || ''
resolve(base64)
}
reader.onerror = function () {
reject(new Error('FileReader 读取失败'))
}
reader.readAsDataURL(xhr.response)
}
} else {
// 无 FileReader → 读为 textApp com.6 起支持获取本地文件 base64
xhr.responseType = 'text'
xhr.onload = function () {
resolve(xhr.responseText || '')
}
}
xhr.onerror = function () {
reject(new Error('XHR 读取文件失败'))
}
xhr.send()
} catch (e) {
reject(new Error('读取文件异常: ' + e.message))
}
}
/**
* Base64 ArrayBuffer
*/
function base64ToArrayBuffer(base64) {
const binaryStr = atob(base64)
const bytes = new Uint8Array(binaryStr.length)
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i)
}
return bytes.buffer
}
/**
* 生成 UUID
*/
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16)
})
}
/**
* 获取文件扩展名不含点
*/
function getFileExt(filePath) {
const m = filePath.match(/\.(\w+)$/)
return m ? m[1].toLowerCase() : 'jpg'
}
/**
* 根据扩展名获取 Content-Type
*/
function getContentType(ext) {
const map = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
bmp: 'image/bmp'
}
return map[ext] || 'image/jpeg'
}
// ====== 主上传接口 ======
/**
* 上传单张图片到 MinIO
* @param {string} filePath - 本地文件路径uni.chooseImage 返回的 tempFilePath
* @param {string} typeDir - 目录名默认 'Isbn'
* @returns {Promise<string>} 可公开访问的图片 URL
*/
export function uploadImage(filePath, typeDir = 'Isbn') {
return new Promise(function (resolve, reject) {
syncServerTime().then(function (serverDate) {
readFileAsBase64(filePath)
.then(function (base64) {
const arrayBuffer = base64ToArrayBuffer(base64)
// 构建对象路径(使用服务器时间)
const now = serverDate
const datePath = now.getFullYear() + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate())
const ext = getFileExt(filePath)
const objectKey = datePath + '/' + typeDir + '/' + generateUUID() + '.' + ext
const contentType = getContentType(ext)
// 构建 AWS V4 签名
const { authHeader, amzDate, host } = buildAuthHeader(objectKey, now, contentType)
// PUT URL
const url = CFG.protocol + '://' + host + '/' + CFG.bucket + '/' + objectKey
console.log('【MinIO上传】URL:', url)
console.log('【MinIO上传】contentType:', contentType)
console.log('【MinIO上传】contentLength:', arrayBuffer.byteLength)
// PUT 到 MinIO
minioPut(url, arrayBuffer, contentType, authHeader, amzDate).then(function (resultUrl) {
resolve(resultUrl)
}).catch(function (err) {
reject(err)
})
// uni-app 标准 API
try {
var fs = uni.getFileSystemManager()
if (fs && fs.readFile) {
fs.readFile({
filePath: filePath,
encoding: 'base64',
success: function (res) { resolve(res.data) },
fail: function () {
// 回退到 plus.io
if (typeof plus !== 'undefined' && plus.io && plus.io.FileReader) {
readViaPlus(filePath, resolve, reject)
} else {
reject(new Error('无法读取文件'))
}
}
})
.catch(function (err) {
reject(err)
})
})
} else if (typeof plus !== 'undefined' && plus.io && plus.io.FileReader) {
readViaPlus(filePath, resolve, reject)
} else {
reject(new Error('当前环境不支持文件读取'))
}
} catch (e) {
reject(new Error('读取文件异常: ' + e.message))
}
})
}
function readViaPlus(filePath, resolve, reject) {
plus.io.resolveLocalFileSystemURL(filePath, function (entry) {
entry.file(function (file) {
var reader = new plus.io.FileReader()
reader.onloadend = function (e) {
var data = e.target.result
if (data.indexOf(',') > -1) data = data.split(',')[1]
resolve(data)
}
reader.onerror = function () { reject(new Error('FileReader失败')) }
reader.readAsDataURL(file)
}, function () { reject(new Error('获取文件对象失败')) })
}, function () { reject(new Error('resolveLocalFileSystemURL失败')) })
}
function base64ToBytes(base64) {
// 手动解码 base64 到 Uint8Array避免 atob 兼容问题)
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
base64 = base64.replace(/=+$/, '')
var bytes = []
for (var i = 0; i < base64.length; i += 4) {
var a = chars.indexOf(base64[i])
var b = chars.indexOf(base64[i + 1])
var c = chars.indexOf(base64[i + 2])
var d = chars.indexOf(base64[i + 3])
bytes.push((a << 2) | (b >> 4))
if (c >= 0) bytes.push(((b & 0xf) << 4) | (c >> 2))
if (d >= 0) bytes.push(((c & 3) << 6) | d)
}
return bytes
}
// ====== 上传 ======
/**
* 批量上传多张图片到 MinIO
* @param {string[]} filePaths - 本地文件路径数组
* @param {string} typeDir - 目录名
* @returns {Promise<string[]>} 图片 URL 数组
* 使用 uni.request 上传单张图片到 MinIO fetch 示例等价
*/
export function uploadImages(filePaths, typeDir = 'Isbn') {
const tasks = filePaths.map(function (fp) {
return uploadImage(fp, typeDir)
export function uploadImage(filePath, typeDir) {
if (typeDir === undefined) typeDir = 'Isbn'
return new Promise(function (resolve, reject) {
// 第一步:同步服务器时间
syncServerTime().then(function (serverDate) {
// 第二步:读取文件
readFileAsBase64(filePath).then(function (base64) {
// 解码为二进制字节数组
var fileBytes = base64ToBytes(base64)
var fileByteArray = new Uint8Array(fileBytes)
// 计算文件内容的 SHA256使用整数数组避免 to UTF-8 的问题)
var fileSha256 = sha256Bytes(fileBytes)
// 构建对象路径
var now = serverDate
var datePath = now.getFullYear() + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate())
var ext = (filePath.match(/\.(\w+)$/) || [])[1] || 'jpg'
ext = ext.toLowerCase()
var objectKey = datePath + '/' + typeDir + '/' + 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = Math.random() * 16 | 0
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16)
}) + '.' + ext
var contentTypeMap = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp' }
var contentType = contentTypeMap[ext] || 'image/jpeg'
// 构建签名使用实际文件sha256
var sig = buildAuthHeader(objectKey, now, contentType, fileSha256)
var url = CFG.protocol + '://' + CFG.endpoint + '/' + CFG.bucket + '/' + objectKey
console.log('【MinIO上传】URL:', url)
console.log('【MinIO上传】contentType:', contentType)
console.log('【MinIO上传】contentLength:', fileByteArray.length)
console.log('【MinIO上传】x-amz-content-sha256:', fileSha256)
// 第三步:使用 uni.request PUT 上传(与 fetch 完全一致)
uni.request({
url: url,
method: 'PUT',
header: {
'Content-Type': contentType,
'X-Amz-Content-Sha256': fileSha256,
'X-Amz-Date': sig.amzDate,
'Authorization': sig.authHeader
},
data: fileByteArray.buffer,
success: function (res) {
if (res.statusCode === 200) {
console.log('【MinIO上传】成功:', url)
resolve(url)
} else {
console.error('【MinIO上传】失败, HTTP:', res.statusCode, JSON.stringify(res.data))
reject(new Error('上传失败: HTTP ' + res.statusCode))
}
},
fail: function (err) {
console.error('【MinIO上传】网络错误:', JSON.stringify(err))
reject(new Error('上传网络错误'))
}
})
}).catch(function (err) { reject(err) })
}).catch(function (err) { reject(err) })
})
}
export function uploadImages(filePaths, typeDir) {
if (typeDir === undefined) typeDir = 'Isbn'
var tasks = []
for (var i = 0; i < filePaths.length; i++) {
tasks.push(uploadImage(filePaths[i], typeDir))
}
return Promise.all(tasks)
}