├── .gitignore ├── .jshintrc ├── README.md ├── bower.json ├── demo.html └── lib └── upyun-mu.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | # Logs 3 | logs 4 | *.log 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # Compiled binary addons (http://nodejs.org/api/addons.html) 21 | build/Release 22 | 23 | # Dependency directory 24 | # Commenting this out is preferred by some people, see 25 | # https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git 26 | node_modules 27 | 28 | # Users Environment Variables 29 | .lock-wscript 30 | # workspace files are user-specific 31 | *.sublime-workspace 32 | 33 | # project files should be checked into the repository, unless a significant 34 | # proportion of contributors will probably not be using SublimeText 35 | # *.sublime-project 36 | 37 | #sftp configuration file 38 | sftp-config.json 39 | 40 | bower_components/* 41 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "browser": true, 3 | "esnext": true, 4 | "bitwise": true, 5 | "camelcase": false, 6 | "curly": true, 7 | "immed": true, 8 | "newcap": true, 9 | "noarg": true, 10 | "undef": true, 11 | "unused": "vars", 12 | "strict": true, 13 | "mocha": true 14 | } 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # upyun-multipart-upload 2 | 使用 HTML 5 相关 API 开发的分块上传 DEMO 3 | 4 | 5 | > 在您决定将本项目用于生产环境之前,请确保知晓如何保护表单密钥的安全性,以及本项目在部分低配设备上可能会有的兼容性和性能问题! 6 | 7 | 8 | ## 安装 9 | 你可以通过如下两种方式中任意一种引入本项目: 10 | 11 | ### 1.bower 12 | ```sh 13 | $ bower install --save upyun-multipart-upload 14 | ``` 15 | 16 | ### 2.直接下载 17 | 1. 下载本项目最新的 [Release](https://github.com/upyun/js-multipart-upload/releases/latest) 18 | 2. 下载依赖 [async](https://github.com/caolan/async/releases/latest) 19 | 3. 下载依赖 [SparkMD5](https://github.com/satazor/SparkMD5/releases/latest) 20 | 4. 通过 `` 标签以此引入文件,注意将依赖放在前面 21 | 22 | ```html 23 | 24 | 25 | 26 | ``` 27 | 28 | ## Usage 29 | 30 | eg: 31 | 32 | ```js 33 | document.getElementById('submit').onclick = function() { 34 | var ext = '.' + document.getElementById('file').files[0].name.split('.').pop(); 35 | 36 | var config = { 37 | bucket: 'demonstration', 38 | expiration: parseInt((new Date().getTime() + 3600000) / 1000), 39 | signature: 'something' 40 | }; 41 | 42 | var instance = new Sand(config); 43 | var options = { 44 | 'notify_url': 'http://upyun.com' 45 | }; 46 | 47 | instance.setOptions(options); 48 | instance.upload('/upload/test' + parseInt((new Date().getTime() + 3600000) / 1000) + ext); 49 | }; 50 | ``` 51 | 52 | 53 | ## API 54 | 55 | ### 构建实例 56 | ```js 57 | new Sand(config); 58 | ``` 59 | 60 | __参数说明__ 61 | 62 | * `config` 必要参数 63 | * `bucket`: 空间名称 64 | * `expiration`: 上传请求过期时间(单位为:`秒`) 65 | * `signature`: 初始化上传所需的签名 66 | * `form_api_secret`: 表单 API (慎用) 67 | 68 | __注意__ 69 | 70 | 其中 `signature` 和 `form_api_secret` 为互斥项,为了避免表单 API 泄露造成安全隐患,请尽可能根据[所需参数](https://github.com/upyun/js-multipart-upload/wiki/%E5%88%86%E5%9D%97%E4%B8%8A%E4%BC%A0%E8%AF%B4%E6%98%8E#%E5%85%83%E4%BF%A1%E6%81%AF)自行传入初始化上传所需的 `signature` 参数 71 | 72 | 计算签名算法,请参考[文档](https://github.com/upyun/js-multipart-upload/wiki/%E5%88%86%E5%9D%97%E4%B8%8A%E4%BC%A0%E8%AF%B4%E6%98%8E#signature-%E5%92%8C-policy-%E7%AE%97%E6%B3%95) 73 | 74 | 75 | ### 设置额外上传参数 76 | 77 | ```js 78 | instance.setOptions(options) 79 | ``` 80 | __参数说明__ 81 | 82 | * `options`: Object 类型,包含额外的上传参数(详情见 [表单 API Policy](http://docs.upyun.com/api/form_api/#api_1)) 83 | 84 | ## 上传 85 | ```js 86 | instance.upload(path) 87 | ``` 88 | 89 | __参数说明__ 90 | 91 | * `path`: 文件在空间中的存放路径 92 | 93 | 94 | ## 事件 95 | 96 | ### `uploaded` 97 | 上传完成后,会触发自定义事件 `uploaded`, 在事件对象中,会包含一些基本的信息,以供使用 98 | 99 | 100 | ### `error` 101 | 上传出错,会触发自定义事件 `error`, 在事件对象中,会包含错误的详情 102 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "upyun-multipart-upload", 3 | "version": "0.3.0", 4 | "authors": [ 5 | "Leigh Zhu " 6 | ], 7 | "description": "a js sdk for upyun multipart upload", 8 | "main": "lib/upyun-mu.js", 9 | "keywords": [ 10 | "upyun", 11 | "upload", 12 | "cdn" 13 | ], 14 | "license": "MIT", 15 | "homepage": "https://github.com/upyun/js-multipart-upload", 16 | "ignore": [ 17 | "**/.*", 18 | "node_modules", 19 | "bower_components", 20 | "test", 21 | "tests" 22 | ], 23 | "dependencies": { 24 | "spark-md5": "~0.0.5", 25 | "async": "~0.9.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Demo 6 | 7 | 8 | 9 | 10 | 52 | 53 | 54 | 55 | 56 |
57 | 58 | UPLOAD 59 |
60 | 61 |
62 | 63 |
64 | 65 | 66 | 107 | 108 | -------------------------------------------------------------------------------- /lib/upyun-mu.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 'use strict'; 3 | /** 4 | * 5 | * Base64 encode / decode 6 | * http://www.webtoolkit.info/ 7 | * 8 | */ 9 | var Base64 = { 10 | // private property 11 | _keyStr : "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=", 12 | // public method for encoding 13 | encode : function (input) { 14 | var output = ""; 15 | var chr1, chr2, chr3, enc1, enc2, enc3, enc4; 16 | var i = 0; 17 | input = Base64._utf8_encode(input); 18 | while (i < input.length) { 19 | chr1 = input.charCodeAt(i++); 20 | chr2 = input.charCodeAt(i++); 21 | chr3 = input.charCodeAt(i++); 22 | enc1 = chr1 >> 2; 23 | enc2 = ((chr1 & 3) << 4) | (chr2 >> 4); 24 | enc3 = ((chr2 & 15) << 2) | (chr3 >> 6); 25 | enc4 = chr3 & 63; 26 | if (isNaN(chr2)) { 27 | enc3 = enc4 = 64; 28 | } else if (isNaN(chr3)) { 29 | enc4 = 64; 30 | } 31 | output = output + 32 | this._keyStr.charAt(enc1) + this._keyStr.charAt(enc2) + 33 | this._keyStr.charAt(enc3) + this._keyStr.charAt(enc4); 34 | } 35 | return output; 36 | }, 37 | // public method for decoding 38 | decode : function (input) { 39 | var output = ""; 40 | var chr1, chr2, chr3; 41 | var enc1, enc2, enc3, enc4; 42 | var i = 0; 43 | input = input.replace(/[^A-Za-z0-9\+\/\=]/g, ""); 44 | while (i < input.length) { 45 | enc1 = this._keyStr.indexOf(input.charAt(i++)); 46 | enc2 = this._keyStr.indexOf(input.charAt(i++)); 47 | enc3 = this._keyStr.indexOf(input.charAt(i++)); 48 | enc4 = this._keyStr.indexOf(input.charAt(i++)); 49 | chr1 = (enc1 << 2) | (enc2 >> 4); 50 | chr2 = ((enc2 & 15) << 4) | (enc3 >> 2); 51 | chr3 = ((enc3 & 3) << 6) | enc4; 52 | output = output + String.fromCharCode(chr1); 53 | if (enc3 != 64) { 54 | output = output + String.fromCharCode(chr2); 55 | } 56 | if (enc4 != 64) { 57 | output = output + String.fromCharCode(chr3); 58 | } 59 | } 60 | output = Base64._utf8_decode(output); 61 | return output; 62 | }, 63 | // private method for UTF-8 encoding 64 | _utf8_encode : function (string) { 65 | string = string.replace(/\r\n/g,"\n"); 66 | var utftext = ""; 67 | for (var n = 0; n < string.length; n++) { 68 | var c = string.charCodeAt(n); 69 | if (c < 128) { 70 | utftext += String.fromCharCode(c); 71 | } 72 | else if((c > 127) && (c < 2048)) { 73 | utftext += String.fromCharCode((c >> 6) | 192); 74 | utftext += String.fromCharCode((c & 63) | 128); 75 | } 76 | else { 77 | utftext += String.fromCharCode((c >> 12) | 224); 78 | utftext += String.fromCharCode(((c >> 6) & 63) | 128); 79 | utftext += String.fromCharCode((c & 63) | 128); 80 | } 81 | } 82 | return utftext; 83 | }, 84 | // private method for UTF-8 decoding 85 | _utf8_decode : function (utftext) { 86 | var string = ""; 87 | var i = 0; 88 | var c = c1 = c2 = 0; 89 | while ( i < utftext.length ) { 90 | c = utftext.charCodeAt(i); 91 | if (c < 128) { 92 | string += String.fromCharCode(c); 93 | i++; 94 | } 95 | else if((c > 191) && (c < 224)) { 96 | c2 = utftext.charCodeAt(i+1); 97 | string += String.fromCharCode(((c & 31) << 6) | (c2 & 63)); 98 | i += 2; 99 | } 100 | else { 101 | c2 = utftext.charCodeAt(i+1); 102 | c3 = utftext.charCodeAt(i+2); 103 | string += String.fromCharCode(((c & 15) << 12) | ((c2 & 63) << 6) | (c3 & 63)); 104 | i += 3; 105 | } 106 | } 107 | return string; 108 | } 109 | }; 110 | var _config = { 111 | api: '//m0.api.upyun.com/', 112 | chunkSize: 1048576 113 | }; 114 | 115 | function _extend(dst, src) { 116 | for (var i in src) { 117 | dst[i] = src[i]; 118 | } 119 | } 120 | 121 | function sortPropertiesByKey(obj) { 122 | var keys = []; 123 | var sorted_obj = {}; 124 | for (var key in obj) { 125 | if (obj.hasOwnProperty(key)) { 126 | keys.push(key); 127 | } 128 | } 129 | keys.sort(); 130 | for (var i = 0; i < keys.length; i++) { 131 | var k = keys[i]; 132 | sorted_obj[k] = obj[k]; 133 | } 134 | return sorted_obj; 135 | } 136 | 137 | function calcSign(data, secret) { 138 | if (typeof data !== 'object') { 139 | return; 140 | } 141 | var sortedData = sortPropertiesByKey(data); 142 | var md5Str = ''; 143 | for (var key in sortedData) { 144 | if (key !== 'signature') { 145 | md5Str = md5Str + key + sortedData[key]; 146 | } 147 | } 148 | var sign = SparkMD5.hash(md5Str + secret); 149 | return sign; 150 | } 151 | 152 | function formatParams(data) { 153 | var arr = []; 154 | for (var name in data) { 155 | arr.push(encodeURIComponent(name) + "=" + encodeURIComponent(data[name])); 156 | } 157 | return arr.join("&"); 158 | } 159 | 160 | function checkBlocks(arr) { 161 | var indices = []; 162 | var idx = arr.indexOf(0); 163 | while (idx != -1) { 164 | indices.push(idx); 165 | idx = arr.indexOf(0, idx + 1); 166 | } 167 | return indices; 168 | } 169 | 170 | 171 | function upload(path, fileSelector) { 172 | var self = this; 173 | var blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice; 174 | var chunkSize = _config.chunkSize; 175 | var chunks; 176 | 177 | async.waterfall([ 178 | 179 | function(callback) { 180 | var chunkInfo = { 181 | chunksHash: {} 182 | }; 183 | var files; 184 | if(fileSelector === void 0) { 185 | files = document.getElementById('file').files; 186 | } else { 187 | files = document.querySelector(fileSelector).files; 188 | } 189 | if (!files.length) { 190 | console.log('no file is selected'); 191 | return; 192 | } 193 | var file = files[0]; 194 | chunks = Math.ceil(file.size / chunkSize); 195 | if(!file.slice) { 196 | chunkSize = file.size; 197 | chunks = 1; 198 | } 199 | var currentChunk = 0; 200 | var spark = new SparkMD5.ArrayBuffer(); 201 | var frOnload = function(e) { 202 | chunkInfo.chunksHash[currentChunk] = SparkMD5.ArrayBuffer.hash(e.target.result); 203 | spark.append(e.target.result); 204 | currentChunk++; 205 | if (currentChunk < chunks) { 206 | loadNext(); 207 | } else { 208 | chunkInfo.entire = spark.end(); 209 | chunkInfo.chunksNum = chunks; 210 | chunkInfo.file_size = file.size; 211 | callback(null, chunkInfo); 212 | return; 213 | } 214 | }; 215 | var frOnerror = function() { 216 | console.warn("oops, something went wrong."); 217 | }; 218 | 219 | function loadNext() { 220 | var fileReader = new FileReader(); 221 | fileReader.onload = frOnload; 222 | fileReader.onerror = frOnerror; 223 | var start = currentChunk * chunkSize, 224 | end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize; 225 | var blobPacket = blobSlice.call(file, start, end); 226 | fileReader.readAsArrayBuffer(blobPacket); 227 | } 228 | loadNext(); 229 | }, 230 | function(chunkInfo, callback) { 231 | var options = { 232 | 'path': path, 233 | 'expiration': _config.expiration, 234 | 'file_blocks': chunkInfo.chunksNum, 235 | 'file_size': chunkInfo.file_size, 236 | 'file_hash': chunkInfo.entire 237 | }; 238 | var signature; 239 | 240 | _extend(options, self.options); 241 | 242 | if (self._signature) { 243 | signature = self._signature; 244 | } else { 245 | signature = calcSign(options, _config.form_api_secret); 246 | } 247 | var policy = Base64.encode(JSON.stringify(options)); 248 | var paramsData = { 249 | policy: policy, 250 | signature: signature 251 | }; 252 | var urlencParams = formatParams(paramsData); 253 | var request = new XMLHttpRequest(); 254 | request.open('POST', _config.api + _config.bucket + '/'); 255 | request.setRequestHeader("Content-type", "application/x-www-form-urlencoded"); 256 | request.onload = function(e) { 257 | if (request.status == 200) { 258 | if (JSON.parse(request.response).status.indexOf(0) < 0) { 259 | return callback(new Error('file already exists')); 260 | } 261 | 262 | callback(null, chunkInfo, request.response); 263 | } else { 264 | request.send(urlencParams); 265 | } 266 | }; 267 | request.send(urlencParams); 268 | }, 269 | function(chunkInfo, res, callback) { 270 | res = JSON.parse(res); 271 | 272 | var file; 273 | if(fileSelector === void 0) { 274 | file = document.getElementById('file').files[0]; 275 | } else { 276 | file = document.querySelector(fileSelector).files[0]; 277 | } 278 | var _status = res.status; 279 | var result; 280 | async.until(function() { 281 | return checkBlocks(_status).length <= 0; 282 | }, function(callback) { 283 | var idx = checkBlocks(_status)[0]; 284 | var start = idx * chunkSize, 285 | end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize; 286 | var packet = blobSlice.call(file, start, end); 287 | 288 | var options = { 289 | 'save_token': res.save_token, 290 | 'expiration': _config.expiration, 291 | 'block_index': idx, 292 | 'block_hash': chunkInfo.chunksHash[idx] 293 | }; 294 | 295 | var signature = calcSign(options, res.token_secret); 296 | var policy = Base64.encode(JSON.stringify(options)); 297 | 298 | var formDataPart = new FormData(); 299 | formDataPart.append('policy', policy); 300 | formDataPart.append('signature', signature); 301 | formDataPart.append('file', chunks === 1 ? file : packet); 302 | 303 | var request = new XMLHttpRequest(); 304 | request.onreadystatechange = function(e) { 305 | if (e.currentTarget.readyState === 4 && e.currentTarget.status == 200) { 306 | _status = JSON.parse(e.currentTarget.response).status; 307 | result = request.response; 308 | setTimeout(function() { 309 | return callback(null); 310 | }, 0); 311 | } 312 | }; 313 | request.open('POST', _config.api + _config.bucket + '/', false); 314 | request.send(formDataPart); 315 | 316 | }, function(err) { 317 | if (err) { 318 | callback(err); 319 | } 320 | callback(null, chunkInfo, result); 321 | }); 322 | }, 323 | function(chunkInfo, res, callback) { 324 | res = JSON.parse(res); 325 | 326 | var options = { 327 | 'save_token': res.save_token, 328 | 'expiration': _config.expiration 329 | }; 330 | 331 | var signature = calcSign(options, res.token_secret); 332 | var policy = Base64.encode(JSON.stringify(options)); 333 | var formParams = { 334 | policy: policy, 335 | signature: signature 336 | }; 337 | var formParamsUrlenc = formatParams(formParams); 338 | var request = new XMLHttpRequest(); 339 | request.open('POST', _config.api + _config.bucket + '/'); 340 | request.setRequestHeader("Content-type", "application/x-www-form-urlencoded"); 341 | request.onload = function(e) { 342 | if (request.status == 200) { 343 | callback(null, request.response); 344 | } else { 345 | callback(null, request.response); 346 | } 347 | }; 348 | request.send(formParamsUrlenc); 349 | } 350 | ], function(err, res) { 351 | var event; 352 | if (err) { 353 | if (typeof CustomEvent === 'function') { 354 | event = new CustomEvent('error', { 355 | 'detail': err 356 | }); 357 | document.dispatchEvent(event); 358 | return; 359 | } else { 360 | //IE compatibility 361 | event = document.createEvent("CustomEvent"); 362 | event.initCustomEvent("error", false, false, err); 363 | document.dispatchEvent(event); 364 | return; 365 | } 366 | } 367 | if(typeof CustomEvent === 'function') { 368 | event = new CustomEvent('uploaded', { 369 | 'detail': JSON.parse(res) 370 | }); 371 | document.dispatchEvent(event); 372 | } else { 373 | //IE compatibility 374 | event = document.createEvent("CustomEvent"); 375 | event.initCustomEvent("uploaded", false, false, JSON.parse(res)); 376 | document.dispatchEvent(event); 377 | } 378 | }); 379 | } 380 | 381 | function Sand(config) { 382 | _extend(_config, config); 383 | 384 | if(config.signature) { 385 | this._signature = config.signature; 386 | } 387 | 388 | this.setOptions = function(options) { 389 | this.options = options; 390 | }; 391 | 392 | this.upload = upload; 393 | } 394 | 395 | // bind the construct fn. to global 396 | this.Sand = Sand; 397 | 398 | }).call(this); 399 | --------------------------------------------------------------------------------