├── .editorconfig ├── .eslintignore ├── .eslintrc.yml ├── .github └── workflows │ ├── build-test.yml │ └── publish.yml ├── .gitignore ├── README.md ├── client.js ├── client.min.js ├── demo ├── .snyk ├── app.js ├── package.json └── public_html │ ├── config.js │ └── index.html ├── package-lock.json ├── package.json ├── server.js └── test ├── .eslintrc.yml ├── assets ├── mandrill.png └── sonnet18.txt ├── browser-phantom.js ├── serve ├── browser-file-transfer.js └── index.html ├── setup-server.js ├── test-serves-client-js.js └── test-transfer.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # IDE Configuration 2 | # See if you need a plugin => https://editorconfig.org/#download 3 | 4 | root = true 5 | 6 | # Default config 7 | 8 | [*] 9 | charset = utf-8 10 | indent_style = tab 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | # Specific config 15 | 16 | [*.{json,yml,yaml}] 17 | indent_style = space 18 | indent_size = 1 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | demo/node_modules/ 2 | test/serve/bundle.js 3 | client.min.js 4 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | es6: true 3 | browser: true 4 | extends: 'eslint:recommended' 5 | parserOptions: 6 | ecmaVersion: 5 7 | rules: 8 | indent: 9 | - error 10 | - tab 11 | - SwitchCase: 1 12 | linebreak-style: 13 | - error 14 | - unix 15 | quotes: 16 | - error 17 | - double 18 | semi: 19 | - error 20 | - always 21 | -------------------------------------------------------------------------------- /.github/workflows/build-test.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions/setup-node@v1 11 | with: 12 | node-version: 10.x 13 | - name: Install 14 | run: npm ci 15 | - name: Test 16 | run: npm test 17 | env: 18 | CI: true 19 | X_USE_PHANTOM: 1 20 | - name: Lint 21 | run: npm run lint 22 | - name: Check client.min.js 23 | run: | 24 | npm run minify 25 | git update-index --refresh 26 | git diff-index HEAD 27 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Release 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | publish: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v1 11 | - uses: actions/setup-node@v1 12 | with: 13 | node-version: 10.x 14 | - run: npm ci 15 | - run: npm test 16 | - uses: JS-DevTools/npm-publish@v1 17 | name: Publish to npm 18 | id: publish 19 | with: 20 | token: ${{ secrets.NPM_AUTH_TOKEN }} 21 | - if: steps.publish.outputs.type != 'none' 22 | name: Create GitHub Release 23 | id: create_release 24 | uses: actions/create-release@v1 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | with: 28 | tag_name: "v${{ steps.publish.outputs.version }}" 29 | release_name: Release ${{ steps.publish.outputs.version }} 30 | draft: true 31 | prerelease: false 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | demo/node_modules/ 3 | demo/uploads/ 4 | test/serve/bundle.js 5 | .idea 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Socket.IO File Upload 2 | ===================== 3 | 4 | This module provides functionality to upload files from a browser to a Node.JS server that runs Socket.IO. Throughout the process, if their browser supports WebSockets, the user will not submit a single HTTP request. Supports Socket.IO 0.9 and higher. 5 | 6 | The intended audience are single-page web apps, but other types of Node.JS projects may benefit from this library. 7 | 8 | Since version 0.4, this module also supports monitoring file upload progress. 9 | 10 | The module is released under the X11 open-source license. 11 | 12 | [![Node.js CI](https://github.com/sffc/socketio-file-upload/workflows/Node.js%20CI/badge.svg)](https://github.com/sffc/socketio-file-upload/actions) 13 | [![Known Vulnerabilities](https://snyk.io/test/github/sffc/socketio-file-upload/badge.svg)](https://snyk.io/test/github/sffc/socketio-file-upload) 14 | [![npm version](http://img.shields.io/npm/v/socketio-file-upload.svg?style=flat)](https://npmjs.org/package/socketio-file-upload "View this project on npm") 15 | 16 | 17 | ## Quick Start 18 | 19 | Navigate to your project directory and run: 20 | 21 | $ npm install --save socketio-file-upload 22 | 23 | In your Express app, add the router like this (if you don't use Express, read the docs below): 24 | 25 | ```javascript 26 | var siofu = require("socketio-file-upload"); 27 | var app = express() 28 | .use(siofu.router) 29 | .listen(8000); 30 | ``` 31 | 32 | On a server-side socket connection, do this: 33 | 34 | ```javascript 35 | io.on("connection", function(socket){ 36 | var uploader = new siofu(); 37 | uploader.dir = "/path/to/save/uploads"; 38 | uploader.listen(socket); 39 | }); 40 | ``` 41 | 42 | The client-side script is served at `/siofu/client.js`. Include it like this: 43 | 44 | ```html 45 | 46 | ``` 47 | 48 | If you use browserify, just require it like this: 49 | 50 | ```javascript 51 | var SocketIOFileUpload = require('socketio-file-upload'); 52 | ``` 53 | 54 | The module also supports AMD; see the docs below for more information. 55 | 56 | Then, in your client side app, with this HTML: 57 | 58 | ```html 59 | 60 | ``` 61 | 62 | Just do this in JavaScript: 63 | 64 | ```javascript 65 | var socket = io.connect(); 66 | var uploader = new SocketIOFileUpload(socket); 67 | uploader.listenOnInput(document.getElementById("siofu_input")); 68 | ``` 69 | 70 | That's all you need to get started. For the detailed API, continue reading below. A longer example is available at the bottom of the readme. 71 | 72 | ## Table of Contents 73 | 74 | - [Client-Side API](#client-side-api) 75 | - [instance.listenOnInput(input)](#instancelistenoninputinput) 76 | - [instance.listenOnDrop(element)](#instancelistenondropelement) 77 | - [instance.listenOnSubmit(submitButton, input)](#instancelistenonsubmitsubmitbutton-input) 78 | - [instance.listenOnArraySubmit(submitButton, input[])](#instancelistenonarraysubmitsubmitbutton-input) 79 | - [instance.prompt()](#instanceprompt) 80 | - [instance.submitFiles(files)](#instancesubmitfilesfiles) 81 | - [instance.destroy()](#instancedestroy) 82 | - [instance.resetFileInputs = true](#instanceresetFileInputs--true) 83 | - [instance.maxFileSize = null](#instancemaxfilesize--null) 84 | - [instance.chunkSize = 100 KiB](#instancechunksize--100-kib) 85 | - [instance.useText = false](#instanceusetext--false) 86 | - [instance.useBuffer = true](#instanceusebuffer--true) 87 | - [instance.serializeOctets = false](#instanceserializeoctets--false) 88 | - [instance.topicName = "siofu"](#instancetopicname--siofu) 89 | - [instance.wrapData = false](#instancewrapdata--false) 90 | - [instance.exposePrivateFunction = false](#instanceexposeprivatefunction--false) 91 | - [Client-Side Events](#events) 92 | - [choose](#choose) 93 | - [start](#start) 94 | - [progress](#progress) 95 | - [load](#load) 96 | - [complete](#complete) 97 | - [error](#error) 98 | - [Server-Side API](#server-side-api) 99 | - [SocketIOFileUpload.listen(app)](#socketiofileuploadlistenapp) 100 | - [SocketIOFileUpload.router](#socketiofileuploadrouter) 101 | - [instance.listen(socket)](#instancelistensocket) 102 | - [instance.abort(id, socket)](#instanceabortid-socket) 103 | - [instance.dir = "/path/to/upload/directory"](#instancedir--pathtouploaddirectory) 104 | - [instance.mode = "0666"](#instancemode--0666) 105 | - [instance.maxFileSize = null](#instancemaxfilesize--null-1) 106 | - [instance.emitChunkFail = false](#instanceemitchunkfail--false) 107 | - [instance.uploadValidator(event, callback)](#instanceuploadvalidatorevent-callback) 108 | - instance.topicName = "siofu" (see [client](#instancetopicname--siofu)) 109 | - instance.wrapData = false (see [client](#instancewrapdata--false)) 110 | - instance.exposePrivateFunction = false (see [client](#instanceexposeprivatefunction--false)) 111 | - [Server-Side Events](#events-1) 112 | - [start](#start-1) 113 | - [progress](#progress-1) 114 | - [complete](#complete-1) 115 | - [saved](#saved) 116 | - [error](#error) 117 | - [Adding Meta Data](#adding-meta-data) 118 | - [Client to Server](#client-to-server-meta-data) 119 | - [Server to Client](#server-to-client-meta-data) 120 | - [Example](#example) 121 | 122 | ## Client-Side API 123 | 124 | The client-side interface is inside the `SocketIOFileUpload` namespace. Include it with: 125 | 126 | ```html 127 | 128 | ``` 129 | 130 | If you're awesome and you use AMD/RequireJS, set up your paths config like this: 131 | 132 | ```javascript 133 | requirejs.config({ 134 | paths: { 135 | "SocketIOFileUpload": "/siofu/client", 136 | // ... 137 | } 138 | }); 139 | ``` 140 | 141 | and then include it in your app like this: 142 | 143 | ```javascript 144 | define("app", ["SocketIOFileUpload"], function(SocketIOFileUpload){ 145 | // ... 146 | }); 147 | ``` 148 | 149 | When instantiating an instance of the `SocketIOFileUpload`, pass a reference to your socket. 150 | 151 | ```javascript 152 | var instance = new SocketIOFileUpload(socket); 153 | ``` 154 | 155 | ### Public Properties and Methods 156 | 157 | Each public property can be set up in an object passing at second parameter of the Siofu constructor: 158 | 159 | ```javascript 160 | var instance = new SocketIOFileUpload(socket); 161 | instance.chunkSize = 1024 * 1000 162 | // is the same that 163 | var instance = new SocketIOFileUpload(socket, { 164 | chunkSize: 1024 * 1000 165 | }); 166 | ``` 167 | 168 | 169 | #### instance.listenOnInput(input) 170 | 171 | When the user selects a file or files in the specified HTML Input Element, the library will begin to upload that file or those files. 172 | 173 | JavaScript: 174 | 175 | ```javascript 176 | instance.listenOnInput(document.getElementById("file_input")); 177 | ``` 178 | 179 | HTML: 180 | 181 | ```html 182 | 183 | ``` 184 | 185 | All browsers tested support this method. 186 | 187 | #### instance.listenOnDrop(element) 188 | 189 | When the user drags and drops a file or files onto the specified HTML Element, the library will begin to upload that file or those files. 190 | 191 | JavaScript: 192 | 193 | ```javascript 194 | instance.listenOnDrop(document.getElementById("file_drop")); 195 | ``` 196 | 197 | HTML: 198 | 199 | ```html 200 |
Drop Files Here
201 | ``` 202 | 203 | In order to work, this method requires a browser that supports the HTML5 drag-and-drop interface. 204 | 205 | #### instance.listenOnSubmit(submitButton, input) 206 | 207 | Like `instance.listenOnInput(input)`, except instead of listening for the "change" event on the input element, listen for the "click" event of a button. 208 | 209 | JavaScript: 210 | 211 | ```javascript 212 | instance.listenOnSubmit(document.getElementById("my_button"), document.getElementById("file_input")); 213 | ``` 214 | 215 | HTML: 216 | 217 | ```html 218 | 219 | 220 | ``` 221 | 222 | #### instance.listenOnArraySubmit(submitButton, input[]) 223 | 224 | A shorthand for running `instance.listenOnSubmit(submitButton, input)` repeatedly over multiple file input elements. Accepts an array of file input elements as the second argument. 225 | 226 | #### instance.prompt() 227 | 228 | When this method is called, the user will be prompted to choose a file to upload. 229 | 230 | JavaScript: 231 | 232 | ```javascript 233 | document.getElementById("file_button").addEventListener("click", instance.prompt, false); 234 | ``` 235 | 236 | HTML: 237 | 238 | ```html 239 | 240 | ``` 241 | 242 | Unfortunately, this method does not work in Firefox for security reasons. Read the code comments for more information. 243 | 244 | #### instance.submitFiles(files) 245 | 246 | Call this method to manually submit an array of files. The argument can be either a [FileList](https://developer.mozilla.org/en-US/docs/Web/API/FileList) or an array of [File](https://developer.mozilla.org/en-US/docs/Web/API/File) objects. 247 | 248 | #### instance.destroy() 249 | 250 | Unbinds all events and DOM elements created by this instance of SIOFU. 251 | 252 | **Important Memory Note:** In order to remove the instance of SIOFU from memory, you need to do at least three things: 253 | 254 | 1. Remove all `siofu.prompt` event listeners *and then* 255 | 2. Call this function *and then* 256 | 3. Set this reference (and all references) to the instance to `null` 257 | 258 | For example, if you created an instance like this: 259 | 260 | ```javascript 261 | // ... 262 | var instance = new SocketIOFileUpload(socket); 263 | myBtn.addEventListener("click", instance.prompt, false); 264 | // ... 265 | ``` 266 | 267 | then you can remove it from memory like this: 268 | 269 | ```javascript 270 | myBtn.removeEventListener("click", instance.prompt, false); 271 | instance.destroy(); 272 | instance = null; 273 | ``` 274 | 275 | #### instance.resetFileInputs = true 276 | 277 | Defaults to `true`, which resets file input elements to their empty state after the user selects a file. If you do not reset the file input elements, if the user selects a file with the same name as the previous file, then the second file may not be uploaded. 278 | 279 | #### instance.maxFileSize = null 280 | 281 | Will cancel any attempt by the user to upload a file larger than this number of bytes. An "error" event with code 1 will be emitted if such an attempt is made. Defaults to a value of `null`, which does not enforce a file size limit. 282 | 283 | To tell the client when they have tried to upload a file that is too large, you can use the following code: 284 | 285 | ```javascript 286 | siofu.addEventListener("error", function(data){ 287 | if (data.code === 1) { 288 | alert("Don't upload such a big file"); 289 | } 290 | }); 291 | ``` 292 | 293 | For maximum security, if you set a maximum file size on the client side, you should also do so on the server side. 294 | 295 | #### instance.chunkSize = 100 KiB 296 | 297 | The size of the file "chunks" to be loaded at a time. This enables you to monitor the upload progress with a progress bar and the "progress" event (see below). 298 | 299 | The default value is 100 KiB, which is specified as 300 | 301 | `instance.chunkSize = 1024 * 100;` 302 | 303 | Setting this parameter to 0 disables chunking of files. 304 | 305 | #### instance.useText = false 306 | 307 | Defaults to `false`, which reads files as an octet array. This is necessary for binary-type files, like images. 308 | 309 | Set to `true` to read and transmit files as plain text instead. This will save bandwidth if you expect to transmit only text files. If you choose this option, it is recommended that you perform a filter by returning `false` to a `start` event if the file does not have a desired extension. 310 | 311 | #### instance.useBuffer = true 312 | 313 | Starting with Socket.IO 1.0, binary data may now be transmitted through the Web Socket. Begining with SIOFU version 0.3.2 (December 17, 2014), this option is enabled by default. To support older versions of Socket.IO (e.g. version 0.9.x), set this option to `false`, which transmits files as base 64-encoded strings. 314 | 315 | Advantages of enabling this option: 316 | 317 | - Less overhead in the socket, since base 64 increases overhead by approximately 33%. 318 | - No serialization and deserialization into and out of base 64 is required on the client and server side. 319 | 320 | Disadvantages of enabling this option: 321 | 322 | - Transmitting buffer types through a WebSocket is not supported in older browsers. 323 | - This option is relatively new in both Socket.IO and Socket.IO File Upload and has not been rigorously tested. 324 | 325 | As you use this option, [please leave feedback](https://github.com/vote539/socketio-file-upload/issues/16). 326 | 327 | #### instance.serializeOctets = false 328 | 329 | *This method is experimental, and has been deprecated in Socket.IO File Upload as of version 0.3 in favor of instance.useBuffer.* 330 | 331 | Defaults to `false`, which transmits binary files as Base 64 data (with a 33% overhead). 332 | 333 | Set to `true` to instead transmit the data as a serialized octet array. This will result in an overhead of over 1000% (not recommended for production applications). 334 | 335 | *Note:* This option is not supported by Firefox. 336 | 337 | #### instance.topicName = "siofu" 338 | 339 | Customize the name of the topic where Siofu emit message. Need to be the same that the one specified in the server options. 340 | 341 | Can be used in team with instance.wrapData and instance.exposePrivateFunction to use a topic already used for something else. 342 | 343 | #### instance.wrapData = false 344 | 345 | By default Siofu client sends data the server on a different topic depending of the progress of the upload: 346 | 347 | ``` 348 | siofu_start 349 | siofu_progress 350 | siofu_done 351 | ``` 352 | 353 | And events received from the server to the client: 354 | 355 | ``` 356 | siofu_ready 357 | siofu_chunk 358 | siofu_complete 359 | siofu_error 360 | ``` 361 | 362 | If wrapData is set to true, Siofu will use only one topic specified by instance.topicName and wrap the data into a parent message. 363 | 364 | The following examples are example settings for the client. :warning: IF YOU USE `wrapData` ON THE CLIENT, YOU MUST ALSO USE IT ON THE SERVER. :warning: 365 | 366 | ex: 367 | 368 | ```javascript 369 | // wrapData false: 370 | { 371 | id: id, 372 | success: success, 373 | detail: fileInfo.clientDetail 374 | } 375 | // wrapData true 376 | { 377 | action: 'complete', 378 | message: { 379 | id: id, 380 | success: success, 381 | detail: fileInfo.clientDetail 382 | } 383 | } 384 | ``` 385 | 386 | You can personalise the 'action' and 'message' key by passing a object to wrapData instance. The settings on the server should be the inverse of the settings on the client. For example, if the client has wrapData.wrapKey.message = "data", then the server should have wrapData.unwrapKey.message = "data". 387 | 388 | ```javascript 389 | instance.wrapData = { 390 | wrapKey: { 391 | action: 'actionType', 392 | message: 'data' 393 | }, 394 | unwrapKey: { 395 | action: 'actionType', 396 | message: 'message' 397 | } 398 | } 399 | // Send a message like this: 400 | { 401 | actionType: 'complete', 402 | data: { 403 | id: id, 404 | success: success, 405 | detail: fileInfo.clientDetail 406 | } 407 | } 408 | // Expect message like this from the server: 409 | { 410 | actionType: 'complete', 411 | message: { 412 | id: id, 413 | success: success, 414 | detail: fileInfo.clientDetail 415 | } 416 | } 417 | ``` 418 | 419 | It's also possible to add additional data (for strongly typed topic or secure pipeline or acknowledgement): 420 | ```javascript 421 | instance.wrapData = { 422 | adtionalData: { 423 | userId: '123456', 424 | }, 425 | } 426 | // Send a message like this: 427 | { 428 | userId: '123456', 429 | action: 'complete', 430 | message: { 431 | id: id, 432 | success: success, 433 | detail: fileInfo.clientDetail 434 | } 435 | } 436 | ``` 437 | 438 | #### instance.exposePrivateFunction = false 439 | 440 | If true this will expose some functions used in intern to personalize action on the topic. This is used alongside with wrapData to add custom check or logic before process the file upload. 441 | If true you will have access to: 442 | ``` 443 | instance.chunckCallback 444 | instance.readyCallback 445 | instance.completCallback 446 | instance.errorCallback 447 | ``` 448 | 449 | ### Events 450 | 451 | Instances of the `SocketIOFileUpload` object implement the [W3C `EventTarget` interface](http://www.w3.org/wiki/DOM/domcore/EventTarget). This means that you can do: 452 | 453 | * `instance.addEventListener("type", callback)` 454 | * `instance.removeEventListener("type", callback)` 455 | * `instance.dispatchEvent(event)` 456 | 457 | The events are documented below. 458 | 459 | #### choose 460 | 461 | The user has chosen files to upload, through any of the channels you have implemented. If you want to cancel the upload, make your callback return `false`. 462 | 463 | ##### Event Properties 464 | 465 | * `event.files` an instance of a W3C FileList object 466 | 467 | #### start 468 | 469 | This event is fired immediately following the `choose` event, but once per file. If you want to cancel the upload for this individual file, make your callback return `false`. 470 | 471 | ##### Event Properties 472 | 473 | * `event.file` an instance of a W3C File object 474 | 475 | #### progress 476 | 477 | Part of the file has been loaded from the file system and ready to be transmitted via Socket.IO. This event can be used to make an upload progress bar. 478 | 479 | You can compute the percent progress via `event.bytesLoaded / event.file.size` 480 | 481 | ##### Event Properties 482 | 483 | * `event.file` an instance of a W3C File object 484 | * `event.bytesLoaded` the number of bytes that have been loaded into memory 485 | * `event.name` the filename to which the server saved the file 486 | 487 | #### load 488 | 489 | A file has been loaded into an instance of the HTML5 FileReader object and has been transmitted through Socket.IO. We are awaiting a response from the server about whether the upload was successful; when we receive this response, a `complete` event will be dispatched. 490 | 491 | ##### Event Properties 492 | 493 | * `event.file` an instance of a W3C File object 494 | * `event.reader` an instance of a W3C FileReader object 495 | * `event.name` the filename to which the server saved the file 496 | 497 | #### complete 498 | 499 | The server has received our file. 500 | 501 | ##### Event Properties 502 | 503 | * `event.file` an instance of a W3C File object 504 | * `event.success` true if the server-side implementation ran without error; false otherwise 505 | * `event.detail` The value of `file.clientDetail` on the server side. Properties may be added to this object literal during any event on the server side. 506 | 507 | #### error 508 | 509 | The server encountered an error. 510 | 511 | ##### Event Properties 512 | 513 | * `event.file` an instance of a W3C File object 514 | * `event.message` the error message 515 | * `event.code` the error code, if available 516 | 517 | ## Server-Side API 518 | 519 | The server-side interface is contained within an NPM module. Require it with: 520 | 521 | ```javascript 522 | var SocketIOFileUpload = require("socketio-file-upload"); 523 | ``` 524 | 525 | ### Static Properties and Methods 526 | 527 | #### SocketIOFileUpload.listen(app) 528 | 529 | If you are using an HTTP server in Node, pass it into this method in order for the client-side JavaScript file to be served. 530 | 531 | ```javascript 532 | var app = http.createServer( /* your configurations here */ ).listen(80); 533 | SocketIOFileUpload.listen(app); 534 | ``` 535 | 536 | #### SocketIOFileUpload.router 537 | 538 | If you are using Connect-based middleware like Express, pass this value into the middleware. 539 | 540 | ```javascript 541 | var app = express() 542 | .use(SocketIOFileUpload.router) 543 | .use( /* your other middleware here */ ) 544 | .listen(80); 545 | ``` 546 | 547 | ### Public Properties and Methods 548 | 549 | #### instance.listen(socket) 550 | 551 | Listen for uploads occuring on this Socket.IO socket. 552 | 553 | ```javascript 554 | io.sockets.on("connection", function(socket){ 555 | var uploader = new SocketIOFileUpload(); 556 | uploader.listen(socket); 557 | }); 558 | ``` 559 | 560 | #### instance.abort(id, socket) 561 | 562 | Aborts an upload that is in progress. Example use case: 563 | 564 | ```javascript 565 | uploader.on("start", function(event){ 566 | if (/\.exe$/.test(event.file.name)) { 567 | uploader.abort(event.file.id, socket); 568 | } 569 | }); 570 | ``` 571 | 572 | #### instance.dir = "/path/to/upload/directory" 573 | 574 | If specified, the module will attempt to save uploaded files in this directory. The module will intelligently suffix numbers to the uploaded filenames until name conflicts are resolved. It will also sanitize the filename to help prevent attacks. 575 | 576 | The last-modified time of the file might be retained from the upload. If this is of high importance to you, I recommend performing some tests, and if it does not meet your needs, submit an issue or a pull request. 577 | 578 | #### instance.mode = "0666" 579 | 580 | Use these UNIX permissions when saving the uploaded file. Defaults to `0666`. 581 | 582 | #### instance.maxFileSize = null 583 | 584 | The maximum file size, in bytes, to write to the disk. If file data is received from the client that exceeds this bound, the data will not be written to the disk and an "error" event will be thrown. Defaults to `null`, in which no maximum file size is enforced. 585 | 586 | Note that the other events like "progress", "complete", and "saved" will still be emitted even if the file's maximum allowed size had been exceeded. However, in those events, `event.file.success` will be false. 587 | 588 | #### instance.emitChunkFail = false 589 | 590 | Whether or not to emit an error event if a progress chunk fails to finish writing. In most cases, the failure is a harmless notification that the file is larger than the internal buffer size, but it could also mean that the file upload triggered an ENOSPC error. It may be useful to enable this error event if you are concerned about uploads running out of space. 591 | 592 | #### instance.uploadValidator(event, callback) 593 | 594 | Can be overridden to enable async validation and preparing. 595 | 596 | ```javascript 597 | uploader.uploadValidator = function(event, callback){ 598 | // asynchronous operations allowed here; when done, 599 | if (/* success */) { 600 | callback(true); 601 | } else { 602 | callback(false); 603 | } 604 | }; 605 | ``` 606 | 607 | ### Events 608 | 609 | Instances of `SocketIOFileUpload` implement [Node's `EventEmitter` interface](http://nodejs.org/api/events.html#events_class_events_eventemitter). This means that you can do: 610 | 611 | * `instance.on("type", callback)` 612 | * `instance.removeListener("type", callback)` 613 | * `instance.emit("type", event)` 614 | * et cetera. 615 | 616 | The events are documented below. 617 | 618 | #### start 619 | 620 | The client has started the upload process, and the server is now processing the request. 621 | 622 | ##### Event Properties 623 | 624 | * `event.file` An object containing the file's `name`, `mtime`, `encoding`, `meta`, `success`, `bytesLoaded`, and `id`. 625 | *Note:* `encoding` is either "text" if the file is being transmitted as plain text or "octet" if it is being transmitted using an ArrayBuffer. *Note:* In the "progress", "complete", "saved", and "error" events, if you are letting the module save the file for you, the file object will contain two additional properties: `base`, the new base name given to the file, and `pathName`, the full path at which the uploaded file was saved. 626 | 627 | #### progress 628 | 629 | Data has been received from the client. 630 | 631 | ##### Event Properties 632 | 633 | * `event.file` The same file object that would have been passed during the `start` event earlier. 634 | * `event.buffer` A buffer containing the data received from the client 635 | 636 | #### complete 637 | 638 | The transmission of a file is complete. 639 | 640 | ##### Event Properties 641 | 642 | * `event.file` The same file object that would have been passed during the `start` event earlier. 643 | * `event.interrupt` true if the client said that the data was interrupted (not completely sent); false otherwise 644 | 645 | #### saved 646 | 647 | A file has been saved. It is recommended that you check `event.file.success` to tell whether or not the file was saved without errors. 648 | 649 | In this event, you can safely move the saved file to a new location. 650 | 651 | ##### Event Properties 652 | 653 | * `event.file` The same file object that would have been passed during the `start` event earlier. 654 | 655 | #### error 656 | 657 | An error was encountered in the saving of the file. 658 | 659 | ##### Event Properties 660 | 661 | * `event.file` The same file object that would have been passed during the `start` event earlier. 662 | * `event.error` The I/O error that was encountered. 663 | 664 | ## Adding Meta Data 665 | 666 | It is sometimes useful to add metadata to a file prior to uploading the file. You may add metadata to a file on the client side by setting the `file.meta` property on the File object during the "choose" or "start" events. You may also add metadata to a file on the server side by setting the `file.clientDetail` property on the fileInfo object during any of the server-side events. 667 | 668 | ### Client to Server Meta Data 669 | 670 | To add meta data to an individual file, you can listen on the "start" event as shown below. 671 | 672 | ```javascript 673 | // client side 674 | siofu.addEventListener("start", function(event){ 675 | event.file.meta.hello = "world"; 676 | }); 677 | ``` 678 | 679 | The data is then available on the server side as follows. 680 | 681 | ```javascript 682 | // server side 683 | uploader.on("saved", function(event){ 684 | console.log(event.file.meta.hello); 685 | }); 686 | ``` 687 | 688 | You can also refer back to your meta data at any time on the client side by referencing the same `event.file.meta` object literal. 689 | 690 | ### Server to Client Meta Data 691 | 692 | You can add meta data on the server. The meta data will be available to the client on the "complete" event on the client as shown below. 693 | 694 | ```javascript 695 | // server side 696 | siofuServer.on("saved", function(event){ 697 | event.file.clientDetail.hello = "world"; 698 | }); 699 | ``` 700 | 701 | The information saved in `event.file.clientDetail` will be available in `event.detail` on the client side. 702 | 703 | ```javascript 704 | // client side 705 | siofu.addEventListener("complete", function(event){ 706 | console.log(event.detail.hello); 707 | }); 708 | ``` 709 | 710 | ## Example 711 | 712 | This example assumes that you are running your application via the Connect middleware, including Express. If you are using a middleware that is not Connect-based or Node-HTTP-based, download the `client.js` file from the project repository and serve it on the path `/siofu/client.js`. Alternatively, you may contribute an adapter for your middleware to this project and submit a pull request. 713 | 714 | ### Server Code: app.js 715 | 716 | ```javascript 717 | // Require the libraries: 718 | var SocketIOFileUpload = require('socketio-file-upload'), 719 | socketio = require('socket.io'), 720 | express = require('express'); 721 | 722 | // Make your Express server: 723 | var app = express() 724 | .use(SocketIOFileUpload.router) 725 | .use(express.static(__dirname + "/public")) 726 | .listen(80); 727 | 728 | // Start up Socket.IO: 729 | var io = socketio.listen(app); 730 | io.sockets.on("connection", function(socket){ 731 | 732 | // Make an instance of SocketIOFileUpload and listen on this socket: 733 | var uploader = new SocketIOFileUpload(); 734 | uploader.dir = "/srv/uploads"; 735 | uploader.listen(socket); 736 | 737 | // Do something when a file is saved: 738 | uploader.on("saved", function(event){ 739 | console.log(event.file); 740 | }); 741 | 742 | // Error handler: 743 | uploader.on("error", function(event){ 744 | console.log("Error from uploader", event); 745 | }); 746 | }); 747 | ``` 748 | 749 | ### Client Code: public/index.html 750 | 751 | ```html 752 | 753 | 754 | 755 | Upload Files 756 | 757 | 758 | 759 | 785 | 786 | 787 | 788 | 789 |

