UNPKG

picgo-plugin-huawei-nutpi

Version:

华为云对象存储图床

333 lines (290 loc) 8.91 kB
const mime = require('mime/lite') // 使用更轻量的版本 const crypto = require('crypto') /** * 常量配置 */ const CONSTANTS = { PLUGIN_NAME: 'huaweicloud-uploader', DISPLAY_NAME: '华为云OBS', CONFIG_PATH: 'picBed.huaweicloud-uploader', LOG_PREFIX: '[华为云OBS]', HTTP_METHOD: 'PUT', DEFAULT_CACHE_CONTROL: 'max-age=31536000' } /** * 错误类型定义 */ const ERROR_TYPES = { ACCESS_DENIED: 'Access Denied', NO_SUCH_BUCKET: 'NoSuchBucket', INVALID_ACCESS_KEY: 'InvalidAccessKeyId', SIGNATURE_MISMATCH: 'SignatureDoesNotMatch' } /** * 日志工具 */ const logger = { info: (message) => console.log(`${CONSTANTS.LOG_PREFIX} ${message}`), error: (message) => console.error(`${CONSTANTS.LOG_PREFIX} ${message}`), debug: (message, data) => console.log(`${CONSTANTS.LOG_PREFIX} ${message}`, data || '') } /** * 生成华为云 OBS 签名 * @param {Object} options - 上传配置项 * @param {string} fileName - 文件名 * @returns {string} 签名字符串 * @throws {Error} 当无法获取 MIME 类型时抛出错误 */ const generateSignature = (options, fileName) => { logger.info(`开始生成签名,文件名: ${fileName}`) const date = new Date().toUTCString() const mimeType = mime.getType(fileName) if (!mimeType) { logger.error(`无法获取文件 ${fileName} 的 MIME 类型`) throw Error(`No mime type found for file ${fileName}`) } const path = options.path const strToSign = `${CONSTANTS.HTTP_METHOD}\n\n${mimeType}\n${date}\n/${options.bucketName}${path ? '/' + encodeURI(options.path) : ''}/${encodeURI(fileName)}` logger.debug('生成的签名字符串:', strToSign) const signature = crypto .createHmac('sha1', options.accessKeySecret) .update(strToSign) .digest('base64') logger.info('签名生成完成') return `OBS ${options.accessKeyId}:${signature}` } /** * 构建上传请求选项 * @param {Object} options - 上传配置项 * @param {string} fileName - 文件名 * @param {string} signature - 签名字符串 * @param {Buffer} image - 图片数据 * @returns {Object} 请求配置对象 */ const postOptions = (options, fileName, signature, image) => { logger.info(`构建上传请求参数,文件名: ${fileName}`) const path = options.path const mimeType = mime.getType(fileName) const url = `https://${options.bucketName}.${options.endpoint}${path ? '/' + encodeURI(options.path) : ''}/${encodeURI(fileName)}` logger.debug('上传URL:', url) return { method: CONSTANTS.HTTP_METHOD, url, headers: { Authorization: signature, Date: new Date().toUTCString(), 'content-type': mimeType, 'Cache-Control': options.cacheControl || CONSTANTS.DEFAULT_CACHE_CONTROL }, body: image, resolveWithFullResponse: true } } /** * 处理错误消息 * @param {Error} error - 错误对象 * @returns {string} 格式化的错误消息 */ const handleError = (error) => { const message = error.message const errorMessages = { [ERROR_TYPES.ACCESS_DENIED]: '访问被拒绝,请检查:\n1. AccessKey 是否正确\n2. 桶名称是否正确\n3. 地域节点是否正确\n4. AccessKey 是否有权限', [ERROR_TYPES.NO_SUCH_BUCKET]: '存储桶不存在,请检查桶名称是否正确', [ERROR_TYPES.INVALID_ACCESS_KEY]: 'AccessKey ID 无效', [ERROR_TYPES.SIGNATURE_MISMATCH]: 'AccessKey Secret 不正确' } for (const [errorType, errorMessage] of Object.entries(errorMessages)) { if (message.includes(errorType)) { return errorMessage } } return message || '未知错误' } /** * 验证配置项 * @param {Object} options - 配置项 * @throws {Error} 当必要配置缺失时抛出错误 */ const validateConfig = (options) => { if (!options) { throw new Error('找不到华为OBS图床配置文件') } const requiredFields = { accessKeyId: 'AccessKey ID', accessKeySecret: 'AccessKey Secret', bucketName: '桶名称', endpoint: '地域节点' } for (const [field, name] of Object.entries(requiredFields)) { if (!options[field]) { throw new Error(`请配置${name}`) } } } /** * 处理单个图片上传 * @param {Object} ctx - PicGo 上下文 * @param {Object} obsOptions - OBS 配置项 * @param {Object} img - 图片对象 * @returns {Promise<void>} */ const handleSingleImage = async (ctx, obsOptions, img) => { logger.info(`开始处理图片: ${img.fileName}`) if (!img.fileName || (!img.buffer && !img.base64Image)) { throw new Error('图片数据不完整') } const signature = generateSignature(obsOptions, img.fileName) let image = img.buffer if (!image && img.base64Image) { logger.info('转换base64图片数据为Buffer') image = Buffer.from(img.base64Image, 'base64') } const options = postOptions(obsOptions, img.fileName, signature, image) logger.info('开始上传图片') const response = await ctx.request(options) if (!response || response.statusCode !== 200) { throw new Error(`上传失败: HTTP ${response?.statusCode || 'unknown'}`) } logger.info('图片上传成功,清理临时数据') delete img.base64Image delete img.buffer const path = obsOptions.path const domain = obsOptions.customDomain || `https://${obsOptions.bucketName}.${obsOptions.endpoint}` img.imgUrl = `${domain}${path ? '/' + encodeURI(path) : ''}/${encodeURI(img.fileName)}` logger.debug('生成访问链接:', img.imgUrl) if (obsOptions.imageProcess) { img.imgUrl += obsOptions.imageProcess logger.debug('添加图片处理参数后的最终链接:', img.imgUrl) } } /** * 处理图片上传的主函数 * @param {Object} ctx - PicGo 上下文 * @returns {Promise<Object>} Promise<picgo> */ const handle = async (ctx) => { logger.info('开始处理上传任务') const obsOptions = ctx.getConfig(CONSTANTS.CONFIG_PATH) try { validateConfig(obsOptions) const images = ctx.output logger.info(`待上传图片数量: ${images.length}`) for (const img of images) { try { await handleSingleImage(ctx, obsOptions, img) } catch (error) { logger.error(`处理图片时出错: ${error.message}`) ctx.emit('notification', { title: '图片上传失败', body: handleError(error) }) } } logger.info('所有图片处理完成') return ctx } catch (err) { logger.error(`上传过程发生错误: ${err.message}`) logger.error('错误详情:', err) ctx.emit('notification', { title: '上传失败', body: handleError(err) }) throw err } } /** * 插件配置项 * @param {Object} ctx - PicGo 上下文 * @returns {Array} 配置项数组 */ const config = (ctx) => { const userConfig = ctx.getConfig('picBed.huaweicloud-uploader') || { accessKeyId: '', accessKeySecret: '', bucketName: '', endpoint: '', path: '', imageProcess: '', customDomain: '', cacheControl: '' } return [ { name: 'accessKeyId', type: 'input', alias: 'AccessKeyId', default: userConfig.accessKeyId || '', message: '例如YYTIRPODKPQUFBGNKLPE', required: true }, { name: 'accessKeySecret', type: 'password', alias: 'AccessKeySecret', default: userConfig.accessKeySecret || '', message: '例如sYYTIRPODKPQUFBGNKLPE', required: true }, { name: 'bucketName', type: 'input', alias: '桶名称', default: userConfig.bucketName || '', message: '例如nutpi', required: true }, { name: 'endpoint', type: 'input', alias: 'EndPoint', default: userConfig.endpoint || '', message: '例如obs.cn-north-4.myhuaweicloud.com', required: true }, { name: 'path', type: 'input', alias: '存储路径', message: '在桶中存储的路径,例如img或img/huawei', default: userConfig.path || '', required: false }, { name: 'imageProcess', type: 'input', alias: '网址后缀', message: '例如https://nutpi.net', default: userConfig.imageProcess || '', required: false }, { name: 'customDomain', type: 'input', alias: '自定义域名', message: '例如https://nutpi.net', default: userConfig.customDomain || '', required: false }, { name: 'cacheControl', type: 'input', alias: 'CacheControl配置', message: '例如max-age=31536000', default: userConfig.cacheControl || '', required: false } ] } module.exports = (ctx) => { const register = () => { ctx.helper.uploader.register(CONSTANTS.PLUGIN_NAME, { handle, name: CONSTANTS.DISPLAY_NAME, config }) } return { uploader: CONSTANTS.PLUGIN_NAME, register } }