251 lines
8.7 KiB
JavaScript
251 lines
8.7 KiB
JavaScript
/**
|
||
* MinIO 文件上传工具
|
||
* 使用 AWS Signature V4 签名,通过 plus.net.XMLHttpRequest PUT 直传
|
||
*/
|
||
|
||
import sha256 from 'js-sha256'
|
||
|
||
// ====== Polyfill: atob ======
|
||
if (typeof atob === 'undefined') {
|
||
var atob = function (input) {
|
||
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||
input = input.replace(/=+$/, '')
|
||
var output = ''
|
||
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 配置 ======
|
||
var CFG = {
|
||
endpoint: 'shxy.image.yushutx.com',
|
||
accessKey: 'minioadmin',
|
||
secretKey: 'minioadmin',
|
||
bucket: 'scan-book',
|
||
region: 'us-east-1',
|
||
protocol: 'https'
|
||
}
|
||
|
||
// ====== 工具函数 ======
|
||
|
||
function pad(n) { return n < 10 ? '0' + n : '' + n }
|
||
|
||
function hmacRaw(key, msg) {
|
||
return hexToBytes(sha256.hmac(key, msg))
|
||
}
|
||
|
||
function hexToBytes(hex) {
|
||
var bytes = []
|
||
for (var i = 0; i < hex.length; i += 2) {
|
||
bytes.push(parseInt(hex.substring(i, i + 2), 16))
|
||
}
|
||
return new Uint8Array(bytes)
|
||
}
|
||
|
||
function bytesToHex(bytes) {
|
||
var s = ''
|
||
for (var i = 0; i < bytes.length; i++) {
|
||
s += '0123456789abcdef'[(bytes[i] >> 4) & 0xf]
|
||
s += '0123456789abcdef'[bytes[i] & 0xf]
|
||
}
|
||
return s
|
||
}
|
||
|
||
function sha256Hex(s) { return sha256(s) }
|
||
|
||
// ====== AWS V4 签名 ======
|
||
|
||
function buildAuthHeader(objectKey, date, contentType, contentSha256) {
|
||
var dateStr = date.getUTCFullYear() + pad(date.getUTCMonth() + 1) + pad(date.getUTCDate())
|
||
var amzDate = dateStr + 'T' + pad(date.getUTCHours()) + pad(date.getUTCMinutes()) + pad(date.getUTCSeconds()) + 'Z'
|
||
|
||
var host = CFG.endpoint
|
||
var canonicalUri = '/' + CFG.bucket + '/' + objectKey
|
||
|
||
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(';')
|
||
|
||
var canonicalRequest =
|
||
'PUT\n' + canonicalUri + '\n\n' + canonicalHeaders + '\n' + signedHeadersStr + '\n' + contentSha256
|
||
|
||
var credentialScope = dateStr + '/' + CFG.region + '/s3/aws4_request'
|
||
var stringToSign = 'AWS4-HMAC-SHA256\n' + amzDate + '\n' + credentialScope + '\n' + sha256Hex(canonicalRequest)
|
||
|
||
var signingKey = hmacRaw('AWS4' + CFG.secretKey, dateStr)
|
||
signingKey = hmacRaw(signingKey, CFG.region)
|
||
signingKey = hmacRaw(signingKey, 's3')
|
||
signingKey = hmacRaw(signingKey, 'aws4_request')
|
||
var signature = bytesToHex(hmacRaw(signingKey, stringToSign))
|
||
|
||
var authHeader = 'AWS4-HMAC-SHA256 Credential=' + CFG.accessKey + '/' + credentialScope +
|
||
', SignedHeaders=' + signedHeadersStr + ', Signature=' + signature
|
||
|
||
return { authHeader: authHeader, amzDate: amzDate }
|
||
}
|
||
|
||
// ====== 文件读取(readAsDataURL → base64 → uni.base64ToArrayBuffer) ======
|
||
|
||
function readFileAsBase64(filePath) {
|
||
return new Promise(function (resolve, reject) {
|
||
if (typeof plus !== 'undefined' && plus.io && plus.io.FileReader) {
|
||
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 (err) {
|
||
reject(new Error('resolveLocalFileSystemURL失败: ' + JSON.stringify(err)))
|
||
})
|
||
return
|
||
}
|
||
reject(new Error('plus.io 不可用'))
|
||
})
|
||
}
|
||
|
||
// ====== 时间同步 ======
|
||
|
||
var _cachedTimeOffset = null
|
||
|
||
function syncServerTime() {
|
||
return new Promise(function (resolve) {
|
||
console.log('【MinIO时间同步】开始...')
|
||
if (typeof plus !== 'undefined' && plus.net && plus.net.XMLHttpRequest) {
|
||
try {
|
||
var xhr = new plus.net.XMLHttpRequest()
|
||
var timer = setTimeout(function () {
|
||
console.warn('【MinIO时间同步】超时')
|
||
xhr.abort()
|
||
resolve(new Date())
|
||
}, 10000)
|
||
|
||
xhr.onreadystatechange = function () {
|
||
if (xhr.readyState === 4) {
|
||
clearTimeout(timer)
|
||
try {
|
||
var dateStr = xhr.getResponseHeader('Date')
|
||
console.log('【MinIO时间同步】readyState=4, status:', xhr.status, ', Date头:', dateStr)
|
||
if (dateStr) {
|
||
var serverMs = Date.parse(dateStr)
|
||
if (!isNaN(serverMs)) {
|
||
_cachedTimeOffset = serverMs - Date.now()
|
||
console.log('【MinIO时间同步】成功! 偏移:', _cachedTimeOffset, 'ms')
|
||
resolve(new Date(Date.now() + _cachedTimeOffset))
|
||
return
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('【MinIO时间同步】解析异常:', e)
|
||
}
|
||
resolve(new Date())
|
||
}
|
||
}
|
||
xhr.open('GET', CFG.protocol + '://' + CFG.endpoint + '/')
|
||
xhr.send()
|
||
return
|
||
} catch (e) {
|
||
console.warn('【MinIO时间同步】创建XHR失败:', e)
|
||
}
|
||
}
|
||
resolve(new Date())
|
||
})
|
||
}
|
||
|
||
function getServerDate() {
|
||
if (_cachedTimeOffset !== null) {
|
||
return new Date(Date.now() + _cachedTimeOffset)
|
||
}
|
||
return new Date()
|
||
}
|
||
|
||
// ====== 上传 ======
|
||
|
||
export function uploadImage(filePath, typeDir) {
|
||
if (typeDir === undefined) typeDir = 'Isbn'
|
||
return new Promise(function (resolve, reject) {
|
||
syncServerTime().then(function () {
|
||
readFileAsBase64(filePath).then(function (base64) {
|
||
// base64 → ArrayBuffer
|
||
var binaryStr = atob(base64)
|
||
var bytes = new Uint8Array(binaryStr.length)
|
||
for (var i = 0; i < binaryStr.length; i++) {
|
||
bytes[i] = binaryStr.charCodeAt(i)
|
||
}
|
||
var arrayBuffer = bytes.buffer
|
||
var payloadHash = 'UNSIGNED-PAYLOAD'
|
||
|
||
var now = getServerDate()
|
||
var datePath = now.getFullYear() + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate())
|
||
var ext = (filePath.match(/\.(\w+)$/) || [])[1] || 'jpg'
|
||
ext = ext.toLowerCase()
|
||
if (ext === 'jpeg') ext = 'jpg'
|
||
var uuid = '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)
|
||
})
|
||
var objectKey = datePath + '/' + typeDir + '/' + uuid + '.' + ext
|
||
var ct = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp' }
|
||
var contentType = ct[ext] || 'image/jpeg'
|
||
|
||
var sig = buildAuthHeader(objectKey, now, contentType, payloadHash)
|
||
var url = CFG.protocol + '://' + CFG.endpoint + '/' + CFG.bucket + '/' + objectKey
|
||
|
||
console.log('【MinIO上传】URL:', url)
|
||
console.log('【MinIO上传】contentType:', contentType)
|
||
console.log('【MinIO上传】dataSize:', arrayBuffer.byteLength, '字节')
|
||
console.log('【MinIO上传】服务器时间:', now.toISOString())
|
||
|
||
// 使用 uni.request PUT(App 环境中 ArrayBuffer 传输最可靠)
|
||
uni.request({
|
||
url: url,
|
||
method: 'PUT',
|
||
header: {
|
||
'Content-Type': contentType,
|
||
'X-Amz-Content-Sha256': payloadHash,
|
||
'X-Amz-Date': sig.amzDate,
|
||
'Authorization': sig.authHeader
|
||
},
|
||
data: arrayBuffer,
|
||
success: function (res) {
|
||
console.log('【MinIO上传】status:', res.statusCode)
|
||
if (res.statusCode === 200) {
|
||
console.log('【MinIO上传】成功:', url)
|
||
resolve(url)
|
||
} else {
|
||
console.error('【MinIO上传】失败, HTTP:', res.statusCode, JSON.stringify(res.data || '').substring(0, 200))
|
||
reject(new Error('上传失败: HTTP ' + res.statusCode))
|
||
}
|
||
},
|
||
fail: function (err) {
|
||
console.error('【MinIO上传】uni.request失败:', 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)
|
||
}
|
||
|
||
export default { uploadImage, uploadImages }
|