790 |

791 |
Drop File
792 | 793 | 794 | 795 | ``` 796 | 797 | ## Future Work 798 | 799 | First, I'm aware that this module currently lacks unit tests (mocha, etc). This is a problem that should be solved. I'm willing to accept PRs that add unit tests, or else one of these days when I have extra time I'll see if I can add them myself. 800 | 801 | In addition, the following features would be useful for the module to support. 802 | 803 | 1. Allow input of a file URL rather than uploading a file from your computer or mobile device. 804 | 805 | As always PRs are welcome. 806 | -------------------------------------------------------------------------------- /client.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2015 Shane Carr and others 3 | * X11 License 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a 6 | * copy of this software and associated documentation files (the "Software"), 7 | * to deal in the Software without restriction, including without limitation 8 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, 9 | * and/or sell copies of the Software, and to permit persons to whom the 10 | * Software is furnished to do so, subject to the following conditions: 11 | * 12 | * The above copyright notice and this permission notice shall be included in 13 | * all copies or substantial portions of the Software. 14 | * 15 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 21 | * DEALINGS IN THE SOFTWARE. 22 | * 23 | * Except as contained in this notice, the names of the authors or copyright 24 | * holders shall not be used in advertising or otherwise to promote the sale, 25 | * use or other dealings in this Software without prior written authorization 26 | * from the authors or copyright holders. 27 | */ 28 | 29 | // Do not check function indentation because this is intentionally ignored in order to preserve history in git. 30 | /* eslint-disable indent */ 31 | 32 | /* 33 | * A client-side JavaScript object to handle file uploads to a Node.JS server 34 | * via Socket.IO. 35 | * @implements EventTarget 36 | * @param {SocketIO} socket The current Socket.IO connection. 37 | */ 38 | (function (scope, name, factory) { 39 | /* eslint-disable no-undef */ 40 | if (typeof define === "function" && define.amd) { 41 | define([], factory); 42 | } 43 | else if (typeof module === "object" && module.exports) { 44 | module.exports = factory(); 45 | } 46 | else { 47 | scope[name] = factory(); 48 | } 49 | /* eslint-enable no-undef */ 50 | }(this, "SocketIOFileUpload", function () { 51 | return function (socket, options) { 52 | "use strict"; 53 | 54 | var self = this; // avoids context issues 55 | 56 | // Check for compatibility 57 | if (!window.File || !window.FileReader) { 58 | throw new Error("Socket.IO File Upload: Browser Not Supported"); 59 | } 60 | 61 | if ( !window.siofu_global ) { 62 | window.siofu_global = { 63 | instances: 0, 64 | downloads: 0 65 | }; 66 | } 67 | 68 | // Private and Public Variables 69 | var callbacks = {}, 70 | uploadedFiles = {}, 71 | chunkCallbacks = {}, 72 | readyCallbacks = {}, 73 | communicators = {}; 74 | 75 | var _getOption = function (key, defaultValue) { 76 | if(!options) { 77 | return defaultValue; 78 | } 79 | return options[key] || defaultValue; 80 | }; 81 | 82 | self.fileInputElementId = "siofu_input_"+window.siofu_global.instances++; 83 | self.resetFileInputs = true; 84 | self.useText = _getOption("useText", false); 85 | self.serializedOctets = _getOption("serializedOctets", false); 86 | self.useBuffer = _getOption("useBuffer", true); 87 | self.chunkSize = _getOption("chunkSize", 1024 * 100); // 100kb default chunk size 88 | self.topicName = _getOption("topicName", "siofu"); 89 | 90 | /** 91 | * WrapData allow you to wrap the Siofu messages into a predefined format. 92 | * You can then easily use Siofu packages even in strongly typed topic. 93 | * wrapData can be a boolean or an object. It is false by default. 94 | * If wrapData is true it will allow you to send all the messages to only one topic by wrapping the siofu actions and messages. 95 | * 96 | * ex: 97 | { 98 | action: 'complete', 99 | message: { 100 | id: id, 101 | success: success, 102 | detail: fileInfo.clientDetail 103 | } 104 | } 105 | * 106 | * If wrapData is an object constituted of two mandatory key and one optional: 107 | * wrapKey and unwrapKey (mandatory): Corresponding to the key used to wrap the siofu data and message 108 | * additionalData (optional): Corresponding to the data to send along with file data 109 | * 110 | * ex: 111 | * if wrapData = { 112 | wrapKey: { 113 | action: 'actionType', 114 | message: 'data' 115 | }, 116 | unwrapKey: { 117 | action: 'actionType', 118 | message: 'message' 119 | }, 120 | additionalData: { 121 | acknowledgement: true 122 | } 123 | } 124 | * When Siofu will send for example a complete message this will send: 125 | * 126 | { 127 | acknowledgement: true, 128 | actionType: 'complete', 129 | data: { 130 | id: id, 131 | success: success, 132 | detail: fileInfo.clientDetail 133 | } 134 | } 135 | * and it's waiting from client data formatted like this: 136 | * 137 | { 138 | actionType: '...', 139 | message: {...} 140 | } 141 | * /!\ If wrapData is wrong configured is interpreted as false /!\ 142 | */ 143 | self.wrapData = _getOption("wrapData", false); 144 | 145 | var _isWrapDataWellConfigured = function () { 146 | if (typeof self.wrapData === "boolean") { 147 | return true; 148 | } 149 | if (typeof self.wrapData !== "object" || Array.isArray(self.wrapData)) { 150 | return false; 151 | } 152 | 153 | if(!self.wrapData.wrapKey || typeof self.wrapData.wrapKey.action !== "string" || typeof self.wrapData.wrapKey.message !== "string" || 154 | !self.wrapData.unwrapKey || typeof self.wrapData.unwrapKey.action !== "string" || typeof self.wrapData.unwrapKey.message !== "string") { 155 | return false; 156 | } 157 | 158 | return true; 159 | }; 160 | 161 | 162 | /** 163 | * Allow user to access to some private function to customize message reception. 164 | * This is used if you specified wrapOptions on the client side and have to manually bind message to callback. 165 | */ 166 | self.exposePrivateFunction = _getOption("exposePrivateFunction", false); 167 | 168 | var _getTopicName = function (topicExtension) { 169 | if (self.wrapData) { 170 | return self.topicName; 171 | } 172 | 173 | return self.topicName + topicExtension; 174 | }; 175 | 176 | var _wrapData = function (data, action) { 177 | if(!_isWrapDataWellConfigured() || !self.wrapData) { 178 | return data; 179 | } 180 | var dataWrapped = {}; 181 | if(self.wrapData.additionalData) { 182 | Object.assign(dataWrapped, self.wrapData.additionalData); 183 | } 184 | 185 | var actionKey = self.wrapData.wrapKey && typeof self.wrapData.wrapKey.action === "string" ? self.wrapData.wrapKey.action : "action"; 186 | var messageKey = self.wrapData.wrapKey && typeof self.wrapData.wrapKey.message === "string" ? self.wrapData.wrapKey.message : "message"; 187 | 188 | dataWrapped[actionKey] = action; 189 | dataWrapped[messageKey] = data; 190 | return dataWrapped; 191 | }; 192 | 193 | /** 194 | * Private method to dispatch a custom event on the instance. 195 | * @param {string} eventName Name for which listeners can listen. 196 | * @param {object} properties An object literal with additional properties 197 | * to be attached to the event object. 198 | * @return {boolean} false if any callback returned false; true otherwise 199 | */ 200 | var _dispatch = function (eventName, properties) { 201 | var evnt = document.createEvent("Event"); 202 | evnt.initEvent(eventName, false, false); 203 | for (var prop in properties) { 204 | if (properties.hasOwnProperty(prop)) { 205 | evnt[prop] = properties[prop]; 206 | } 207 | } 208 | return self.dispatchEvent(evnt); 209 | }; 210 | 211 | /** 212 | * Private method to bind an event listener. Useful to ensure that all 213 | * events have been unbound. Inspired by Backbone.js. 214 | */ 215 | var _listenedReferences = []; 216 | var _listenTo = function (object, eventName, callback, bubble) { 217 | object.addEventListener(eventName, callback, bubble); 218 | _listenedReferences.push(arguments); 219 | }; 220 | var _stopListeningTo = function (object, eventName, callback, bubble) { 221 | if (object.removeEventListener) { 222 | object.removeEventListener(eventName, callback, bubble); 223 | } 224 | }; 225 | var _stopListening = function () { 226 | for (var i = _listenedReferences.length - 1; i >= 0; i--) { 227 | _stopListeningTo.apply(this, _listenedReferences[i]); 228 | } 229 | _listenedReferences = []; 230 | }; 231 | 232 | /** 233 | * Private closure for the _load function. 234 | * @param {File} file A W3C File object 235 | * @return {void} 236 | */ 237 | var _loadOne = function (file) { 238 | // First check for file size 239 | if (self.maxFileSize !== null && file.size > self.maxFileSize) { 240 | _dispatch("error", { 241 | file: file, 242 | message: "Attempt by client to upload file exceeding the maximum file size", 243 | code: 1 244 | }); 245 | return; 246 | } 247 | 248 | // Dispatch an event to listeners and stop now if they don't want 249 | // this file to be uploaded. 250 | var evntResult = _dispatch("start", { 251 | file: file 252 | }); 253 | if (!evntResult) return; 254 | 255 | // Scope variables 256 | var reader = new FileReader(), 257 | id = window.siofu_global.downloads++, 258 | uploadComplete = false, 259 | useText = self.useText, 260 | offset = 0, 261 | newName; 262 | if (reader._realReader) reader = reader._realReader; // Support Android Crosswalk 263 | uploadedFiles[id] = file; 264 | 265 | // An object for the outside to use to communicate with us 266 | var communicator = { id: id }; 267 | 268 | // Calculate chunk size 269 | var chunkSize = self.chunkSize; 270 | if (chunkSize >= file.size || chunkSize <= 0) chunkSize = file.size; 271 | 272 | // Private function to handle transmission of file data 273 | var transmitPart = function (start, end, content) { 274 | var isBase64 = false; 275 | if (!useText) { 276 | try { 277 | var uintArr = new Uint8Array(content); 278 | 279 | // Support the transmission of serialized ArrayBuffers 280 | // for experimental purposes, but default to encoding the 281 | // transmission in Base 64. 282 | if (self.serializedOctets) { 283 | content = uintArr; 284 | } 285 | else if (self.useBuffer) { 286 | content = uintArr.buffer; 287 | } 288 | else { 289 | isBase64 = true; 290 | content = _uint8ArrayToBase64(uintArr); 291 | } 292 | } 293 | catch (error) { 294 | socket.emit(_getTopicName("_done"), _wrapData({ 295 | id: id, 296 | interrupt: true 297 | }, "done")); 298 | return; 299 | } 300 | } 301 | 302 | // TODO override the send data 303 | socket.emit(_getTopicName("_progress"), _wrapData({ 304 | id: id, 305 | size: file.size, 306 | start: start, 307 | end: end, 308 | content: content, 309 | base64: isBase64 310 | }, "progress")); 311 | }; 312 | 313 | // Callback when tranmission is complete. 314 | var transmitDone = function () { 315 | socket.emit(_getTopicName("_done"), _wrapData({ 316 | id: id 317 | }, "done")); 318 | }; 319 | 320 | // Load a "chunk" of the file from offset to offset+chunkSize. 321 | // 322 | // Note that FileReader has its own "progress" event. However, 323 | // it has not proven to be reliable enough for production. See 324 | // Stack Overflow question #16713386. 325 | // 326 | // To compensate, we will manually load the file in chunks of a 327 | // size specified by the user in the uploader.chunkSize property. 328 | var processChunk = function () { 329 | // Abort if we are told to do so. 330 | if (communicator.abort) return; 331 | 332 | var chunk = file.slice(offset, Math.min(offset+chunkSize, file.size)); 333 | if (useText) { 334 | reader.readAsText(chunk); 335 | } 336 | else { 337 | reader.readAsArrayBuffer(chunk); 338 | } 339 | }; 340 | 341 | // Callback for when the reader has completed a load event. 342 | var loadCb = function (event) { 343 | // Abort if we are told to do so. 344 | if (communicator.abort) return; 345 | 346 | // Transmit the newly loaded data to the server and emit a client event 347 | var bytesLoaded = Math.min(offset+chunkSize, file.size); 348 | transmitPart(offset, bytesLoaded, event.target.result); 349 | _dispatch("progress", { 350 | file: file, 351 | bytesLoaded: bytesLoaded, 352 | name: newName 353 | }); 354 | 355 | // Get ready to send the next chunk 356 | offset += chunkSize; 357 | if (offset >= file.size) { 358 | // All done! 359 | transmitDone(); 360 | _dispatch("load", { 361 | file: file, 362 | reader: reader, 363 | name: newName 364 | }); 365 | uploadComplete = true; 366 | } 367 | }; 368 | _listenTo(reader, "load", loadCb); 369 | 370 | // Listen for an "error" event. Stop the transmission if one is received. 371 | _listenTo(reader, "error", function () { 372 | socket.emit(_getTopicName("_done"), _wrapData({ 373 | id: id, 374 | interrupt: true 375 | }, "done")); 376 | _stopListeningTo(reader, "load", loadCb); 377 | }); 378 | 379 | // Do the same for the "abort" event. 380 | _listenTo(reader, "abort", function () { 381 | socket.emit(_getTopicName("_done"), _wrapData({ 382 | id: id, 383 | interrupt: true 384 | }, "done")); 385 | _stopListeningTo(reader, "load", loadCb); 386 | }); 387 | 388 | // Transmit the "start" message to the server. 389 | socket.emit(_getTopicName("_start"), _wrapData({ 390 | name: file.name, 391 | mtime: file.lastModified, 392 | meta: file.meta, 393 | size: file.size, 394 | encoding: useText ? "text" : "octet", 395 | id: id 396 | }, "start")); 397 | 398 | // To avoid a race condition, we don't want to start transmitting to the 399 | // server until the server says it is ready. 400 | var readyCallback = function (_newName) { 401 | newName = _newName; 402 | processChunk(); 403 | }; 404 | var chunkCallback = function(){ 405 | if ( !uploadComplete ) 406 | processChunk(); 407 | }; 408 | readyCallbacks[id] = readyCallback; 409 | chunkCallbacks[id] = chunkCallback; 410 | 411 | return communicator; 412 | }; 413 | 414 | /** 415 | * Private function to load the file into memory using the HTML5 FileReader object 416 | * and then transmit that file through Socket.IO. 417 | * 418 | * @param {FileList} files An array of files 419 | * @return {void} 420 | */ 421 | var _load = function (files) { 422 | // Iterate through the array of files. 423 | for (var i = 0; i < files.length; i++) { 424 | // Evaluate each file in a closure, because we will need a new 425 | // instance of FileReader for each file. 426 | var communicator = _loadOne(files[i]); 427 | communicators[communicator.id] = communicator; 428 | } 429 | }; 430 | 431 | /** 432 | * Private function to fetch an HTMLInputElement instance that can be used 433 | * during the file selection process. 434 | * @return {void} 435 | */ 436 | var _getInputElement = function () { 437 | var inpt = document.getElementById(self.fileInputElementId); 438 | if (!inpt) { 439 | inpt = document.createElement("input"); 440 | inpt.setAttribute("type", "file"); 441 | inpt.setAttribute("id", self.fileInputElementId); 442 | inpt.style.display = "none"; 443 | document.body.appendChild(inpt); 444 | } 445 | return inpt; 446 | }; 447 | 448 | /** 449 | * Private function to remove an HTMLInputElement created by this instance 450 | * of SIOFU. 451 | * 452 | * @return {void} 453 | */ 454 | var _removeInputElement = function () { 455 | var inpt = document.getElementById(self.fileInputElementId); 456 | if (inpt) { 457 | inpt.parentNode.removeChild(inpt); 458 | } 459 | }; 460 | 461 | var _baseFileSelectCallback = function (files) { 462 | if (files.length === 0) return; 463 | 464 | // Ensure existence of meta property on each file 465 | for (var i = 0; i < files.length; i++) { 466 | if(!files[i].meta) files[i].meta = {}; 467 | } 468 | 469 | // Dispatch the "choose" event 470 | var evntResult = _dispatch("choose", { 471 | files: files 472 | }); 473 | 474 | // If the callback didn't return false, continue with the upload 475 | if (evntResult) { 476 | _load(files); 477 | } 478 | }; 479 | 480 | /** 481 | * Private function that serves as a callback on file input. 482 | * @param {Event} event The file input change event 483 | * @return {void} 484 | */ 485 | var _fileSelectCallback = function (event) { 486 | var files = event.target.files || event.dataTransfer.files; 487 | event.preventDefault(); 488 | _baseFileSelectCallback(files); 489 | 490 | if (self.resetFileInputs) { 491 | try { 492 | event.target.value = ""; //for IE11, latest Chrome/Firefox/Opera... 493 | } catch(err) { 494 | // ignore 495 | } 496 | if (event.target.value) { //for IE5 ~ IE10 497 | var form = document.createElement("form"), 498 | parentNode = event.target.parentNode, ref = event.target.nextSibling; 499 | form.appendChild(event.target); 500 | form.reset(); 501 | parentNode.insertBefore(event.target, ref); 502 | } 503 | } 504 | }; 505 | 506 | 507 | /** 508 | * Submit files at arbitrary time 509 | * @param {FileList} files Files received form the input element. 510 | * @return {void} 511 | */ 512 | this.submitFiles = function (files) { 513 | if (files) { 514 | _baseFileSelectCallback(files); 515 | } 516 | }; 517 | 518 | /** 519 | * Use a submitButton to upload files from the field given 520 | * @param {HTMLInputElement} submitButton the button that the user has to 521 | * click to start the upload 522 | * @param {HTMLInputElement} input the field with the data to upload 523 | * 524 | * @return {void} 525 | */ 526 | this.listenOnSubmit = function (submitButton, input) { 527 | if (!input.files) return; 528 | _listenTo(submitButton, "click", function () { 529 | _baseFileSelectCallback(input.files); 530 | }, false); 531 | }; 532 | 533 | /** 534 | * Use a submitButton to upload files from the field given 535 | * @param {HTMLInputElement} submitButton the button that the user has to 536 | * click to start the upload 537 | * @param {Array} array an array of fields with the files to upload 538 | * 539 | * @return {void} 540 | */ 541 | this.listenOnArraySubmit = function (submitButton, array) { 542 | for (var index in array) { 543 | this.listenOnSubmit(submitButton, array[index]); 544 | } 545 | }; 546 | 547 | /** 548 | * Use a file input to activate this instance of the file uploader. 549 | * @param {HTMLInputElement} inpt The input element (e.g., as returned by 550 | * document.getElementById("yourId")) 551 | * @return {void} 552 | */ 553 | this.listenOnInput = function (inpt) { 554 | if (!inpt.files) return; 555 | _listenTo(inpt, "change", _fileSelectCallback, false); 556 | }; 557 | 558 | /** 559 | * Accept files dropped on an element and upload them using this instance 560 | * of the file uploader. 561 | * @param {HTMLELement} div Any HTML element. When the user drags a file 562 | * or files onto this element, those files will 563 | * be processed by the instance. 564 | * @return {void} 565 | */ 566 | this.listenOnDrop = function (div) { 567 | // We need to preventDefault on the dragover event in order for the 568 | // drag-and-drop operation to work. 569 | _listenTo(div, "dragover", function (event) { 570 | event.preventDefault(); 571 | }, false); 572 | 573 | _listenTo(div, "drop", _fileSelectCallback); 574 | }; 575 | 576 | /** 577 | * Display a dialog box for the user to select a file. The file will then 578 | * be uploaded using this instance of SocketIOFileUpload. 579 | * 580 | * This method works in all current browsers except Firefox, though Opera 581 | * requires that the input element be visible. 582 | * 583 | * @return {void} 584 | */ 585 | this.prompt = function () { 586 | var inpt = _getInputElement(); 587 | 588 | // Listen for the "change" event on the file input element. 589 | _listenTo(inpt, "change", _fileSelectCallback, false); 590 | 591 | // Fire a click event on the input element. Firefox does not allow 592 | // programatic clicks on input elements, but the other browsers do. 593 | // Note that Opera requires that the element be visible when "clicked". 594 | var evnt = document.createEvent("MouseEvents"); 595 | evnt.initMouseEvent("click", true, true, window, 596 | 0, 0, 0, 0, 0, false, false, false, false, 0, null); 597 | inpt.dispatchEvent(evnt); 598 | }; 599 | 600 | /** 601 | * Destroy an instance of Socket.IO file upload (i.e., unbind events and 602 | * relieve memory). 603 | * 604 | * IMPORTANT: To finish the memory relief process, set all external 605 | * references to this instance of SIOFU (including the reference used to 606 | * call this destroy function) to null. 607 | * 608 | * @return {void} 609 | */ 610 | this.destroy = function () { 611 | _stopListening(); 612 | _removeInputElement(); 613 | for (var id in communicators) { 614 | if (communicators.hasOwnProperty(id)) { 615 | communicators[id].abort = true; 616 | } 617 | } 618 | callbacks = null, uploadedFiles = null, readyCallbacks = null, communicators = null; 619 | }; 620 | 621 | /** 622 | * Registers an event listener. If the callback function returns false, 623 | * the file uploader will stop uploading the current file. 624 | * @param {string} eventName Type of event for which to listen. 625 | * @param {Function} callback Listener function. Will be passed the 626 | * event as an argument when the event occurs. 627 | * @return {void} 628 | */ 629 | this.addEventListener = function (eventName, callback) { 630 | if (!callbacks[eventName]) callbacks[eventName] = []; 631 | callbacks[eventName].push(callback); 632 | }; 633 | 634 | /** 635 | * Removes an event listener. 636 | * @param {string} eventName Type of event. 637 | * @param {Function} callback Listener function to remove. 638 | * @return {boolean} true if callback removed; false otherwise 639 | */ 640 | this.removeEventListener = function (eventName, callback) { 641 | if (!callbacks[eventName]) return false; 642 | for (var i = 0; i < callbacks[eventName].length; i++) { 643 | if (callbacks[eventName][i] === callback) { 644 | callbacks[eventName].splice(i, 1); 645 | return true; 646 | } 647 | } 648 | return false; 649 | }; 650 | 651 | /** 652 | * Dispatches an event into this instance's event model. 653 | * @param {Event} evnt The event to dispatch. 654 | * @return {boolean} false if any callback returned false; true otherwise 655 | */ 656 | this.dispatchEvent = function (evnt) { 657 | var eventCallbacks = callbacks[evnt.type]; 658 | if (!eventCallbacks) return true; 659 | var retVal = true; 660 | for (var i = 0; i < eventCallbacks.length; i++) { 661 | var callbackResult = eventCallbacks[i](evnt); 662 | if (callbackResult === false) { 663 | retVal = false; 664 | } 665 | } 666 | return retVal; 667 | }; 668 | 669 | // OTHER LIBRARIES 670 | /* 671 | * base64-arraybuffer 672 | * https://github.com/niklasvh/base64-arraybuffer 673 | * 674 | * Copyright (c) 2012 Niklas von Hertzen 675 | * Licensed under the MIT license. 676 | * 677 | * Adapted for SocketIOFileUpload. 678 | */ 679 | var _uint8ArrayToBase64 = function (bytes) { 680 | var i, len = bytes.buffer.byteLength, base64 = "", 681 | chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; 682 | 683 | for (i = 0; i < len; i += 3) { 684 | base64 += chars[bytes[i] >> 2]; 685 | base64 += chars[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]; 686 | base64 += chars[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)]; 687 | base64 += chars[bytes[i + 2] & 63]; 688 | } 689 | 690 | if ((len % 3) === 2) { 691 | base64 = base64.substring(0, base64.length - 1) + "="; 692 | } 693 | else if (len % 3 === 1) { 694 | base64 = base64.substring(0, base64.length - 2) + "=="; 695 | } 696 | 697 | return base64; 698 | }; 699 | // END OTHER LIBRARIES 700 | var _chunckCallback = function(data) { 701 | if ( chunkCallbacks[data.id] ) 702 | chunkCallbacks[data.id](); 703 | }; 704 | 705 | var _readyCallback = function (data) { 706 | if (readyCallbacks[data.id]) 707 | readyCallbacks[data.id](data.name); 708 | }; 709 | 710 | var _completCallback = function (data) { 711 | if (uploadedFiles[data.id]) { 712 | _dispatch("complete", { 713 | file: uploadedFiles[data.id], 714 | detail: data.detail, 715 | success: data.success 716 | }); 717 | } 718 | }; 719 | 720 | var _errorCallback = function (data) { 721 | if ( uploadedFiles[data.id] ) { 722 | _dispatch("error", { 723 | file: uploadedFiles[data.id], 724 | message: data.message, 725 | code: 0 726 | }); 727 | if (communicators) communicators[data.id].abort = true; 728 | } 729 | }; 730 | 731 | // CONSTRUCTOR: Listen to the "complete", "ready", and "error" messages 732 | // on the socket. 733 | if (_isWrapDataWellConfigured() && self.wrapData) { 734 | var mapActionToCallback = { 735 | chunk: _chunckCallback, 736 | ready: _readyCallback, 737 | complete: _completCallback, 738 | error: _errorCallback 739 | }; 740 | 741 | _listenTo(socket, _getTopicName(), function (message) { 742 | if (typeof message !== "object") { 743 | console.log("SocketIOFileUploadClient Error: You choose to wrap your data so the message from the server need to be an object"); // eslint-disable-line no-console 744 | return; 745 | } 746 | var actionKey = self.wrapData.unwrapKey && typeof self.wrapData.unwrapKey.action === "string" ? self.wrapData.unwrapKey.action : "action"; 747 | var messageKey = self.wrapData.unwrapKey && typeof self.wrapData.unwrapKey.message === "string" ? self.wrapData.unwrapKey.message : "message"; 748 | 749 | var action = message[actionKey]; 750 | var data = message[messageKey]; 751 | if (!action || !data || !mapActionToCallback[action]) { 752 | console.log("SocketIOFileUploadClient Error: You choose to wrap your data but the message from the server is wrong configured. Check the message and your wrapData option"); // eslint-disable-line no-console 753 | return; 754 | } 755 | mapActionToCallback[action](data); 756 | }); 757 | } else { 758 | _listenTo(socket, _getTopicName("_chunk"), _chunckCallback); 759 | _listenTo(socket, _getTopicName("_ready"), _readyCallback); 760 | _listenTo(socket, _getTopicName("_complete"), _completCallback); 761 | _listenTo(socket, _getTopicName("_error"), _errorCallback); 762 | } 763 | 764 | if (this.exposePrivateFunction) { 765 | this.chunckCallback = _chunckCallback; 766 | this.readyCallback = _readyCallback; 767 | this.completCallback = _completCallback; 768 | this.errorCallback = _errorCallback; 769 | } 770 | }; 771 | })); 772 | -------------------------------------------------------------------------------- /client.min.js: -------------------------------------------------------------------------------- 1 | /* Socket IO File Upload Client-Side Library 2 | * Copyright (C) 2015 Shane Carr and others 3 | * Released under the X11 License 4 | * For more information, visit: https://github.com/sffc/socketio-file-upload 5 | */ 6 | var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.owns=function(c,f){return Object.prototype.hasOwnProperty.call(c,f)};$jscomp.assign="function"==typeof Object.assign?Object.assign:function(c,f){for(var b=1;bb.maxFileSize)v("error",{file:a,message:"Attempt by client to upload file exceeding the maximum file size", 13 | code:1});else if(v("start",{file:a})){var g=new FileReader,d=window.siofu_global.downloads++,f=!1,h=b.useText,l=0,u;g._realReader&&(g=g._realReader);e[d]=a;var w={id:d},y=b.chunkSize;if(y>=a.size||0>=y)y=a.size;var p=function(){if(!w.abort){var b=a.slice(l,Math.min(l+y,a.size));h?g.readAsText(b):g.readAsArrayBuffer(b)}},q=function(e){if(!w.abort){var F=Math.min(l+y,a.size);a:{var k=l;e=e.target.result;var q=!1;if(!h)try{var r=new Uint8Array(e);if(b.serializedOctets)e=r;else if(b.useBuffer)e=r.buffer; 14 | else{q=!0;var t,p=r.buffer.byteLength,n="";for(t=0;t>2],n+="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"[(r[t]&3)<<4|r[t+1]>>4],n+="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"[(r[t+1]&15)<<2|r[t+2]>>6],n+="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"[r[t+2]&63];2===p%3?n=n.substring(0,n.length-1)+"=":1===p%3&&(n=n.substring(0,n.length-2)+"==");e=n}}catch(K){c.emit(m("_done"), 15 | x({id:d,interrupt:!0},"done"));break a}c.emit(m("_progress"),x({id:d,size:a.size,start:k,end:F,content:e,base64:q},"progress"))}v("progress",{file:a,bytesLoaded:F,name:u});l+=y;l>=a.size&&(c.emit(m("_done"),x({id:d},"done")),v("load",{file:a,reader:g,name:u}),f=!0)}};k(g,"load",q);k(g,"error",function(){c.emit(m("_done"),x({id:d,interrupt:!0},"done"));B(g,"load",q)});k(g,"abort",function(){c.emit(m("_done"),x({id:d,interrupt:!0},"done"));B(g,"load",q)});c.emit(m("_start"),x({name:a.name,mtime:a.lastModified, 16 | meta:a.meta,size:a.size,encoding:h?"text":"octet",id:d},"start"));z[d]=function(a){u=a;p()};A[d]=function(){f||p()};return w}},w=function(a){if(0!==a.length){for(var b=0;b socket.io-adapter > socket.io-parser > debug: 8 | patched: '2018-10-25T01:57:32.880Z' 9 | 'npm:ms:20170412': 10 | - socket.io > socket.io-adapter > socket.io-parser > debug > ms: 11 | patched: '2018-10-25T01:57:32.880Z' 12 | SNYK-JS-LODASH-450202: 13 | - snyk > snyk-nodejs-lockfile-parser > lodash: 14 | patched: '2019-07-04T07:55:15.758Z' 15 | - snyk > lodash: 16 | patched: '2019-07-04T07:55:15.758Z' 17 | - snyk > snyk-nuget-plugin > lodash: 18 | patched: '2019-07-04T07:55:15.758Z' 19 | - snyk > @snyk/dep-graph > lodash: 20 | patched: '2019-07-04T07:55:15.758Z' 21 | - snyk > inquirer > lodash: 22 | patched: '2019-07-04T07:55:15.758Z' 23 | - snyk > snyk-config > lodash: 24 | patched: '2019-07-04T07:55:15.758Z' 25 | - snyk > snyk-mvn-plugin > lodash: 26 | patched: '2019-07-04T07:55:15.758Z' 27 | - snyk > snyk-go-plugin > graphlib > lodash: 28 | patched: '2019-07-04T07:55:15.758Z' 29 | - snyk > snyk-nodejs-lockfile-parser > graphlib > lodash: 30 | patched: '2019-07-04T07:55:15.758Z' 31 | - snyk > snyk-php-plugin > @snyk/composer-lockfile-parser > lodash: 32 | patched: '2019-07-04T07:55:15.758Z' 33 | - snyk > @snyk/dep-graph > graphlib > lodash: 34 | patched: '2019-07-04T07:55:15.758Z' 35 | -------------------------------------------------------------------------------- /demo/app.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | /* eslint-disable no-console */ 3 | 4 | var http = require("http"), 5 | url = require("url"), 6 | path = require("path"), 7 | mime = require("mime"), 8 | fs = require("fs"), 9 | SocketIOFileUploadServer = require("../server"), 10 | socketio = require("socket.io"), 11 | express = require("express"); 12 | 13 | var app, io; 14 | 15 | // Simple Static File Server. Used under the terms of the BSD license. 16 | // http://classes.engineering.wustl.edu/cse330/index.php/Node.JS 17 | app = http.createServer(function(req, resp){ 18 | var filename = path.join(__dirname, "public_html", url.parse(req.url).pathname); 19 | (fs.exists || path.exists)(filename, function(exists){ 20 | if (exists) { 21 | fs.readFile(filename, function(err, data){ 22 | if (err) { 23 | // File exists but is not readable (permissions issue?) 24 | resp.writeHead(500, { 25 | "Content-Type": "text/plain" 26 | }); 27 | resp.write("Internal server error: could not read file"); 28 | resp.end(); 29 | return; 30 | } 31 | 32 | // File exists and is readable 33 | var mimetype = mime.lookup(filename); 34 | resp.writeHead(200, { 35 | "Content-Type": mimetype 36 | }); 37 | resp.write(data); 38 | resp.end(); 39 | return; 40 | }); 41 | } 42 | }); 43 | }); 44 | //app.listen(3456); 45 | //io = socketio.listen(app); 46 | //SocketIOFileUploadServer.listen(app); 47 | 48 | app = express() 49 | .use(SocketIOFileUploadServer.router) 50 | .use(express.static(__dirname + "/out")) 51 | .use(express.static(__dirname + "/public_html")) 52 | .listen(4567); 53 | io = socketio.listen(app); 54 | console.log("Listening on port 4567"); 55 | 56 | io.sockets.on("connection", function(socket){ 57 | var siofuServer = new SocketIOFileUploadServer(); 58 | siofuServer.on("saved", function(event){ 59 | console.log(event.file); 60 | event.file.clientDetail.base = event.file.base; 61 | }); 62 | siofuServer.on("error", function(data){ 63 | console.log("Error: "+data.memo); 64 | console.log(data.error); 65 | }); 66 | siofuServer.on("start", function(event){ 67 | if (/\.exe$/.test(event.file.name)) { 68 | console.log("Aborting: " + event.file.id); 69 | siofuServer.abort(event.file.id, socket); 70 | } 71 | }); 72 | siofuServer.dir = "uploads"; 73 | siofuServer.maxFileSize = 2000; 74 | siofuServer.listen(socket); 75 | }); 76 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "version": "1.0.0", 4 | "description": "Small demo app using SIOFU.", 5 | "main": "app.js", 6 | "dependencies": { 7 | "express": "^4.14.0", 8 | "socket.io": "^3.0.0", 9 | "snyk": "^1.189.0" 10 | }, 11 | "scripts": { 12 | "snyk-protect": "snyk protect", 13 | "prepare": "npm run snyk-protect", 14 | "prepublish": "npm run snyk-protect" 15 | }, 16 | "snyk": true 17 | } 18 | -------------------------------------------------------------------------------- /demo/public_html/config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env amd, jquery */ 2 | /* eslint-disable no-console */ 3 | /* global requirejs */ 4 | 5 | requirejs.config({ 6 | paths: { 7 | "SocketIOFileUpload": "/siofu/client", 8 | "socket.io": "/socket.io/socket.io" 9 | } 10 | }); 11 | 12 | require(["socket.io", "SocketIOFileUpload"], function (io, SocketIOFileUpload) { 13 | // jQuery version 14 | function flash(message){ 15 | (function(message){ 16 | var flsh = $("
"); 17 | flsh.addClass("flash"); 18 | flsh.text(message); 19 | flsh.appendTo(document.body); 20 | setTimeout(function(){ 21 | flsh.slideUp(500, function(){ 22 | flsh.remove(); 23 | }); 24 | }, 2000); 25 | })(message); 26 | } 27 | 28 | // non-jQuery version 29 | // eslint-disable-next-line no-redeclare 30 | function flash(message){ 31 | (function(message){ 32 | var flsh = document.createElement("div"); 33 | flsh.setAttribute("class", "flash"); 34 | flsh.textContent = message; 35 | document.body.appendChild(flsh); 36 | setTimeout(function(){ 37 | document.body.removeChild(flsh); 38 | }, 2000); 39 | })(message); 40 | } 41 | 42 | var socket = io.connect(); 43 | var uploader = new SocketIOFileUpload(socket); 44 | uploader.addEventListener("complete", function(event){ 45 | console.log(event); 46 | flash("Upload Complete: "+event.file.name); 47 | }); 48 | uploader.addEventListener("choose", function(event){ 49 | flash("Files Chosen: "+event.files); 50 | }); 51 | uploader.addEventListener("start", function(event){ 52 | event.file.meta.hello = "World"; 53 | }); 54 | uploader.addEventListener("progress", function(event){ 55 | console.log(event); 56 | console.log("File is", event.bytesLoaded/event.file.size*100, "percent loaded"); 57 | }); 58 | uploader.addEventListener("load", function(event){ 59 | flash("File Loaded: "+event.file.name); 60 | console.log(event); 61 | }); 62 | uploader.addEventListener("error", function(event){ 63 | flash("Error: "+event.message); 64 | console.log(event.message); 65 | if (event.code === 1) { 66 | alert("Don't upload such a big file"); 67 | } 68 | }); 69 | uploader.maxFileSize = 20000; 70 | uploader.useBuffer = true; 71 | uploader.chunkSize = 1024; 72 | //uploader.useText = true; 73 | //uploader.serializedOctets = true; 74 | document.getElementById("ul_btn").addEventListener("click", function(){ 75 | uploader.prompt(); 76 | }, false); 77 | uploader.listenOnInput(document.getElementById("plain_input_element")); 78 | uploader.listenOnDrop(document.getElementById("file_drop")); 79 | 80 | window.uploader = uploader; 81 | }); -------------------------------------------------------------------------------- /demo/public_html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Upload Form 4 | 5 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
drop file here
29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "socketio-file-upload", 3 | "version": "0.7.3", 4 | "description": "Uploads files to a Node.JS server using Socket.IO", 5 | "keywords": [ 6 | "upload", 7 | "uploader", 8 | "socket", 9 | "socket.io" 10 | ], 11 | "dependencies": {}, 12 | "devDependencies": { 13 | "browserify": "^16.5.1", 14 | "buffer-equals": "^1.0.3", 15 | "chrome-location": "^1.2.1", 16 | "concat-stream": "^1.6.2", 17 | "ecstatic": "^4.1.4", 18 | "eslint": "^5.16.0", 19 | "google-closure-compiler": "^20181028.0.1", 20 | "phantom": "^6.3.0", 21 | "socket.io": "=2.4.0", 22 | "socket.io-client": "=2.1.1", 23 | "tape": "^4.13.3" 24 | }, 25 | "files": [ 26 | "client.js", 27 | "client.min.js", 28 | "server.js", 29 | "README.md" 30 | ], 31 | "main": "server.js", 32 | "browser": "client.js", 33 | "scripts": { 34 | "lint": "eslint .", 35 | "pretest": "browserify test/serve/browser-file-transfer.js -o test/serve/bundle.js", 36 | "test": "tape test/*.js", 37 | "minify:compile": "google-closure-compiler --js=client.js --js_output_file=client.min.js.tmp", 38 | "minify:license": "echo \"$(head -n5 client.min.js)\n$(cat client.min.js.tmp)\" > client.min.js.tmp", 39 | "minify:move": "mv client.min.js.tmp client.min.js", 40 | "minify": "npm run minify:compile && npm run minify:license && npm run minify:move" 41 | }, 42 | "repository": { 43 | "type": "git", 44 | "url": "git://github.com/sffc/socketio-file-upload.git" 45 | }, 46 | "homepage": "https://github.com/sffc/socketio-file-upload", 47 | "author": { 48 | "name": "Shane Carr", 49 | "email": "shane.carr@wustl.edu" 50 | }, 51 | "license": "X11", 52 | "readmeFilename": "Readme.md" 53 | } 54 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2013 Shane Carr 3 | * X11 License 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a 6 | * copy of this software and associated documentation files (the "Software"), 7 | * to deal in the Software without restriction, including without limitation 8 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, 9 | * and/or sell copies of the Software, and to permit persons to whom the 10 | * Software is furnished to do so, subject to the following conditions: 11 | * 12 | * The above copyright notice and this permission notice shall be included in 13 | * all copies or substantial portions of the Software. 14 | * 15 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 21 | * DEALINGS IN THE SOFTWARE. 22 | * 23 | * Except as contained in this notice, the names of the authors or copyright 24 | * holders shall not be used in advertising or otherwise to promote the sale, 25 | * use or other dealings in this Software without prior written authorization 26 | * from the authors or copyright holders. 27 | */ 28 | 29 | /* eslint-env node */ 30 | 31 | // Require Libraries 32 | var util = require("util"), 33 | EventEmitter = require("events").EventEmitter, 34 | path = require("path"), 35 | fs = require("fs"); 36 | 37 | 38 | function SocketIOFileUploadServer(options) { 39 | "use strict"; 40 | 41 | EventEmitter.call(this); 42 | var self = this; // avoids context issues 43 | 44 | var _getOption = function (key, defaultValue) { 45 | if(!options) { 46 | return defaultValue; 47 | } 48 | return options[key] || defaultValue; 49 | }; 50 | 51 | /** 52 | * Directory in which to save uploaded files. null = do not save files 53 | * @type {String} 54 | */ 55 | self.dir = _getOption("dir", null); 56 | 57 | /** 58 | * What mode (UNIX permissions) in which to save uploaded files 59 | * @type {Number} 60 | */ 61 | self.mode = _getOption("mode", "0666"); 62 | 63 | /** 64 | * Maximum file size, in bytes, when saving files. An "error" event will 65 | * be emitted when this size is exceeded, and the data will not be written 66 | * to the disk. null = allow any file size 67 | */ 68 | self.maxFileSize = _getOption("maxFileSize", null); 69 | 70 | /** 71 | * Whether or not to emit an error event if a progress chunk fails to 72 | * finish writing. The failure could be a harmless notification that the 73 | * file is larger than the internal buffer size, or it could mean that the 74 | * file upload triggered an ENOSPC error. 75 | */ 76 | self.emitChunkFail = _getOption("emitChunkFail", false); 77 | 78 | /** 79 | * Specify the topic to listen on. 80 | * Need to be the same that the one specified in the client. 81 | */ 82 | self.topicName = _getOption("topicName", "siofu"); 83 | 84 | /** 85 | * WrapData allow you to wrap the Siofu messages into a predefined format. 86 | * You can then easily use Siofu packages even in strongly typed topic. 87 | * wrapData can be a boolean or an object. It is false by default. 88 | * If wrapData is true it will allow you to send all the messages to only one topic by wrapping the siofu actions and messages. 89 | * 90 | * ex: 91 | { 92 | action: 'complete', 93 | message: { 94 | id: id, 95 | success: success, 96 | detail: fileInfo.clientDetail 97 | } 98 | } 99 | * 100 | * If wrapData is an object constituted of two mandatory key and one optional: 101 | * wrapKey and unwrapKey (mandatory): Corresponding to the key used to wrap the siofu data and message 102 | * additionalData (optional): Corresponding to the data to send along with file data 103 | * 104 | * ex: 105 | * if wrapData = { 106 | wrapKey: { 107 | action: 'actionType', 108 | message: 'data' 109 | }, 110 | unwrapKey: { 111 | action: 'actionType', 112 | message: 'message' 113 | }, 114 | additionalData: { 115 | acknowledgement: true 116 | } 117 | } 118 | * When Siofu will send for example a complete message this will send: 119 | * 120 | { 121 | acknowledgement: true, 122 | actionType: 'complete', 123 | data: { 124 | id: id, 125 | success: success, 126 | detail: fileInfo.clientDetail 127 | } 128 | } 129 | * and it's waiting from client data formatted like this: 130 | * 131 | { 132 | actionType: '...', 133 | message: {...} 134 | } 135 | * /!\ If wrapData is wrong configured is interpreted as false /!\ 136 | */ 137 | self.wrapData = _getOption("wrapData", false); 138 | 139 | var _isWrapDataWellConfigured = function () { 140 | if (typeof self.wrapData === "boolean") { 141 | return true; 142 | } 143 | if (typeof self.wrapData !== "object" || Array.isArray(self.wrapData)) { 144 | return false; 145 | } 146 | 147 | if(!self.wrapData.wrapKey || typeof self.wrapData.wrapKey.action !== "string" || typeof self.wrapData.wrapKey.message !== "string" || 148 | !self.wrapData.unwrapKey || typeof self.wrapData.unwrapKey.action !== "string" || typeof self.wrapData.unwrapKey.message !== "string") { 149 | return false; 150 | } 151 | 152 | return true; 153 | }; 154 | 155 | /** 156 | * Allow user to access to some private function to customize message reception. 157 | * This is used if you specified wrapData on the client side and have to manually bind message to callback. 158 | */ 159 | self.exposePrivateFunction = _getOption("exposePrivateFunction", false); 160 | 161 | /** 162 | * Default validator. 163 | * @param {Object} event Contains { file: fileInfo } 164 | * @param {function} callback Call it with true to start upload, false to abort 165 | */ 166 | self.uploadValidator = function(event, callback){ 167 | callback(true); 168 | }; 169 | 170 | var _getTopicName = function (topicExtension) { 171 | if (self.wrapData) { 172 | return self.topicName; 173 | } 174 | 175 | return self.topicName + topicExtension; 176 | }; 177 | 178 | var _wrapData = function (data, action) { 179 | if(!_isWrapDataWellConfigured() || !self.wrapData) { 180 | return data; 181 | } 182 | var dataWrapped = {}; 183 | if(self.wrapData.additionalData) { 184 | Object.assign(dataWrapped, self.wrapData.additionalData); 185 | } 186 | 187 | var actionKey = self.wrapData.wrapKey && typeof self.wrapData.wrapKey.action === "string" ? self.wrapData.wrapKey.action : "action"; 188 | var messageKey = self.wrapData.wrapKey && typeof self.wrapData.wrapKey.message === "string" ? self.wrapData.wrapKey.message : "message"; 189 | 190 | dataWrapped[actionKey] = action; 191 | dataWrapped[messageKey] = data; 192 | return dataWrapped; 193 | }; 194 | 195 | var files = []; 196 | 197 | /** 198 | * Private function to emit the "_complete" message on the socket. 199 | * @param {Number} id The file ID as passed on the siofu_upload. 200 | * @param {boolean} success 201 | * @return {void} 202 | */ 203 | var _emitComplete = function (socket, id, success) { 204 | var fileInfo = files[id]; 205 | 206 | // Check if the upload was aborted 207 | if (!fileInfo) { 208 | return; 209 | } 210 | 211 | socket.emit(_getTopicName("_complete"), _wrapData({ 212 | id: id, 213 | success: success, 214 | detail: fileInfo.clientDetail 215 | }, "complete")); 216 | }; 217 | 218 | /** 219 | * Private function to recursively find a file name by incrementing "inc" until 220 | * an empty file is found. 221 | * @param {String} ext File extension 222 | * @param {String} base File base name 223 | * @param {Date} mtime File modified time 224 | * @param {Number} inc Current number to suffix the base name. Pass -1 225 | * to not suffix a number to the base name. 226 | * @param {Function} next Callback function when the save is complete. 227 | * Will be passed a possible error as well as the 228 | * final base name. 229 | * @return {void} 230 | */ 231 | var _findFileNameWorker = function (ext, base, inc, next) { 232 | var newBase = (inc === -1) ? base : base + "-" + inc; 233 | var pathName = path.join(self.dir, newBase + ext); 234 | fs.exists(pathName, function (exists) { 235 | if (exists) { 236 | _findFileNameWorker(ext, base, inc + 1, next); 237 | } 238 | else { 239 | fs.open(pathName, "w", self.mode, function (err, fd) { 240 | if (err) { 241 | // Oops! Pass an error to the callback function. 242 | next(err); 243 | return; 244 | } 245 | // Pass the file handler and the new name to the callback. 246 | next(null, newBase, pathName, fd); 247 | }); 248 | } 249 | }); 250 | }; 251 | 252 | /** 253 | * Private function to save an uploaded file. 254 | * @param {Object} fileInfo Object containing file name, modified time, and 255 | * text content. 256 | * @return {void} 257 | */ 258 | var _findFileName = function (fileInfo, next) { 259 | // Strip dangerous characters from the file name 260 | var filesafeName = fileInfo.name 261 | .replace(/[\/\?<>\\:\*\|":]|[\x00-\x1f\x80-\x9f]|^\.+$/g, "_"); // eslint-disable-line no-control-regex, no-useless-escape 262 | 263 | var ext = path.extname(filesafeName); 264 | var base = path.basename(filesafeName, ext); 265 | 266 | // Use a recursive function to save the file under the first available filename. 267 | _findFileNameWorker(ext, base, -1, function (err, newBase, pathName, fd) { 268 | if (err) { 269 | next(err); 270 | return; 271 | } 272 | fs.close(fd, function (err) { 273 | if (err) { 274 | next(err); 275 | return; 276 | } 277 | next(null, newBase, pathName); 278 | }); 279 | }); 280 | }; 281 | 282 | var _uploadDone = function (socket) { 283 | return function (data) { 284 | var fileInfo = files[data.id]; 285 | 286 | // Check if the upload was aborted 287 | if (!fileInfo) { 288 | return; 289 | } 290 | 291 | try { 292 | if (fileInfo.writeStream) { 293 | // Update the file modified time. This doesn't seem to work; I'm not 294 | // sure if it's my error or a bug in Node. 295 | fs.utimes(fileInfo.pathName, new Date(), fileInfo.mtime, function (err) { 296 | // Check if the upload was aborted 297 | if (!files[data.id]) { 298 | return; 299 | } 300 | 301 | // I'm not sure what arguments the futimes callback is passed. 302 | // Based on node_file.cc, it looks like it is passed zero 303 | // arguments (version 0.10.6 line 140), but the docs say that 304 | // "the first argument is always reserved for an exception". 305 | if (err) { 306 | fileInfo.success = false; 307 | _emitComplete(socket, data.id, fileInfo.success); 308 | // TODO: We should probably propagate the error out to the user here. 309 | console.log("SocketIOFileUploadServer Error (_uploadDone fs.utimes):"); // eslint-disable-line no-console 310 | console.log(err); // eslint-disable-line no-console 311 | _cleanupFile(data.id); 312 | return; 313 | } 314 | 315 | // The order here matters: 316 | // _cleanupFile1 needs to be before server-side "saved" event such that the "saved" event can move the file (see #62) 317 | // The server-side "saved" event needs to be before _emitComplete so that clientDetail can be edited (see #82) 318 | // _emitComplete needs to happen before _cleanupFile2 so that the file info object is still valid 319 | _cleanupFile1(data.id); 320 | self.emit("saved", { 321 | file: fileInfo 322 | }); 323 | _emitComplete(socket, data.id, fileInfo.success); 324 | _cleanupFile2(data.id); 325 | }); 326 | } 327 | else { 328 | _emitComplete(socket, data.id, fileInfo.success); 329 | _cleanupFile(data.id); 330 | } 331 | } 332 | catch (err) { 333 | // TODO: We should probably propagate the error out to the user here. 334 | console.log("SocketIOFileUploadServer Error (_uploadDone):"); // eslint-disable-line no-console 335 | console.log(err); // eslint-disable-line no-console 336 | } 337 | 338 | // Emit the "complete" event to the server-side listeners 339 | self.emit("complete", { 340 | file: fileInfo, 341 | interrupt: !!data.interrupt 342 | }); 343 | }; 344 | }; 345 | 346 | var _uploadProgress = function (socket) { 347 | //jshint unused:false 348 | return function (data) { 349 | var fileInfo = files[data.id], buffer; 350 | 351 | // Check if the upload was aborted 352 | if (!fileInfo) { 353 | return; 354 | } 355 | 356 | try { 357 | if (data.base64) { 358 | buffer = new Buffer(data.content, "base64"); 359 | } 360 | else { 361 | buffer = new Buffer(data.content); 362 | } 363 | 364 | fileInfo.size = data.size; 365 | fileInfo.bytesLoaded += buffer.length; 366 | if (self.maxFileSize !== null 367 | && fileInfo.bytesLoaded > self.maxFileSize) { 368 | fileInfo.success = false; 369 | socket.emit(_getTopicName("_error"), _wrapData({ 370 | id: data.id, 371 | message: "Max allowed file size exceeded" 372 | }, "error")); 373 | self.emit("error", { 374 | file: fileInfo, 375 | error: new Error("Max allowed file size exceeded"), 376 | memo: "self-thrown from progress event" 377 | }); 378 | _cleanupFile(data.id); 379 | } 380 | else { 381 | if (fileInfo.writeStream) { 382 | if (!fileInfo.writeStream.write(buffer) && self.emitChunkFail) { 383 | self.emit("error", { 384 | file: fileInfo, 385 | error: new Error("Write of chunk failed (ENOSPC?)"), 386 | memo: "self-thrown from progress event" 387 | }); 388 | } 389 | } 390 | } 391 | // Emit that the chunk has been received, so client starts sending the next chunk 392 | socket.emit(_getTopicName("_chunk"), _wrapData({ id: data.id }, "chunk")); 393 | self.emit("progress", { 394 | file: fileInfo, 395 | buffer: buffer 396 | }); 397 | } 398 | catch (err) { 399 | // TODO: We should probably propagate the error out to the user here. 400 | console.log("SocketIOFileUploadServer Error (_uploadProgress):"); // eslint-disable-line no-console 401 | console.log(err); // eslint-disable-line no-console 402 | } 403 | }; 404 | }; 405 | 406 | /** 407 | * Private function to handle the start of a file upload. 408 | * @param {Socket} socket The socket on which the listener is bound 409 | * @return {Function} A function compatible with a Socket.IO callback 410 | */ 411 | var _uploadStart = function (socket) { 412 | return function (data) { 413 | 414 | // Save the file information 415 | var fileInfo = { 416 | name: data.name, 417 | mtime: new Date(data.mtime), 418 | encoding: data.encoding, 419 | clientDetail: {}, 420 | meta: data.meta || {}, 421 | id: data.id, 422 | size: data.size, 423 | bytesLoaded: 0, 424 | success: true 425 | }; 426 | files[data.id] = fileInfo; 427 | 428 | // Dispatch event to listeners on the server side 429 | self.emit("start", { 430 | file: fileInfo 431 | }); 432 | 433 | // Abort right now if the "start" event aborted the file upload. 434 | if (!files[data.id]) { 435 | return; 436 | } 437 | 438 | self.uploadValidator({ file: fileInfo }, function( isValid ){ 439 | if ( !isValid ) { 440 | self.abort( data.id, socket ); 441 | } else { 442 | // If we're not saving the file, we are ready to start receiving data now. 443 | if (!self.dir) { 444 | socket.emit(_getTopicName("_ready"), _wrapData({ 445 | id: data.id, 446 | name: null 447 | }, "ready")); 448 | } else { 449 | _serverReady(socket, data, fileInfo); 450 | } 451 | } 452 | }); 453 | }; 454 | }; 455 | 456 | // The indentation got messed up here, but changing it would make git history less useful. 457 | /* eslint-disable indent */ 458 | var _serverReady = function(socket, data, fileInfo){ 459 | // Find a filename and get the handler. Then tell the client that 460 | // we're ready to start receiving data. 461 | _findFileName(fileInfo, function (err, newBase, pathName) { 462 | // Check if the upload was aborted 463 | if (!files[data.id]) { 464 | return; 465 | } 466 | 467 | if (err) { 468 | _emitComplete(socket, data.id, false); 469 | self.emit("error", { 470 | file: fileInfo, 471 | error: err, 472 | memo: "computing file name" 473 | }); 474 | _cleanupFile(data.id); 475 | return; 476 | } 477 | 478 | files[data.id].base = newBase; 479 | files[data.id].pathName = pathName; 480 | 481 | // Create a write stream. 482 | try { 483 | var writeStream = fs.createWriteStream(pathName, { 484 | mode: self.mode 485 | }); 486 | writeStream.on("open", function () { 487 | // Check if the upload was aborted 488 | if (!files[data.id]) { 489 | return; 490 | } 491 | 492 | socket.emit(_getTopicName("_ready"), _wrapData({ 493 | id: data.id, 494 | name: newBase 495 | }, "ready")); 496 | }); 497 | writeStream.on("error", function (err) { 498 | // Check if the upload was aborted 499 | if (!files[data.id]) { 500 | return; 501 | } 502 | 503 | _emitComplete(socket, data.id, false); 504 | self.emit("error", { 505 | file: fileInfo, 506 | error: err, 507 | memo: "from within write stream" 508 | }); 509 | _cleanupFile(data.id); 510 | }); 511 | files[data.id].writeStream = writeStream; 512 | } 513 | catch (err) { 514 | _emitComplete(socket, data.id, false); 515 | self.emit("error", { 516 | file: fileInfo, 517 | error: err, 518 | memo: "creating write stream" 519 | }); 520 | _cleanupFile(data.id); 521 | return; 522 | } 523 | }); 524 | }; 525 | /* eslint-enable indent */ 526 | 527 | var _cleanupFile = function (id) { 528 | var fileInfo = files[id]; 529 | if (fileInfo.writeStream) { 530 | fileInfo.writeStream.end(); 531 | } 532 | delete files[id]; 533 | }; 534 | 535 | // _cleanupFile1() followed by _cleanupFile2() is equivalent to _cleanupFile() 536 | var _cleanupFile1 = function (id) { 537 | var fileInfo = files[id]; 538 | if (fileInfo.writeStream) { 539 | fileInfo.writeStream.end(); 540 | } 541 | }; 542 | var _cleanupFile2 = function (id) { 543 | delete files[id]; 544 | }; 545 | 546 | /** 547 | * Private function to handle a client disconnect event. 548 | * @param {Socket} socket The socket on which the listener is bound 549 | * @return {Function} A function compatible with a Socket.IO callback 550 | */ 551 | var _onDisconnect = function (socket) { // eslint-disable-line no-unused-vars 552 | return function () { 553 | for (var id in files) { 554 | if (files.hasOwnProperty(id)) { 555 | var fileInfo = files[id]; 556 | self.emit("error", { 557 | file: fileInfo, 558 | error: new Error("Client disconnected in the middle of an upload"), 559 | memo: "disconnect during upload" 560 | }); 561 | _cleanupFile(id); 562 | return; 563 | } 564 | } 565 | }; 566 | }; 567 | 568 | /** 569 | * Public method. Listen to a Socket.IO socket for a file upload event 570 | * emitted from the client-side library. 571 | * 572 | * @param {Socket} socket The socket on which to listen 573 | * @return {void} 574 | */ 575 | this.listen = function (socket) { 576 | if(_isWrapDataWellConfigured() && self.wrapData) { 577 | var actionToMethods = { 578 | start: _uploadStart(socket), 579 | progress: _uploadProgress(socket), 580 | done: _uploadDone(socket) 581 | }; 582 | socket.on(self.topicName, function(message) { 583 | if (typeof message !== "object") { 584 | console.log("SocketIOFileUploadServer Error: You choose to wrap your data so the message from the client need to be an object"); // eslint-disable-line no-console 585 | return; 586 | } 587 | 588 | 589 | var actionKey = self.wrapData.unwrapKey && typeof self.wrapData.unwrapKey.action === "string" ? self.wrapData.unwrapKey.action : "action"; 590 | var messageKey = self.wrapData.unwrapKey && typeof self.wrapData.unwrapKey.message === "string" ? self.wrapData.unwrapKey.message : "message"; 591 | 592 | var action = message[actionKey]; 593 | var data = message[messageKey]; 594 | if(!action || !data || !actionToMethods[action]) { 595 | console.log("SocketIOFileUploadServer Error: You choose to wrap your data but the message from the client is wrong configured. Please check the message and your wrapData option"); // eslint-disable-line no-console 596 | return; 597 | } 598 | actionToMethods[action](data); 599 | }); 600 | } else { 601 | socket.on(self.topicName + "_start", _uploadStart(socket)); 602 | socket.on(self.topicName + "_progress", _uploadProgress(socket)); 603 | socket.on(self.topicName + "_done", _uploadDone(socket)); 604 | } 605 | 606 | socket.on("disconnect", _onDisconnect(socket)); 607 | }; 608 | 609 | /** 610 | * Public method. Abort an upload that may be in progress. Throws an 611 | * exception if the specified file upload is not in progress. 612 | * 613 | * @param {String} id The ID of the file upload to abort. 614 | * @param {Socket} socket The socket that this instance is connected to. 615 | * @return {void} 616 | */ 617 | this.abort = function (id, socket) { 618 | if (!socket) { 619 | throw new Error("Please pass the socket instance as the second argument to abort()"); 620 | } 621 | 622 | var fileInfo = files[id]; 623 | if (!fileInfo) { 624 | throw new Error("File with specified ID does not exist: " + id); 625 | } 626 | 627 | fileInfo.success = false; 628 | socket.emit(_getTopicName("_error"), _wrapData({ 629 | id: id, 630 | message: "File upload aborted by server" 631 | }, "error")); 632 | _cleanupFile(id); 633 | }; 634 | 635 | if (this.exposePrivateFunction) { 636 | this.uploadStart = function (socket, data) { 637 | return _uploadStart(socket)(data); 638 | }; 639 | this.uploadProgress = function (socket, data) { 640 | return _uploadProgress(socket)(data); 641 | }; 642 | this.uploadDone = function (socket, data) { 643 | return _uploadDone(socket)(data); 644 | }; 645 | } 646 | } 647 | util.inherits(SocketIOFileUploadServer, EventEmitter); 648 | 649 | /** 650 | * Path at which to serve the client JavaScript file. 651 | * @type {String} 652 | */ 653 | SocketIOFileUploadServer.clientPath = "/siofu/client.js"; 654 | 655 | /** 656 | * Private function to serve the static client file. 657 | * @param {ServerResponse} res The server response 658 | * @return {void} 659 | */ 660 | var _serve = function (res) { 661 | "use strict"; 662 | 663 | fs.readFile(__dirname + "/client.min.js", function (err, data) { 664 | if (err) throw err; 665 | res.writeHead(200, { 666 | "Content-Type": "text/javascript" 667 | }); 668 | res.write(data); 669 | res.end(); 670 | }); 671 | }; 672 | 673 | /** 674 | * Transmit the static client file on a vanilla HTTP server. 675 | * @param {HTTPServer} app Your HTTP server 676 | * @return {void} 677 | */ 678 | SocketIOFileUploadServer.listen = function (app) { 679 | "use strict"; 680 | 681 | app.on("request", function (req, res) { 682 | if (req.url === SocketIOFileUploadServer.clientPath) { 683 | _serve(res); 684 | } 685 | }); 686 | }; 687 | 688 | /** 689 | * Router to serve the static client file on the Connect middleware, including 690 | * the Express.JS web framework. Pass this function to your application like 691 | * this: 692 | * 693 | * app.use(SocketIOFileUploadServer.router) 694 | * 695 | * You should not need to ever call this function. 696 | */ 697 | SocketIOFileUploadServer.router = function (req, res, next) { 698 | "use strict"; 699 | 700 | if (req.url === SocketIOFileUploadServer.clientPath) { 701 | _serve(res); 702 | } 703 | else { 704 | next(); 705 | } 706 | }; 707 | 708 | // Export the object. 709 | module.exports = SocketIOFileUploadServer; 710 | -------------------------------------------------------------------------------- /test/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | node: true 3 | parserOptions: 4 | ecmaVersion: 2017 5 | -------------------------------------------------------------------------------- /test/assets/mandrill.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sffc/socketio-file-upload/9e9bb95210c9e370e62db9c54a9f9cc973118ac7/test/assets/mandrill.png -------------------------------------------------------------------------------- /test/assets/sonnet18.txt: -------------------------------------------------------------------------------- 1 | Shall I compare thee to a summer’s day? 2 | Thou art more lovely and more temperate: 3 | Rough winds do shake the darling buds of May, 4 | And summer’s lease hath all too short a date; 5 | Sometime too hot the eye of heaven shines, 6 | And often is his gold complexion dimm'd; 7 | And every fair from fair sometime declines, 8 | By chance or nature’s changing course untrimm'd; 9 | But thy eternal summer shall not fade, 10 | Nor lose possession of that fair thou ow’st; 11 | Nor shall death brag thou wander’st in his shade, 12 | When in eternal lines to time thou grow’st: 13 | So long as men can breathe or eyes can see, 14 | So long lives this, and this gives life to thee. 15 | -------------------------------------------------------------------------------- /test/browser-phantom.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // Automation for the browser side of the test using PhantomJS. 3 | 4 | /* eslint-disable no-console */ 5 | 6 | "use strict"; 7 | 8 | const phantom = require("phantom"); 9 | const path = require("path"); 10 | const sleep = require("util").promisify(setTimeout); 11 | 12 | async function run(port) { 13 | // Return value: first error on the client 14 | let clientError = null; 15 | 16 | // Page will alert when done or failure 17 | let resolveDonePromise; 18 | let donePromise = new Promise((resolve) => { 19 | resolveDonePromise = resolve; 20 | }); 21 | 22 | const instance = await phantom.create(); 23 | const page = await instance.createPage(); 24 | await page.on("onResourceRequested", (requestData) => { 25 | console.info("requesting:", requestData.url); 26 | }); 27 | await page.on("onConsoleMessage", (message) => { 28 | console.log("browser:", message); 29 | }); 30 | await page.on("onError", (message, trace) => { 31 | if (!clientError) { 32 | let traceString = ""; 33 | for (let i=0; i { 40 | console.info("alert:", message); 41 | if (message.substr(0, 5) !== "done:") { 42 | clientError = clientError || message; 43 | } 44 | resolveDonePromise(); 45 | }); 46 | 47 | const status = await page.open("http://127.0.0.1:" + port); 48 | if (status !== "success") { 49 | await instance.exit(); 50 | return new Error("Not able to load test page: " + status); 51 | } 52 | console.info("phantom-runner: Uploading files to #file-picker"); 53 | await page.uploadFile("#file-picker", [ 54 | path.join(__dirname, "assets", "mandrill.png"), 55 | path.join(__dirname, "assets", "sonnet18.txt") 56 | ]); 57 | 58 | console.info("phantom-runner: Waiting 3 seconds before testing wrap data"); 59 | await sleep(3000); 60 | console.info("phantom-runner: Uploading files to #file-picker-wrap-data"); 61 | await page.uploadFile("#file-picker-wrap-data", [ 62 | path.join(__dirname, "assets", "mandrill.png"), 63 | path.join(__dirname, "assets", "sonnet18.txt") 64 | ]); 65 | 66 | await donePromise; 67 | await instance.exit(); 68 | return clientError; 69 | } 70 | 71 | module.exports = function(port, callback) { 72 | run(port).then(callback); 73 | }; 74 | 75 | // Standalone endpoint for testing 76 | async function main() { 77 | if (!process.argv[2]) { 78 | console.error("Error: Pass port number as first argument"); 79 | process.exit(1); 80 | } 81 | console.log("Attaching on port:", process.argv[2]); 82 | const clientError = await run(process.argv[2]); 83 | if (clientError) { 84 | console.error("Error from client:"); 85 | console.error(clientError); 86 | process.exit(1); 87 | } else { 88 | console.info("Client closed successfully"); 89 | process.exit(0); 90 | } 91 | } 92 | if (require.main === module) { 93 | main(); 94 | } 95 | -------------------------------------------------------------------------------- /test/serve/browser-file-transfer.js: -------------------------------------------------------------------------------- 1 | /* eslint linebreak-style: ["error", "windows"] */ 2 | /* eslint-disable no-console */ 3 | /* eslint-env node */ 4 | 5 | var test = require("tape"); 6 | var SocketIoClient = require("socket.io-client"); 7 | var SiofuClient = require("../../client.js"); 8 | 9 | function evtos(ev) { 10 | return ev.file ? "[ev file=" + ev.file.name + "]" : "[ev]"; 11 | } 12 | 13 | var socket = new SocketIoClient(); 14 | 15 | function runTest(t, isWrapData) { 16 | var client; 17 | if (isWrapData) { 18 | client = new SiofuClient(socket, { 19 | topicName: "siofu_only_topic", 20 | wrapData: { 21 | wrapKey: { 22 | action: "action", 23 | message: "data", 24 | }, 25 | unwrapKey: { 26 | action: "action", 27 | message: "message", 28 | }, 29 | } 30 | }); 31 | } else { 32 | client = new SiofuClient(socket); 33 | } 34 | 35 | var numSubmitted = 0; 36 | var startFired = 0; 37 | var loadFired = 0; 38 | var progressFired = 0; 39 | var completeFired = 0; 40 | 41 | t.equal(typeof client.listenOnInput, "function", "instance.listenOnInput is a function"); 42 | t.equal(typeof client.listenOnDrop, "function", "instance.listenOnDrop is a function"); 43 | t.equal(typeof client.listenOnSubmit, "function", "instance.listenOnSubmit is a function"); 44 | t.equal(typeof client.listenOnArraySubmit, "function", "instance.listenOnArraySubmit is a function"); 45 | t.equal(typeof client.prompt, "function", "instance.prompt is a function"); 46 | t.equal(typeof client.submitFiles, "function", "instance.submitFiles is a function"); 47 | t.equal(typeof client.destroy, "function", "instance.destroy is a function"); 48 | 49 | t.notOk(client.maxFileSize, "instance.maxFileSize defaults to null"); 50 | t.equal(client.chunkSize, 102400, "instance.chunkSize defaults to 100 KiB"); 51 | t.notOk(client.useText, "instance.useText defaults to false"); 52 | t.ok(client.useBuffer, "instance.useBuffer defaults to true"); 53 | t.notOk(client.serializeOctets, "instance.serializeOctets defaults to false"); 54 | t.notOk(client.exposePrivateFunction, "instance.exposePrivateFunction defaults to false"); 55 | 56 | if (isWrapData) { 57 | t.equal(client.topicName, "siofu_only_topic", "instance.topicName correctly set to siofu_only_topic"); 58 | t.deepLooseEqual(client.wrapData, { 59 | wrapKey: { 60 | action: "action", 61 | message: "data" 62 | }, 63 | unwrapKey: { 64 | action: "action", 65 | message: "message" 66 | } 67 | }, "instance.wrapData correctly formatted"); 68 | } else { 69 | t.equal(client.topicName, "siofu", "instance.topicName defaults to siofu"); 70 | t.notOk(client.wrapData, "instance.wrapData defaults to null"); 71 | } 72 | 73 | if (window._phantom) { 74 | console.log("PHANTOMJS DETECTED: Disabling useBuffer now."); 75 | // Seems to be a bug in PhantomJS 76 | client.useBuffer = false; 77 | } 78 | 79 | t.pass(""); 80 | t.pass("SELECT FILES TO UPLOAD"); 81 | 82 | if (isWrapData) { 83 | client.listenOnInput(document.getElementById("file-picker-wrap-data")); 84 | } else { 85 | client.listenOnInput(document.getElementById("file-picker")); 86 | } 87 | 88 | client.addEventListener("choose", function (ev) { 89 | numSubmitted = ev.files.length; 90 | t.ok(numSubmitted, "user just submitted " + numSubmitted + " files " + evtos(ev)); 91 | socket.emit(isWrapData ? "numSubmittedWrap" : "numSubmitted", numSubmitted); 92 | 93 | t.notOk(startFired, "'start' event must not have been fired yet " + evtos(ev)); 94 | t.notOk(loadFired, "'load' event must not have been fired yet " + evtos(ev)); 95 | t.notOk(progressFired, "'progress' event must not have been fired yet " + evtos(ev)); 96 | t.notOk(completeFired, "'complete' event must not have been fired yet " + evtos(ev)); 97 | }); 98 | 99 | client.addEventListener("start", function (ev) { 100 | t.ok(!!ev.file, "file not in start event object " + evtos(ev)); 101 | t.ok(++startFired <= numSubmitted, "'start' event has not fired too many times " + evtos(ev)); 102 | // Client-to-Server Metadata 103 | ev.file.meta.bar = "from-client"; 104 | }); 105 | 106 | client.addEventListener("load", function (ev) { 107 | t.ok(!!ev.file, "file not in load event object " + evtos(ev)); 108 | t.ok(++loadFired <= numSubmitted, "'load' event has not fired too many times " + evtos(ev)); 109 | }); 110 | 111 | client.addEventListener("progress", function (ev) { 112 | t.ok(ev.bytesLoaded <= ev.file.size, "'progress' size calculation " + evtos(ev)); 113 | }); 114 | 115 | client.addEventListener("complete", function (ev) { 116 | t.ok(++completeFired <= numSubmitted, "'complete' event has not fired too many times " + evtos(ev)); 117 | 118 | t.ok(ev.detail, "'complete' event has a 'detail' property " + evtos(ev)); 119 | t.ok(ev.success, "'complete' event was successful " + evtos(ev)); 120 | 121 | // Server-to-Client Metadata 122 | t.equal(ev.detail.foo, "from-server", "server-to-client metadata correct " + evtos(ev)); 123 | 124 | if (completeFired >= numSubmitted) { 125 | 126 | t.equal(startFired, numSubmitted, "'start' event fired the right number of times " + evtos(ev)); 127 | t.equal(loadFired, numSubmitted, "'load' event fired the right number of times " + evtos(ev)); 128 | t.equal(completeFired, numSubmitted, "'complete' event fired the right number of times " + evtos(ev)); 129 | 130 | client.destroy(); 131 | t.end(); 132 | } 133 | }); 134 | 135 | client.addEventListener("error", function (ev) { 136 | t.fail("Error: " + ev.file + " - " + ev.message); 137 | client.destroy(); 138 | t.end(); 139 | }); 140 | } 141 | 142 | test("basic functionality", function (t) { 143 | runTest(t, false); 144 | }); 145 | 146 | test("Test wrap data and send to a single topic", function (t) { 147 | runTest(t, true); 148 | }); 149 | 150 | test.onFailure(function() { 151 | alert("Test failed; see log for details"); 152 | }); 153 | 154 | test.onFinish(function() { 155 | socket.disconnect(); 156 | alert("done: Test complete; you may close your window"); 157 | }); 158 | -------------------------------------------------------------------------------- /test/serve/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Testing! 6 | 7 | 8 |

9 | Please select both mandrill.png and sonnet18.txt from the test/assets directory… 10 |

11 |

12 | Open the dev tools to view the test status. 13 |

14 |
15 |


16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /test/setup-server.js: -------------------------------------------------------------------------------- 1 | /* eslint linebreak-style: ["error", "windows"] */ 2 | /* eslint-disable no-console */ 3 | /* eslint-env node */ 4 | 5 | const SocketIo = require("socket.io"); 6 | const SiofuServer = require("../server.js"); 7 | 8 | module.exports = { 9 | setupSocketIo(httpServer) { 10 | return new Promise((resolve) => { 11 | const io = new SocketIo(httpServer); 12 | 13 | io.on("connection", (socket) => { 14 | resolve(socket); 15 | }); 16 | }); 17 | }, 18 | getUploader(siofuOptions, socket) { 19 | const uploader = new SiofuServer(siofuOptions); 20 | 21 | uploader.listen(socket); 22 | 23 | uploader.uploadValidator = (event, next) => { 24 | console.log("Passing upload validator for " + event.file.name); 25 | next(true); 26 | }; 27 | 28 | return uploader; 29 | }, 30 | listen(server) { 31 | return new Promise((resolve) => { 32 | // Try the first time 33 | let port = Math.floor(Math.random() * 63535 + 2000); 34 | console.log("Attempting connection on port", port); 35 | server.listen(port, "127.0.0.1", () => { 36 | resolve(port); 37 | }); 38 | 39 | server.on("error", (err) => { 40 | // Try again 41 | port = Math.floor(Math.random() * 63535 + 2000); 42 | console.log("Attempt failed. Attempting connection on port", port); 43 | console.log("Error was:", err); 44 | server.listen(port, "127.0.0.1", () => { 45 | resolve(port); 46 | }); 47 | }); 48 | }); 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /test/test-serves-client-js.js: -------------------------------------------------------------------------------- 1 | /* eslint linebreak-style: ["error", "windows"] */ 2 | /* eslint-disable no-console */ 3 | /* eslint-env node */ 4 | 5 | const test = require("tape"); 6 | const SocketIOFileUpload = require("../server.js"); 7 | const http = require("http"); 8 | const fs = require("fs"); 9 | const path = require("path"); 10 | const concatStream = require("concat-stream"); 11 | const setup = require("./setup-server"); 12 | 13 | function serveClientCb(t, server, port) { 14 | http.get({ 15 | host: "127.0.0.1", 16 | port: port, 17 | path: "/siofu/client.js" 18 | }, (res) => { 19 | const clientJsPath = path.join(__dirname, "../client.min.js"); 20 | const clientJsStr = fs.readFileSync(clientJsPath, { encoding: "utf8" }); 21 | res.pipe(concatStream({ encoding: "string" }, (resString) => { 22 | t.equal(clientJsStr, resString, "client.min.js is being served"); 23 | server.close( (err) => { 24 | t.notOk(err, "no error"); 25 | t.end(); 26 | }); 27 | })); 28 | res.on("error", (err) => { 29 | t.fail("error: " + err.message); 30 | }); 31 | }); 32 | } 33 | 34 | test("Can be constructed using SIOFU.listen()", (t) => { 35 | const server = http.createServer(); 36 | SocketIOFileUpload.listen(server); 37 | 38 | setup.listen(server).then((port) => { 39 | serveClientCb(t, server, port); 40 | }); 41 | }); 42 | 43 | test("Can be constructed using SIOFU.router", (t) => { 44 | const server = http.createServer(SocketIOFileUpload.router); 45 | 46 | setup.listen(server).then((port) => { 47 | serveClientCb(t, server, port); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /test/test-transfer.js: -------------------------------------------------------------------------------- 1 | /* eslint linebreak-style: ["error", "windows"] */ 2 | /* eslint-disable no-console */ 3 | /* eslint-env node */ 4 | 5 | const test = require("tape"); 6 | const setup = require("./setup-server.js"); 7 | const chrome = require("chrome-location"); 8 | const cp = require("child_process"); 9 | const http = require("http"); 10 | const fs = require("fs"); 11 | const ecstatic = require("ecstatic"); 12 | const bufferEquals = require("buffer-equals"); 13 | const path = require("path"); 14 | const phantomRunner = require("./browser-phantom"); 15 | 16 | function evtos(ev) { 17 | return ev.file ? "[ev file=" + ev.file.name + "]" : "[ev]"; 18 | } 19 | 20 | const mandrillContent = fs.readFileSync(path.join(__dirname, "assets", "mandrill.png")); 21 | const sonnet18Content = fs.readFileSync(path.join(__dirname, "assets", "sonnet18.txt")); 22 | 23 | function _testUploader(t, uploader, callbackFileSavedAndUnlink) { 24 | let startFired = 0; 25 | let completeFired = 0; 26 | let savedFired = 0; 27 | 28 | t.ok(uploader, "uploader is not null/undefined"); 29 | t.equal(typeof uploader, "object", "uploader is an object"); 30 | 31 | uploader.on("start", (ev) => { 32 | t.ok(!!ev.file, "file not in start event object " + evtos(ev)); 33 | startFired++; 34 | }); 35 | 36 | let progressContent = {}; 37 | uploader.on("progress", (ev) => { 38 | if (!progressContent[ev.file.id]) progressContent[ev.file.id] = new Buffer([]); 39 | progressContent[ev.file.id] = Buffer.concat([progressContent[ev.file.id], ev.buffer]); 40 | t.ok(ev.buffer.length <= ev.file.size, "'progress' event " + evtos(ev)); 41 | }); 42 | 43 | uploader.on("complete", (ev) => { 44 | t.ok(++completeFired <= startFired, "'complete' event has not fired too many times " + evtos(ev)); 45 | 46 | t.ok(ev.file.success, "Successful upload " + evtos(ev)); 47 | }); 48 | 49 | uploader.on("saved", (ev) => { 50 | t.ok(++savedFired <= startFired, "'saved' event has not fired too many times " + evtos(ev)); 51 | t.ok(ev.file.success, "Successful save " + evtos(ev)); 52 | 53 | // Client-to-Server Metadata 54 | t.equal(ev.file.meta.bar, "from-client", "client-to-server metadata correct " + evtos(ev)); 55 | // Server-to-Client Metadata 56 | ev.file.clientDetail.foo = "from-server"; 57 | 58 | // Check for file equality 59 | fs.readFile(ev.file.pathName, (err, content) => { 60 | t.error(err, "reading saved file " + evtos(ev)); 61 | 62 | if (!bufferEquals(progressContent[ev.file.id], content)) { 63 | t.fail("Saved file content is not the same as progress buffer " + evtos(ev)); 64 | } else { 65 | t.pass("Saved file content is same as progress buffer " + evtos(ev)); 66 | } 67 | 68 | let fileContent = ev.file.name === "mandrill.png" ? mandrillContent : sonnet18Content; 69 | if (!bufferEquals(fileContent, content)) { 70 | t.fail("Saved file content is not the same as original file buffer " + evtos(ev)); 71 | } else { 72 | t.pass("Saved file content is same as original file buffer " + evtos(ev)); 73 | } 74 | 75 | // Clean up 76 | fs.unlink(ev.file.pathName, () => { 77 | callbackFileSavedAndUnlink(startFired, completeFired, savedFired, ev); 78 | }); 79 | }); 80 | }); 81 | 82 | uploader.on("error", (ev) => { 83 | t.fail("Error: " + ev.error + " " + evtos(ev)); 84 | }); 85 | 86 | } 87 | 88 | test("test setup function", (t) => { 89 | const requestHandler = ecstatic({ 90 | root: __dirname + "/serve", 91 | cache: 0 92 | }); 93 | 94 | const server = http.createServer(requestHandler); 95 | 96 | setup.listen(server).then(async (port) => { 97 | 98 | if (process.env["X_USE_PHANTOM"]) { 99 | // Headless test 100 | phantomRunner(port, (err) => { 101 | if (err) { 102 | t.fail("Error: " + err); 103 | } 104 | 105 | // No more tests 106 | server.close(); 107 | t.end(); 108 | }); 109 | } else { 110 | // Manual test 111 | const child = cp.spawn(chrome, [ "http://127.0.0.1:" + port ]); 112 | child.on("close", () => { 113 | // No more tests 114 | server.close(); 115 | t.end(); 116 | }); 117 | } 118 | 119 | const socket = await setup.setupSocketIo(server); 120 | let numSubmitted = -1; 121 | let numSubmittedWrap = -1; 122 | 123 | socket.once("numSubmitted", (_numSubmitted) => { 124 | numSubmitted = _numSubmitted; 125 | t.ok(numSubmitted, "user submitted " + numSubmitted + " files"); 126 | }); 127 | 128 | socket.once("numSubmittedWrap", (_numSubmittedWrap) => { 129 | numSubmittedWrap = _numSubmittedWrap; 130 | t.ok(numSubmittedWrap, "user submitted " + numSubmittedWrap + " files with data wrapped"); 131 | }); 132 | 133 | const uploaderOptions = { 134 | dir: "/tmp", 135 | }; 136 | const uploaderWrapDataOptions = { 137 | dir: "/tmp", 138 | topicName: "siofu_only_topic", 139 | wrapData: { 140 | wrapKey: { 141 | action: "action", 142 | message: "message" 143 | }, 144 | unwrapKey: { 145 | action: "action", 146 | message: "data" 147 | } 148 | } 149 | }; 150 | 151 | const uploader = setup.getUploader(uploaderOptions, socket); 152 | const uploaderWrapData = setup.getUploader(uploaderWrapDataOptions, socket); 153 | 154 | _testUploader(t, uploader, (startFired, completeFired, savedFired, ev) => { 155 | if (numSubmitted > 0 && savedFired >= numSubmitted) { 156 | t.equal(completeFired, startFired, "wrapData=false: 'complete' event fired the right number of times " + evtos(ev)); 157 | t.equal(savedFired, startFired, "wrapData=false: 'saved' event fired the right number of times " + evtos(ev)); 158 | } 159 | }); 160 | 161 | _testUploader(t, uploaderWrapData, (startFired, completeFired, savedFired, ev) => { 162 | if (numSubmitted > 0 && savedFired >= numSubmitted) { 163 | t.equal(completeFired, startFired, "wrapData=true: 'complete' event fired the right number of times " + evtos(ev)); 164 | t.equal(savedFired, startFired, "wrapData=true: 'saved' event fired the right number of times " + evtos(ev)); 165 | } 166 | }); 167 | }); 168 | }); 169 | --------------------------------------------------------------------------------