diff --git a/utils/minio.js b/utils/minio.js index 755c62f..96f1050 100644 --- a/utils/minio.js +++ b/utils/minio.js @@ -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 → 读为 text(App 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} 可公开访问的图片 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} 图片 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) }