├── queue.json ├── www ├── .DS_Store ├── logo.png ├── favicon.ico ├── favicon.png ├── fontello.eot ├── fontello.ttf ├── fontello.woff ├── LICENSE.txt ├── index.html ├── fontello.svg ├── logo.svg ├── style.css └── script.js ├── screenshot.png ├── script.sh ├── FIXME.txt ├── CHANGELOG.txt ├── ScriptRunner.js ├── README.md ├── NodeDownloader.js └── server.js /queue.json: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /www/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxstrauch/bert/HEAD/www/.DS_Store -------------------------------------------------------------------------------- /www/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxstrauch/bert/HEAD/www/logo.png -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxstrauch/bert/HEAD/screenshot.png -------------------------------------------------------------------------------- /www/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxstrauch/bert/HEAD/www/favicon.ico -------------------------------------------------------------------------------- /www/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxstrauch/bert/HEAD/www/favicon.png -------------------------------------------------------------------------------- /www/fontello.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxstrauch/bert/HEAD/www/fontello.eot -------------------------------------------------------------------------------- /www/fontello.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxstrauch/bert/HEAD/www/fontello.ttf -------------------------------------------------------------------------------- /www/fontello.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxstrauch/bert/HEAD/www/fontello.woff -------------------------------------------------------------------------------- /script.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | for i in `seq 1 3`; 4 | do 5 | echo "Hello World No. $i" 6 | sleep 1 7 | done 8 | 9 | echo "Put your script here" -------------------------------------------------------------------------------- /FIXME.txt: -------------------------------------------------------------------------------- 1 | - Given 'http://creativecommons.org/licenses/by-sa/4.0/' Bert 0.42.1 2 | downloads the HTML file but displays the error message: 3 | 4 | Error: ENOENT, no such file or directory '' 5 | - Enhance error messages from `wget` (e.g. nicer) -------------------------------------------------------------------------------- /www/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Font license info 2 | 3 | 4 | ## MFG Labs 5 | 6 | Copyright (C) 2012 by Daniel Bruce 7 | 8 | Author: MFG Labs 9 | License: SIL (http://scripts.sil.org/OFL) 10 | Homepage: http://www.mfglabs.com/ 11 | 12 | 13 | -------------------------------------------------------------------------------- /CHANGELOG.txt: -------------------------------------------------------------------------------- 1 | Bert 0.42.1, 2016-01-04 2 | --------------------- 3 | - Added automatic download folder creation if not present 4 | - Tweaked client script to return false on link click and 5 | therefore don't show the hash character in the URL 6 | - Added this `CHANGELOG.txt` file to project 7 | - Added a favicon to the index.html page (and also a new 8 | MIME type to serve *.ico files) 9 | - Added a screenshot to the description 10 | - Removed some debug code `from www/script.js` 11 | 12 | Bert 0.42, 2016-01-04 13 | --------------------- 14 | - Initial version -------------------------------------------------------------------------------- /ScriptRunner.js: -------------------------------------------------------------------------------- 1 | var spawn = require('child_process').spawn; 2 | var events = require('events'); 3 | 4 | // Simple script runner wraper to catch all output of the script 5 | // with the script as argument 6 | function ScriptRunner(scriptName) { 7 | // State of this object where: 8 | // 0 = Empty/Reset, 1 = Running, 2 = Finished 9 | var state = 0; 10 | 11 | // Text received from stdout (and also stderr) 12 | var stdout = ''; 13 | 14 | // The process handle 15 | var handle; 16 | 17 | function log(str) { 18 | console.log(' *** [' + scriptName + '] ' + str) 19 | } 20 | 21 | // Runs the script attached to this object; should return 22 | // a boolean whether it works or not 23 | this.run = function() { 24 | log('Going to run script') 25 | 26 | // Create new process 27 | handle = spawn(scriptName); 28 | state = 1; // Assume that it's running 29 | 30 | handle.on('exit', function (code) { 31 | log('event=exit; EXIT_CODE=' + code) 32 | state = 2; // Now finished 33 | }); 34 | 35 | // Pipe stout and stderr output to the buffer 36 | handle.stdout.on('data', function (data) { 37 | stdout += data.toString(); 38 | }); 39 | 40 | handle.stderr.on('data', function (data) { 41 | stdout += data.toString(); 42 | }); 43 | 44 | handle.stdin.end(); 45 | 46 | // FIXME: test if script is really running 47 | return true; 48 | }; 49 | 50 | // Clear the object (for a 2nd run) 51 | this.clear = function() { 52 | state = 0; 53 | handle = undefined; 54 | stdout = ''; 55 | log('Clear triggered') 56 | }; 57 | 58 | // Expose API 59 | return { 60 | getState: function() { return state; }, 61 | getOutput: function() { return stdout; }, 62 | run: this.run, 63 | clear: this.clear 64 | }; 65 | } 66 | 67 | exports.ScriptRunner = ScriptRunner; 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bert 2 | 3 | Bert is a very simple download manager written for the Raspberry Pi (or any other embedded Linux device). 4 | 5 | ![Screenshot of the web interface](/screenshot.png?raw=true "Screenshot of the web interface currently downloading the latest Kernel version") 6 | 7 | Use case: I often need to download large files which I don't want to download via my computer since it bothers me having a process which can't interrupted by accident or on purpose. The Raspberry Pi is near the router and can download all the day without disturbing anybody or anything. 8 | 9 | Bert uses `wget` and some other linux command line tools (e.g. `du`, `df`) as backend. NodeJS provides the server and application logic and invokes `wget` through some wrapper object and parses it output. 10 | 11 | [Click here to read more about Bert on my blog!](http://maxstrauch.github.io/2016/01/08/bert-downloads-it.html) 12 | 13 | # Features 14 | 15 | - Simple 16 | - The web interface proivdes an easy way to add or monitor downloads 17 | - Queue behaviour: only one download active download at a time 18 | - Run user script from web interface (very useful to copy the downloaded files from the `downloads/` folder to an USB thumb drive without `ssh` into the Pi) 19 | - Clear the downloads folder from web interface 20 | - Shutdown server from web interface 21 | - Detection of downloading the same file multiple times 22 | 23 | # Run it! 24 | 25 | You can run it by simply invoking 26 | 27 | node server.js 28 | 29 | I have created a `cron` entry in my Pi with `crontab -e` by adding the line `@reboot /bin/bash --login /home/pi/script.sh > /home/pi/work.log` which will excute a special script on reboot. `/home/pi/script.sh` contains the following: 30 | 31 | #!/bin/sh 32 | sleep 120 33 | (/usr/local/bin/node server.js > out.log) & 34 | 35 | I don't know why there is a `sleep 120` anymore. But you all know the rule: never change a running system! 36 | 37 | The configuration is done in the first 32 lines of `server.js` - each parameter is documented there. 38 | 39 | # License 40 | 41 | Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0). Click [here](http://creativecommons.org/licenses/by-sa/4.0/) for details. The license applies to the entire source code. 42 | 43 | `This program is distributed WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.` 44 | 45 | # Trivia 46 | 47 | I'm a huge fan of NCIS. Guess who Bert is ... 48 | 49 | ![Bert](/www/logo.png?raw=true) 50 | 51 | -------------------------------------------------------------------------------- /www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Bert 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 25 | 26 | 27 | 35 | 36 | 37 | 60 | 61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /www/fontello.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Copyright (C) 2015 by original authors @ fontello.com 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /www/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 20 | 22 | 45 | 47 | 48 | 50 | image/svg+xml 51 | 53 | 54 | 55 | 56 | 57 | 62 | 67 | 73 | 79 | Adapted from:https://openclipart.org/detail/75277/hippo-line-art 94 | 95 | 96 | -------------------------------------------------------------------------------- /NodeDownloader.js: -------------------------------------------------------------------------------- 1 | var spawn = require('child_process').spawn, 2 | events = require('events'); 3 | 4 | // Creates a new download object with the directory 5 | // where the downloaded file should be saved 6 | function NodeDownloader(dirToSave) { 7 | 8 | // The object of the new process 9 | var dl; 10 | 11 | // Event emitter 12 | var eventEmitter = new events.EventEmitter(); 13 | 14 | // Last progress value (fire event only on progress change 15 | // otherwise we would pipe too many calls to the server (maybe)) 16 | var lastProgress; 17 | 18 | // General buffer object 19 | var buffer = ''; 20 | 21 | // State of this download (see nextLine()) 22 | var state = 0; 23 | 24 | // Other status information variables 25 | var httpState = -1, retval = -1, contentLength = -1; 26 | var progress, bytesReceived, downloadRate, eta; 27 | var saveTo; 28 | 29 | // stopDownload() not yet called 30 | var wasKilled = false; 31 | 32 | // add the Trim function to javascript 33 | String.prototype.trim = function() { 34 | return this.replace(/^\s+|\s+$/g, ''); 35 | } 36 | 37 | // Test if the download was successfull 38 | this.wasSuccessfull = function() { 39 | return httpState == 200 && retval < 1; 40 | } 41 | 42 | // Get the estimated download time outputted by wget 43 | this.getETA = function() { 44 | return eta; 45 | } 46 | 47 | // The name of the downloaded file (from wget containing 48 | // also the path from the base directory) 49 | this.getSaveTo = function() { 50 | return saveTo; 51 | } 52 | 53 | // Like getSaveTo() but only the filename of the 54 | // downloaded file 55 | this.getName = function() { 56 | return saveTo.substr(dirToSave.length); 57 | } 58 | 59 | // This function parses every line outputted by wget and 60 | // saves important bits (e.g. progress) and calls the 61 | // listening objects 62 | var nextLine = function(line) { 63 | var lline = line.toLowerCase(); 64 | var index; 65 | 66 | switch (state) { 67 | case 0: // INITIAL 68 | if (lline.indexOf("response begin") > 0) { 69 | state = 1; 70 | } else { 71 | // Skip lines 72 | } 73 | return; 74 | 75 | case 1: // READ_RESPONSE 76 | if (lline.indexOf("response end") > 0) { 77 | state = 2; 78 | } else if (lline.indexOf("http/1.") == 0) { 79 | httpState = line.match(/[0-9]{3}/); 80 | } else if ((index = lline.indexOf(": ")) > 0) { 81 | var key = line.substr(0, index).trim(); 82 | var value = line.substr(index + 1).trim(); 83 | 84 | if (key.toLowerCase().indexOf("content-length") != -1) { 85 | contentLength = value; 86 | } 87 | } else { 88 | // Unknown, skip 89 | } 90 | return; 91 | 92 | case 2: // AFTER_RESPONSE 93 | if (lline.indexOf("response begin") > 0) { 94 | state = 1; 95 | } else if ((index = lline.indexOf(dirToSave.toLowerCase())) != -1) { 96 | line = line.substr(index); 97 | if ((index = line.indexOf('«')) != -1) { 98 | saveTo = line.substr(0, index); 99 | } else { 100 | saveTo = line.substr(0, line.indexOf('\'')); 101 | } 102 | } else { 103 | // Wrap into an try-catch block since sometimes there 104 | // occur parsing errors due to missing characters which 105 | // aren't flushed out of the wget char buffer yet 106 | try { 107 | if(line.indexOf("..........") != -1) { 108 | var regExp = new RegExp('^.*?([0-9a-zA-Z]+).*?[\. ]+.*?([0-9]+)%.*?([a-zA-Z0-9\,\.]+).*?([a-zA-Z0-9\,\.]+)\s*$'); 109 | var prog = line.match(regExp); 110 | 111 | progress = parseInt(prog[2]); 112 | 113 | bytesReceived = prog[1]; 114 | var c = bytesReceived.charAt(bytesReceived.length - 1); 115 | if (c == 'K') { 116 | bytesReceived = parseInt(bytesReceived.substr(0, bytesReceived.length - 1)) * 1024; 117 | } else if (c == 'M') { 118 | bytesReceived = parseInt(bytesReceived.substr(0, bytesReceived.length - 1)) * 1024 * 1024; 119 | } 120 | 121 | downloadRate = prog[3]; 122 | eta = prog[4]; 123 | 124 | var min = eta.match(/([0-9]+)m/); 125 | if (min != null && min.length > 0) { 126 | min = parseInt(min[1]); 127 | } else { 128 | min = 0; 129 | } 130 | var second = eta.match(/([0-9]+)s/); 131 | if (second != null && second.length > 0) { 132 | second = parseInt(second[1]); 133 | } else { 134 | second = 0; 135 | } 136 | eta = min * 60 + second; 137 | 138 | // call only when percentage changed 139 | if(lastProgress != progress) { 140 | lastProgress = progress; 141 | // call the event 142 | eventEmitter.emit('progress', progress, downloadRate); 143 | } 144 | } 145 | 146 | } catch(err) { 147 | console.log(" *** Downloader error: " + err); 148 | } 149 | } 150 | return; 151 | 152 | default: 153 | return; 154 | } 155 | }; 156 | 157 | // That's the master function which starts the download of a file 158 | // and attaches all necessary listeners 159 | this.downloadFile = function(file) { 160 | dl = spawn('wget', ['-d', '-P' + dirToSave, file]); 161 | 162 | dl.on('exit', function (code) { 163 | retval = code; 164 | if (!wasKilled) { // Don't send finished event iff killed 165 | eventEmitter.emit('finished'); 166 | } 167 | }); 168 | 169 | dl.stderr.on('data', function (data) { 170 | buffer += data.toString(); 171 | 172 | var line = '', index = -1; 173 | for (var i = 0; i < buffer.length; i++) { 174 | if (buffer.charAt(i) == '\n') { 175 | nextLine(line); 176 | line = ''; 177 | index = i; 178 | } else { 179 | line += buffer.charAt(i); 180 | } 181 | } 182 | 183 | if (index > 0 && index < buffer.length) { 184 | buffer = buffer.substr(index); 185 | } 186 | }); 187 | dl.stdin.end(); 188 | } 189 | 190 | // Stops the current download and the wget process which 191 | // succs the file down 192 | this.stopDownload = function() { 193 | wasKilled = true; 194 | dl.kill(); 195 | } 196 | 197 | // Expose API 198 | return { 199 | getSaveTo: this.getSaveTo, 200 | getETA: this.getETA, 201 | wasSuccessfull: this.wasSuccessfull, 202 | stopDownload: this.stopDownload, 203 | downloadFile: this.downloadFile, 204 | getName: this.getName, 205 | eventEmitter: eventEmitter 206 | }; 207 | } 208 | 209 | exports.NodeDownloader = NodeDownloader; 210 | -------------------------------------------------------------------------------- /www/style.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'fontello'; 3 | src: url('fontello.eot'); 4 | src: url('fontello.eot') format('embedded-opentype'), 5 | url('fontello.woff') format('woff'), 6 | url('fontello.ttf') format('truetype'), 7 | url('fontello.svg') format('svg'); 8 | font-weight: normal; 9 | font-style: normal; 10 | } 11 | 12 | [class^="icon-"]:before, [class*=" icon-"]:before { 13 | font-family: "fontello"; 14 | font-style: normal; 15 | font-weight: normal; 16 | speak: none; 17 | 18 | display: inline-block; 19 | text-decoration: inherit; 20 | width: 1em; 21 | margin-right: .2em; 22 | text-align: center; 23 | /* opacity: .8; */ 24 | 25 | /* For safety - reset parent styles, that can break glyph codes*/ 26 | font-variant: normal; 27 | text-transform: none; 28 | 29 | /* fix buttons height, for twitter bootstrap */ 30 | line-height: 1em; 31 | 32 | /* Animation center compensation - margins should be symmetric */ 33 | /* remove if not needed */ 34 | margin-left: .2em; 35 | 36 | /* you can be more comfortable with increased icons size */ 37 | /* font-size: 120%; */ 38 | 39 | /* Font smoothing. That was taken from TWBS */ 40 | -webkit-font-smoothing: antialiased; 41 | -moz-osx-font-smoothing: grayscale; 42 | 43 | /* Uncomment for 3D effect */ 44 | /* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */ 45 | } 46 | 47 | .icon-download:before { content: '\e802'; } 48 | .icon-file:before { content: '\e801'; } 49 | .icon-hourglass:before { content: '\e803'; } 50 | .icon-fail:before { content: '\e804'; } 51 | .icon-success:before { content: '\e805'; } 52 | .icon-github:before { content: '\e806'; } 53 | .icon-trash:before { content: '\e808'; } 54 | .icon-reload:before { content: '\e809'; } 55 | 56 | /* --- */ 57 | 58 | html { font: 13px/1.4 Helvetica,arial,sans-serif; margin: 0; padding: 0; } 59 | body { margin: 1% 1% 1% 31%; } 60 | 61 | #logo { 62 | margin: 2em 0 0 0; 63 | text-align: center; 64 | } 65 | 66 | .pagemenu h1 { 67 | color: #8a8a8a; 68 | margin-left: 1em; 69 | padding: 0; 70 | } 71 | 72 | .pagemenu label { 73 | color: #8a8a8a; 74 | } 75 | 76 | .pagemenu { 77 | width: 30%; 78 | position: fixed; 79 | top: 0px; 80 | left: 0px; 81 | bottom: 0px; 82 | left: 0; 83 | background-color: #F5F5F5; 84 | border-right: 1px solid #E5E5E5; 85 | } 86 | 87 | .pagemenu a, .pagemenu a:visited { 88 | text-decoration: none; 89 | color: #969696; 90 | } 91 | 92 | .pagemenu a:hover, .pagemenu a:active, .pagemenu a:focus { 93 | color: #707070; 94 | } 95 | 96 | .pagemenu .form { margin-left: 2em; } 97 | 98 | .pagemenu .info { 99 | border-top: solid 1px #e3e3e3; 100 | margin: 0.5em 1em 0 1em !important; 101 | padding: .5em .5em 0 .5em; 102 | } 103 | 104 | .pagemenu .space { 105 | height: 2em; 106 | } 107 | 108 | .pagemenu .info p { 109 | margin: 0; 110 | padding: 2px 0; 111 | color: #8A8A8A; 112 | } 113 | 114 | .pagemenu .info .meta-info { 115 | text-align: center !important; 116 | color: #B3B3B3; 117 | } 118 | 119 | #dskprog { 120 | position: absolute; 121 | width: 90%; 122 | } 123 | 124 | #actions { 125 | color: #707070 !important; 126 | margin: 1.5em 0 0 0; 127 | } 128 | 129 | #actions a:hover, #actions a:active { 130 | color: #569E3D; 131 | text-decoration: underline; 132 | } 133 | 134 | input { 135 | margin-bottom: 5px !important; 136 | } 137 | 138 | input[type="text"] { 139 | display: inline; 140 | height: 28px !important; 141 | padding: 0 8px; 142 | font-size: 12px; 143 | color: #333; 144 | background-color: #fff; 145 | border: 1px solid #CCC; 146 | border-radius: 3px; 147 | box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.075) inset; 148 | margin-right: .5em; 149 | width: 84%; 150 | } 151 | 152 | input[type="button"], input[type="button"]:visited, .button, .button:visited { 153 | display: inline-block; 154 | padding: 0 8px; 155 | height: 28px !important; 156 | font-size: 12px; 157 | font-weight: bold; 158 | border: 1px solid #4A993E; 159 | border-radius: 3px; 160 | color: #fff; 161 | background-image: linear-gradient(#79D858, #569E3D); 162 | cursor: pointer; 163 | } 164 | 165 | .button, .button:visited { 166 | padding: 5px 8px !important; 167 | height: auto !important; 168 | font-size: 12px !important; 169 | text-decoration: none; 170 | } 171 | 172 | input[type="button"]:hover, input[type="button"]:active, input[type="button"]:focus, 173 | .button:hover, .button:active, .button:focus { 174 | background-color: #569E3D !important; 175 | background-image: linear-gradient(#4FBA2C, #569E3D); 176 | } 177 | 178 | 179 | 180 | 181 | 182 | 183 | ul { 184 | table-layout: fixed; 185 | width: 100%; 186 | color: #999; 187 | border: 1px solid #E5E5E5; 188 | padding: 0px; 189 | margin-top: 0px; 190 | margin-bottom: 0px; 191 | border-radius: 3px; 192 | } 193 | 194 | ul li { 195 | list-style: none; 196 | position: relative; 197 | 198 | 199 | color: #999; 200 | border-bottom: solid 1px #eee; 201 | font-weight: bold; 202 | color: #333; 203 | font-size: 105%; 204 | 205 | word-wrap: break-word; 206 | 207 | } 208 | 209 | /* Heading */ 210 | 211 | ul li h1 { 212 | margin: 0; 213 | padding: 0; 214 | font-size: 21px; 215 | font-weight: normal; 216 | padding: 4px; 217 | white-space: nowrap; 218 | width: 100%; 219 | overflow: hidden; 220 | -o-text-overflow: ellipsis; 221 | text-overflow: ellipsis; 222 | } 223 | 224 | ul li h1 em { 225 | color: black; 226 | font-weight: normal; 227 | margin-right: 4px; 228 | color: #808080; 229 | } 230 | 231 | ul li h1 em.icon-download { color: #4FBA2C; } 232 | ul li h1 em.icon-globe { color: #2C96BA; } 233 | 234 | /* Others */ 235 | ul li .meta { 236 | padding: 0 6px; 237 | background-color: #E8E8E8; 238 | margin-bottom: 4px; 239 | } 240 | 241 | ul li .meta label { 242 | margin-right: 3px; 243 | } 244 | 245 | ul li .meta strong { 246 | font-weight: normal; 247 | margin-right: 14px; 248 | } 249 | 250 | /* Progress bar/line */ 251 | 252 | .progress, ul li .progress { 253 | height: 5px; 254 | background-color: #E8E8E8; 255 | } 256 | 257 | .progress div, ul li .progress div { 258 | float: left; 259 | width: 0%; 260 | height: 5px; 261 | color: #FFF; 262 | text-align: center; 263 | background-color: #4FBA2C; 264 | position: absolute; 265 | overflow: hidden; 266 | } 267 | 268 | /* Delete download icon */ 269 | 270 | ul li a.action, ul li a.action:visited { 271 | position: absolute; 272 | top: 0; 273 | right: 0; 274 | margin: 5px 4px 0 0; 275 | font-weight: normal; 276 | text-decoration: none; 277 | color: #CFCFCF; 278 | } 279 | 280 | ul li a.action:hover, ul li a.action:active, ul li a.action:focus { 281 | color: black; 282 | } 283 | 284 | ul li a.action:before { 285 | font-family: 'fontello'; 286 | content: '\e808'; 287 | font-size: 21px; 288 | font-weight: normal; 289 | } 290 | 291 | /* Message board */ 292 | 293 | #mmsg-board { 294 | border: none; 295 | z-index: 999; 296 | position: fixed; 297 | display: block; 298 | right: 7px; 299 | top: 7px; 300 | width: 21%; 301 | } 302 | 303 | .mmsg { 304 | border-radius: 5px; 305 | padding: 7px 10px; 306 | margin: 5px 0 10px 0; 307 | font-weight: normal; 308 | display: block; 309 | -webkit-animation-duration: 0.42s; 310 | -moz-animation-duration: 0.42s; 311 | animation-duration: 0.42s; 312 | -webkit-animation-name: fadeIn; 313 | -moz-animation-name: fadeIn; 314 | animation-name: fadeIn; 315 | -webkit-animation-timing-function: ease-in-out; 316 | -moz-animation-timing-function: ease-in-out; 317 | animation-timing-function: ease-in-out; 318 | } 319 | 320 | @keyframes fadeIn { 321 | 0% { display:none; opacity: 0; } 322 | 1% { opacity: 0; } 323 | 100% { display: block; opacity: 1; } 324 | } 325 | 326 | .mmsg.fadeOut { 327 | opacity: 0; 328 | -webkit-animation-duration: 0.42s; 329 | -moz-animation-duration: 0.42s; 330 | animation-duration: 0.42s; 331 | -webkit-animation-name: fadeIn; 332 | -moz-animation-name: fadeIn; 333 | animation-name: fadeIn; 334 | -webkit-animation-timing-function: ease-in-out; 335 | -moz-animation-timing-function: ease-in-out; 336 | animation-timing-function: ease-in-out; 337 | } 338 | 339 | @keyframes fadeOut{ 340 | 0% {opacity: 1;} 341 | 100% {opacity: 0;} 342 | } 343 | 344 | .mmsg.err { 345 | background-color: #E6CAC1; 346 | box-shadow: 0 0 3px rgba(0, 0, 0, 0.14); 347 | border: solid 1px #CD4945; 348 | color: #54212E; 349 | } 350 | 351 | .mmsg.nfo { 352 | box-shadow: 0 0 3px rgba(0, 0, 0, 0.14); 353 | background-color: #CAE6C1; 354 | border: solid 1px #8CC978; 355 | color: #2E5421; 356 | } 357 | 358 | /* No connection overlay */ 359 | 360 | #fail, #script-out { 361 | position: fixed; 362 | top: 0; 363 | left: 0; 364 | width: 100%; 365 | height: 100%; 366 | z-index: 9999; 367 | background-color: rgba(0, 0, 0, 0.5); 368 | } 369 | 370 | #fail div, #script-out div { 371 | background-color: #F5F5F5; 372 | width: 42%; 373 | text-align: center; 374 | margin-left: auto; 375 | margin-right: auto; 376 | margin-top: 7%; 377 | padding: 10px; 378 | border-radius: 4px; 379 | -webkit-box-shadow: 0px 2px 8px -3px rgba(0,0,0,0.5); 380 | -moz-box-shadow: 0px 2px 8px -3px rgba(0,0,0,0.5); 381 | box-shadow: 0px 2px 8px -3px rgba(0,0,0,0.5); 382 | } 383 | 384 | #script-out pre { 385 | border: 1px solid #aaa; 386 | height: 300px; 387 | overflow-y: scroll; 388 | text-align: left; 389 | word-wrap: break-word; 390 | padding: 7px; 391 | } 392 | 393 | #fail div h1 { 394 | margin: 0; 395 | padding: 0; 396 | color: #5C5C5C; 397 | } 398 | 399 | #script-out .disabled, #script-out .disabled:visited, 400 | #script-out .disabled:hover, #script-out .disabled:active, #script-out .disabled:focus { 401 | border: 1px solid #999999; 402 | color: #fff; 403 | background-image: linear-gradient(#D9D9D9, #A6A6A6); 404 | cursor: default; 405 | } 406 | -------------------------------------------------------------------------------- /www/script.js: -------------------------------------------------------------------------------- 1 | // Converts seconds to a more convenient format 2 | // @see: http://stackoverflow.com/a/13368349/2429611 3 | function toHHMMSS(n) { 4 | var seconds = Math.floor(n), 5 | hours = Math.floor(seconds / 3600); 6 | seconds -= hours*3600; 7 | var minutes = Math.floor(seconds / 60); 8 | seconds -= minutes*60; 9 | 10 | if (hours < 10) {hours = "0"+hours;} 11 | if (minutes < 10) {minutes = "0"+minutes;} 12 | if (seconds < 10) {seconds = "0"+seconds;} 13 | return hours+':'+minutes+':'+seconds; 14 | } 15 | 16 | // Format bytes human read-able 17 | // @see: http://stackoverflow.com/a/18650828/2429611 18 | function bytesToSize(bytes) { 19 | var sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; 20 | if (bytes == 0) return '0 Byte'; 21 | var i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024))); 22 | return Math.round(bytes / Math.pow(1024, i), 2) + ' ' + sizes[i]; 23 | } 24 | 25 | // Shortcut to get a DOM element by it's id 26 | function $(id) { 27 | return document.getElementById(id); 28 | } 29 | 30 | // Shortcut to make a new DOM element and apply 31 | // different attributes to it 32 | function $make(tag, opts) { 33 | var elem = document.createElement(tag); 34 | 35 | if (opts && opts.className) { 36 | elem.className = opts.className; 37 | } 38 | 39 | if (opts && opts.attr) { 40 | for (var attr in opts.attr) { 41 | elem.setAttribute(attr, opts.attr[attr]); 42 | } 43 | } 44 | 45 | if (opts && opts.id) { 46 | elem.id = opts.id; 47 | } 48 | 49 | if (opts && opts.html) { 50 | elem.innerHTML = opts.html; 51 | } 52 | 53 | if (opts && opts.onclick) { 54 | elem.onclick = opts.onclick; 55 | } 56 | 57 | if (opts && opts.parent) { 58 | opts.parent.appendChild(elem); 59 | } 60 | 61 | return elem; 62 | } 63 | 64 | // Simple AJAX function to communicate with the backend 65 | function $ajax(url, cb, get) { 66 | // Crate the url (with GET parameters if supplied) 67 | var totalURL = url; 68 | var params = '?'; 69 | for (var key in get) { 70 | var value = encodeURIComponent(get[key]).replace(/%20/g,'+'); 71 | params += key + '=' + value + '&'; 72 | } 73 | if (params.length == 1) { 74 | params = ''; 75 | } else { 76 | params = params.substring(0, params.length - 1); 77 | } 78 | totalURL = url + params; 79 | 80 | // Run the request 81 | var xmlhttp; 82 | if (window.XMLHttpRequest) { 83 | xmlhttp=new XMLHttpRequest(); 84 | } else { 85 | xmlhttp=new ActiveXObject("Microsoft.XMLHTTP"); 86 | } 87 | 88 | xmlhttp.onreadystatechange = function() { 89 | if (xmlhttp.readyState == 4) { 90 | if (xmlhttp.status == 200) { 91 | var data = xmlhttp.responseText; 92 | if (data.length > 0) { 93 | cb(JSON.parse(xmlhttp.responseText)); 94 | return; 95 | } 96 | } 97 | 98 | // No connection to the backend any more 99 | cry(); 100 | } 101 | } 102 | xmlhttp.open("GET", totalURL, true); 103 | xmlhttp.send(); 104 | } 105 | 106 | // Info board object providing short messages just like 107 | // in OS X 108 | function InfoBoard(id, infoDur, alertDur) { 109 | var board = $(id); 110 | 111 | var display = function(str, className, dur) { 112 | // Create the element 113 | var elem = $make('li', { 114 | 'className': 'mmsg ' + className, 115 | 'parent': board 116 | }); 117 | 118 | // Append the text 119 | elem.appendChild(document.createTextNode(str)); 120 | 121 | // Delete element after time expired 122 | window.setTimeout(function() { 123 | elem.className = elem.className + " fadeOut"; 124 | window.setTimeout(function() { 125 | elem.parentNode.removeChild(elem); 126 | }, 420); 127 | }, dur); 128 | }; 129 | 130 | this.alert = function(str) { 131 | display(str, "err", alertDur); 132 | }; 133 | 134 | this.info = function(str) { 135 | display(str, "nfo", infoDur); 136 | }; 137 | 138 | return { 139 | alert: this.alert, 140 | info: this.info 141 | }; 142 | } 143 | 144 | // Main update function for the list 145 | function update() { 146 | $ajax('/rest/', function(ret) { 147 | if (ret.state != 200) { 148 | // FIXME: make me nicer 149 | infoBoard.alert("Can't update the download list (a bug)!"); 150 | return; 151 | } 152 | 153 | // Sort list by state 154 | ret.all.sort(function(a, b) { 155 | if (a.state == 4) { 156 | return 1; 157 | } 158 | 159 | if (b.state == 4) { 160 | return -1; 161 | } 162 | 163 | if (a.state == 1 || b.state == 1) { 164 | return (b.state == 1 ? 1 : -1) * 1000; 165 | } 166 | 167 | if (a.state == b.state && b.state == 0) { 168 | return a.id > b.id ? 1 : -1; 169 | } 170 | 171 | if (a.state == 0 || b.state == 0) { 172 | return (b.state == 0 ? 1 : -1) * 500; 173 | } 174 | 175 | return a.id - b.id; 176 | }); 177 | 178 | // Remove all old entries 179 | var listNode = $('output'); 180 | while (listNode.firstChild) { 181 | listNode.removeChild(listNode.firstChild); 182 | } 183 | 184 | // Create and add new entries 185 | for (var i = 0; i < ret.all.length; i++) { 186 | var li = $make('li', {'parent': listNode}); 187 | var e = ret.all[i]; 188 | 189 | // Add a progress bar, if downloading 190 | if (e.state == 1) { 191 | var prog = $make('div', { 192 | 'className': 'progress', 193 | 'parent': li 194 | }); 195 | $make('div', { 196 | 'parent': prog 197 | }).style.width = e.percent + '%'; 198 | } 199 | 200 | // Create the header 201 | var heading = $make('h1', {'parent': li}); 202 | heading.title = e.name; 203 | 204 | if (e.state == 0) { 205 | $make('em', {'className': 'icon-hourglass', 'parent': heading}); 206 | } else if (e.state == 1) { 207 | $make('em', {'className': 'icon-download', 'parent': heading}); 208 | } else if (e.state == 3) { 209 | $make('em', {'className': 'icon-fail', 'parent': heading}); 210 | } else if (e.state == 4) { 211 | $make('em', {'className': 'icon-file', 'parent': heading}); 212 | } else { 213 | if (e.success) { 214 | $make('em', {'className': 'icon-success', 'parent': heading}); 215 | } else { 216 | $make('em', {'className': 'icon-fail', 'parent': heading}); 217 | } 218 | } 219 | 220 | if (e.state == 4) { 221 | heading.appendChild(document.createTextNode(e.name)); 222 | } else { 223 | heading.appendChild(document.createTextNode(e.url)); 224 | } 225 | 226 | // Add delete action only if not already deleted or downloaded 227 | if (e.state == 0 || e.state == 1) { 228 | $make('a', { 229 | 'className': 'action', 230 | 'onclick': function(e) { 231 | var id = this.getAttribute('data-id'); 232 | $ajax('/rest/', function(ret1) { 233 | // FIXME: do something useful here 234 | }, {'cmd': 'stop', 'id': id}); 235 | }, 236 | 'attr': {'data-id': e.id}, 237 | 'parent': li 238 | }).href = '#'; 239 | } 240 | 241 | // Render the meta information area 242 | var div = $make('div', {'className': 'meta', 'parent': li}); 243 | 244 | if (e.state == 1) { // Only on download ... 245 | $make('label', {'html': 'Progress:', 'parent': div}); 246 | $make('strong', {'html': e.percent + '%', 'parent': div}); 247 | 248 | $make('label', {'html': 'ETA:', 'parent': div}); 249 | $make('strong', {'html': toHHMMSS(e.eta), 'parent': div}); 250 | 251 | $make('label', {'html': 'Speed:', 'parent': div}); 252 | $make('strong', {'html': e.speed, 'parent': div}); 253 | } 254 | 255 | if (e.state == 4) { 256 | $make('label', {'html': 'Size:', 'parent': div}); 257 | $make('strong', {'html': bytesToSize(e.size), 'parent': div}); 258 | } else { 259 | 260 | if (e.state >= 1 && e.filename.length > 0) { 261 | $make('label', {'html': 'Filename:', 'parent': div}); 262 | $make('strong', {'html': e.name, 'parent': div}); 263 | } else if (e.state > 1 && e.size > 0) { 264 | $make('label', {'html': 'Size:', 'parent': div}); 265 | $make('strong', {'html': bytesToSize(e.size), 'parent': div}); 266 | 267 | $make('label', {'html': 'Successful?', 'parent': div}); 268 | $make('strong', {'html': e.success ? 'Yes' : 'No', 'parent': div}); 269 | } else if (e.success == false && e.errmsg.length > 0) { 270 | $make('label', {'html': 'Successful?', 'parent': div}); 271 | $make('strong', {'html': e.success ? 'Yes' : 'No', 'parent': div}); 272 | 273 | $make('label', {'html': 'Error message:', 'parent': div}); 274 | $make('strong', {'html': e.errmsg, 'parent': div}); 275 | } 276 | } 277 | } 278 | }, {'cmd': 'list'}); 279 | } 280 | 281 | // Update system stats (e.g. used disk space) 282 | function updateStats() { 283 | $ajax('/rest/', function(ret) { 284 | if (ret.used < 0) { 285 | $('diskusage').style.width = '0%'; 286 | $('useddsk').innerHTML = 'N/A'; 287 | return; 288 | } 289 | $('diskusage').style.width = ret.percent + '%'; 290 | $('useddsk').innerHTML = Math.round(ret.percent*10)/10 + '% (' + bytesToSize(ret.used) + ')'; 291 | }, {'cmd': 'dskstat'}); 292 | } 293 | 294 | // Check for user script execution and display 295 | // the result 296 | function updateScriptExec() { 297 | $ajax('/rest/', function(ret) { 298 | if (ret.state == 200) { 299 | // Disable controls and show overlay 300 | $('script-data-out').innerHTML = ''; 301 | $('script-exit').className = 'button disabled'; 302 | $('script-out').style.display = 'block'; 303 | 304 | // Get the data from stdout 305 | $ajax('/rest/', function(ret) { 306 | // Set it 307 | $('script-data-out').innerHTML = ret.data; 308 | 309 | // If finished ... 310 | if (ret.finished) { 311 | $ajax('/rest/', function(ret) { 312 | // FIXME: really don't care? 313 | }, {'cmd': 'user-script', 'exit': true}); 314 | 315 | // Enable close button of script overlay 316 | $('script-exit').className = 'button'; 317 | $('script-exit').onclick = function() { 318 | this.onclick = undefined; 319 | $('script-out').style.display = 'none'; 320 | return false; 321 | }; 322 | } 323 | }, {'cmd': 'user-script'}); 324 | } 325 | }, {'cmd': 'user-script', 'test': true} 326 | ); 327 | } 328 | 329 | // Reload the entire page 330 | function reload() { 331 | window.location.href = '/'; 332 | } 333 | 334 | // When the backend connection is lost execute this function 335 | // and display an error message and show the overlay for this 336 | // case 337 | function cry() { 338 | infoBoard.alert("Failed to connect to the backend!"); 339 | for (var i = 0; i < intervals.length; i++) { 340 | clearInterval(intervals[i]); 341 | } 342 | $('fail').style.display = 'block'; 343 | } 344 | 345 | // Download intervals 346 | var intervals = []; 347 | 348 | // Message board 349 | var infoBoard; 350 | 351 | window.onload = function() { 352 | infoBoard = new InfoBoard("mmsg-board", 2100, 5000); 353 | 354 | // Attach listener to download field and button 355 | // and allow for ENTER to start download directly 356 | // from the text field 357 | var loadFile = function(e) { 358 | var opts; 359 | if ($('force').checked) { 360 | opts = {'url': $('addr').value, 'force': 1}; 361 | } else { 362 | opts = {'url': $('addr').value}; 363 | } 364 | 365 | $ajax('/rest/', function(ret) { 366 | if (ret.state == 200) { 367 | infoBoard.info("Download added to the list."); 368 | } else if (ret.state == 501) { 369 | infoBoard.alert("The same URL was previously added to the downloads list."); 370 | } else if (ret.state == 500) { 371 | infoBoard.alert("The given string might not be an URL!"); 372 | } else { 373 | infoBoard.alert("Couldn't add download to list (error code #" + ret.state + ")!"); 374 | } 375 | }, opts); 376 | $('addr').value = ''; 377 | $('force').checked = false; 378 | return false; 379 | }; 380 | 381 | $("load").onclick = loadFile; 382 | $("addr").onkeyup = function(e) { 383 | if (e.keyCode == 13) { 384 | loadFile(e); 385 | } 386 | return false; 387 | }; 388 | 389 | // Handle clear all downloads request 390 | $('on-clear-all').onclick = function(e) { 391 | if (confirm('Do you really want to remove all downloads?')) { 392 | $ajax('/rest/', function(ret) { 393 | if (ret.state == 200) { 394 | infoBoard.info("Downloads removed, list cleared."); 395 | } else { 396 | infoBoard.alert("Error while removing downloads."); 397 | } 398 | }, {'cmd': 'clear-all'} 399 | ); 400 | } 401 | return false; 402 | }; 403 | 404 | // Handle user script execution 405 | $('on-user-script').onclick = function(e) { 406 | $ajax('/rest/', function(ret) { 407 | updateScriptExec(); 408 | }, {'cmd': 'user-script', 'run': true} 409 | ); 410 | return false; 411 | }; 412 | 413 | // Handle shutdown request 414 | $('on-shutdown').onclick = function(e) { 415 | var code = prompt('Enter keycode to shutdown the entire server:'); 416 | if (code.length > 0) { 417 | $ajax('/rest/', function(ret) { 418 | if (ret.state == 200) { 419 | infoBoard.info("Server will shutdown now."); 420 | } else { 421 | infoBoard.alert("Can't shutdown the server."); 422 | } 423 | }, {'cmd': 'halt', 'code': code} 424 | ); 425 | } 426 | 427 | return false; 428 | }; 429 | 430 | // Create update intervals to keep the download list 431 | // and stats up-to-date (with different periodicities) 432 | update(); 433 | intervals.push(setInterval(update, 2500)); 434 | 435 | updateStats(); 436 | intervals.push(setInterval(updateStats, 10000)); 437 | 438 | updateScriptExec(); 439 | intervals.push(setInterval(updateScriptExec, 2000)); 440 | 441 | // Get server version info (only on start up) 442 | $ajax('/rest/', function(ret) { 443 | $('versions').innerHTML = ret.app + ' on ' + ret.node; 444 | }, {'cmd': 'versions'}); 445 | } -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | // Required modules 2 | var http = require('http'); 3 | var url = require('url'); 4 | var fs = require('fs'); 5 | var downloader = require('./NodeDownloader.js'); 6 | 7 | // -------------------------------------------------------------------- 8 | // Configuration section 9 | 10 | // Socket port on which the server listens 11 | var SERVER_PORT = 8080; 12 | // IP address or hostname on which the server listens 13 | var SERVER_HOST = "127.0.0.1"; 14 | // Path to the directory containing the downloaded files. This path 15 | // must end with a slash 16 | var DOWNLOAD_DIR = 'downloads/'; 17 | // Name of this application (DO NOT CHANGE!) 18 | var APP_NAME = 'Bert'; 19 | // Version of this application (DO NOT CHANGE!) 20 | var APP_VERSION = '0.42.2'; 21 | // Script to be executed when the user clicks on "Run user script". 22 | // The second argument takes the script name relative to this file 23 | var SCRIPT = require('./ScriptRunner.js').ScriptRunner('./script.sh'); 24 | // The 'password' used to trigger a shutdown command of the 25 | // system the server currently runs on 26 | var SHUTDOWN_CODE = 'abby'; 27 | // The shell command executed when a shutdown is triggered 28 | var SHUTDOWN_CMD = 'sleep 10'; 29 | // Queue database file name (where the waiting downlaods are saved) 30 | var QUEUE_DB_FN = './queue.json'; 31 | 32 | // -------------------------------------------------------------------- 33 | 34 | // Download ID counter (only used internally) 35 | var ID_COUNTER = 0; 36 | 37 | // List of all downlaods 38 | var ALL = []; 39 | 40 | // The current download (or false if there is no current download) 41 | var CURRENT = false; 42 | 43 | // Removes a directory and all its content 44 | // @see https://gist.github.com/liangzan/807712 45 | function rmDir(dirPath) { //: boolean 46 | try { 47 | var files = fs.readdirSync(dirPath); 48 | } catch(e) { 49 | return false; 50 | } 51 | 52 | if (files.length > 0) { 53 | for (var i = 0; i < files.length; i++) { 54 | var filePath = dirPath + '/' + files[i]; 55 | if (fs.statSync(filePath).isFile()) { 56 | fs.unlinkSync(filePath); 57 | } else { 58 | rmDir(filePath); 59 | } 60 | } 61 | } 62 | fs.rmdirSync(dirPath); 63 | return true; 64 | } 65 | 66 | // Creates the download directory 67 | function createDownloadDir() { 68 | // Make sure that there is a downloads directory 69 | try { 70 | if (fs.statSync(DOWNLOAD_DIR).isDirectory()) { 71 | return; // Everything fine 72 | } 73 | } catch(e) { 74 | // Don't watch; (re-)create directory 75 | } 76 | 77 | fs.mkdir(DOWNLOAD_DIR,function(e){ 78 | if(e && !(e.code === 'EEXIST')){ 79 | console.log(' *** Error creating dir `' + DOWNLOAD_DIR + '`: ' + e); 80 | } else { 81 | console.log(' *** Oops! There was no `' + DOWNLOAD_DIR + '` dir'); 82 | } 83 | }); 84 | } 85 | 86 | // Calculates the used disk space of the downloads 87 | // by invoking the `du` command 88 | // FIXME: this function might not work properly (test & correct) 89 | function usedDsk() { 90 | createDownloadDir(); 91 | 92 | var result = -1; 93 | try { 94 | var result = parseInt( 95 | require('child_process') 96 | .execSync('du -ks ./' + DOWNLOAD_DIR) 97 | .toString() 98 | ) * 1024; 99 | } catch(e) { 100 | result = -1; // Nothing fancy here 101 | } 102 | return result; 103 | } 104 | 105 | // Calculates the free disk space by parsing the `df` output 106 | // FIXME: this function might not work properly (test & correct) 107 | function totalDsk() { 108 | createDownloadDir(); 109 | 110 | var result = -1, totalDskSpace = -1; 111 | try { 112 | totalDskSpace = parseInt( 113 | require('child_process') 114 | .execSync('df -Pk ./' + DOWNLOAD_DIR) 115 | .toString() 116 | .split(/\n/g)[1] 117 | .replace(/.*?([0-9]+)(.+$)/i,'$1') 118 | ) * 1024; 119 | } catch(e) { 120 | totalDskSpace = -1; // Nothing fancy here 121 | } 122 | return totalDskSpace; 123 | } 124 | 125 | // Simple function to provide some MIME types needed by the 126 | // server 127 | function mimeLookup(extension) { 128 | var list = { 129 | 'htm': 'text/html', 130 | 'html': 'text/html', 131 | 'htmls': 'text/html', 132 | 'css': 'text/css', 133 | 'js': 'text/javascript', 134 | 'svg': 'image/svg+xml', 135 | 'eot': 'application/vnd.ms-fontobject', 136 | 'ttf': 'font/truetype', 137 | 'woff': 'application/font-woff', 138 | 'woff2': 'application/font-woff2', 139 | 'png': 'image/png', 140 | 'ico': 'image/x-icon' 141 | }; 142 | for (var key in list) { 143 | if (key === extension) { 144 | return list[key]; 145 | } 146 | } 147 | return 'application/octet-stream'; 148 | } 149 | 150 | // Very basic HTTP error message assembler 151 | function cry(res, statusCode, message) { 152 | res.writeHead(statusCode, {'Content-Type': 'text/html'}); 153 | res.write('Error report'); 154 | res.write(''); 155 | res.write('

HTTP ' + statusCode + '

'); 156 | if (message) { 157 | res.write('

' + message + '

'); 158 | } 159 | res.end(''); 160 | } 161 | 162 | // This function is called when a new download should start. 163 | function onStart() { 164 | if (CURRENT != false || ALL.length < 1) { 165 | return; // Nothing to do here 166 | } 167 | 168 | // Get the next download 169 | CURRENT = false; 170 | for (var i = 0; i < ALL.length; i++) { 171 | if (ALL[i].state == 0) { 172 | CURRENT = ALL[i]; 173 | break; 174 | } 175 | } 176 | if (CURRENT == false) { 177 | return; // Nothing to do (?!) 178 | } 179 | 180 | // Begin the download process 181 | CURRENT.downloader.downloadFile(CURRENT.url); 182 | CURRENT.state = 1; // Downloading 183 | 184 | // Event listeners 185 | CURRENT.downloader.eventEmitter.on('progress', function(percent, speed) { 186 | CURRENT.percent = percent; 187 | CURRENT.speed = speed; 188 | CURRENT.eta = CURRENT.downloader.getETA(); 189 | CURRENT.filename = CURRENT.downloader.getSaveTo(); 190 | CURRENT.name = CURRENT.downloader.getName(); 191 | }); 192 | CURRENT.downloader.eventEmitter.on('finished', function() { 193 | CURRENT.state = 2; 194 | CURRENT.success = CURRENT.downloader.wasSuccessfull(); 195 | try { 196 | var stat = fs.statSync(CURRENT.filename); 197 | CURRENT.size = stat.size; 198 | } catch (e) { 199 | CURRENT.size = 0; 200 | CURRENT.success = false; 201 | CURRENT.errmsg = e.toString(); 202 | } 203 | CURRENT = false; 204 | persist(); 205 | 206 | // Let the next download begin (if any) 207 | onStart(); 208 | }); 209 | } 210 | 211 | // Persists the current running download and all waiting 212 | // downloads to the backup file (JSON content) 213 | function persist() { 214 | var output = []; 215 | 216 | // Pull out all waiting downloads 217 | for (var i = 0; i < ALL.length; i++) { 218 | if (ALL[i].state == 0 /* queued */ || 219 | ALL[i].state == 1 /* active */) { 220 | output.push(ALL[i].url); 221 | } 222 | } 223 | 224 | // Save data to file 225 | fs.writeFile(QUEUE_DB_FN, JSON.stringify(output), function(err) { 226 | if(err) { 227 | console.log(' *** Failed to write: ' + QUEUE_DB_FN + ' (' + err + ')'); 228 | return; 229 | } 230 | console.log(' *** Download queue saved to queue.json'); 231 | }); 232 | } 233 | 234 | // Loads (on server start) all downloaded files from the 235 | // download folder and all pending files 236 | function load() { 237 | // All waiting downloads (from previous run) 238 | fs.readFile(QUEUE_DB_FN, function read(err, data) { 239 | if (err) { 240 | console.log(' *** Can\'t read ' + QUEUE_DB_FN + ' (no saved downloads?)'); 241 | return; 242 | } 243 | 244 | // Get all URLs for waiting downloads 245 | var waiting = JSON.parse(data); 246 | for (var i = 0; i < waiting.length; i++) { 247 | console.log(' *** Restoring `' + waiting[i] + '`'); 248 | createDownload(waiting[i]); 249 | } 250 | 251 | if (waiting.length > 0) { 252 | console.log(' *** ' + waiting.length + ' waiting downloads restored; ' + 253 | ' resuming download ...'); 254 | onStart(); 255 | } 256 | }); 257 | 258 | // Scan the downloads directory for downloaded files and 259 | // add them to the view 260 | createDownloadDir(); 261 | var files = fs.readdirSync(DOWNLOAD_DIR); 262 | for (var i in files){ 263 | var stat = fs.statSync(DOWNLOAD_DIR + '/' + files[i]); 264 | if (stat.isDirectory()){ 265 | getFiles(name, files_); 266 | } else { 267 | var entry = { 268 | 'id': ID_COUNTER++, 269 | 'url': undefined, 270 | 'downloader': undefined, 271 | 'state': 4, 272 | 'percent': 0, 273 | 'speed': 0, 274 | 'eta': 0, 275 | 'filename': DOWNLOAD_DIR + '/' + files[i], 276 | 'success': true, 277 | 'size': stat.size, 278 | 'name': files[i], 279 | 'errmsg': '' 280 | }; 281 | ALL.push(entry); 282 | } 283 | } 284 | } 285 | 286 | function createDownload(url) { 287 | var entry = { 288 | 'id': ID_COUNTER++, 289 | 'url': url, 290 | 'downloader': new downloader.NodeDownloader(DOWNLOAD_DIR), 291 | /* 0 = queued , 1 = active, 2 = finished, 3 = stopped, 4 = file only */ 292 | 'state': 0, 293 | 'percent': 0, 294 | 'speed': 0, 295 | 'eta': 0, 296 | 'filename': '', 297 | 'success': true, 298 | 'size': 0, 299 | 'name': '', 300 | 'errmsg': '' 301 | }; 302 | ALL.push(entry); 303 | } 304 | 305 | // -------------------------------------------------------------------- 306 | // HTTP server section 307 | 308 | // Handles REST requests 309 | function rest(args) { 310 | // Add new file to download 311 | if (args.url) { 312 | // Check if the given URL is valid 313 | if (!(args.url.indexOf("http") === 0 || args.url.indexOf("ftp") === 0)) { 314 | return {'state': 500}; // Unknown URL 315 | } 316 | 317 | // Check if URL is known 318 | if (!args.force) { // Omit test if forced 319 | for (var i = 0; i < ALL.length; i++) { 320 | if (ALL[i].url == args.url) { 321 | // URL already seen 322 | return {'state': 501}; 323 | } 324 | } 325 | } 326 | 327 | createDownload(args.url); 328 | // Add entry and run it (maybe if there is no other running download) 329 | persist(); 330 | onStart(); 331 | return {'state': 200}; 332 | } 333 | 334 | if (args.cmd && args.cmd == 'list') { 335 | return {'state': 200, 'all': ALL}; 336 | } 337 | 338 | if (args.cmd && args.cmd == 'stop' && args.id) { 339 | var elem = false; 340 | for (var i = ALL.length - 1; i >= 0; i--) { 341 | if (ALL[i].id == args.id) { 342 | elem = ALL[i]; 343 | break; 344 | } 345 | } 346 | 347 | if (elem == undefined || elem == null) { 348 | // FIXME: make me nicer (more detailed response code?) 349 | return {'state': 400}; 350 | } 351 | 352 | // Perform action 353 | if (elem.state == 1) { 354 | // Stop the current download 355 | elem.downloader.stopDownload(); 356 | elem.state = 3; 357 | elem.success = false; 358 | elem.percent = 0; 359 | elem.speed = 0; 360 | elem.eta = 0; 361 | // Reset the current element to let a new download start 362 | CURRENT = false; 363 | 364 | // Let the next download begin (if any) 365 | onStart(); 366 | persist(); 367 | return {'state': 200}; 368 | } else if (elem.state == 0) { 369 | // Simply deactivate a queued download 370 | elem.state = 3; 371 | elem.success = false; 372 | elem.percent = 0; 373 | elem.speed = 0; 374 | elem.eta = 0; 375 | persist(); 376 | return {'state': 200}; 377 | } 378 | } 379 | 380 | if (args.cmd && args.cmd == 'dskstat') { 381 | var used = usedDsk(); 382 | var total = totalDsk(); 383 | return { 384 | 'state': 200, 385 | 'used': used, 386 | 'total': total, 387 | 'percent': (used/total)*100.0, 388 | }; 389 | } 390 | 391 | if (args.cmd && args.cmd == 'versions') { 392 | // Get module versions 393 | var info = process.versions; 394 | var str = ''; 395 | for (var key in info) { 396 | str += key + ' v' + info[key]; 397 | } 398 | 399 | return { 400 | 'state': 200, 401 | 'node': 'NodeJS v' + info.node + ' (V8 v' + info.v8 + ')', 402 | 'app': APP_NAME + ' v' + APP_VERSION 403 | }; 404 | } 405 | 406 | if (args.cmd && args.cmd == 'clear-all') { 407 | if (!rmDir(DOWNLOAD_DIR)) { 408 | return {'state': 501}; 409 | } 410 | ALL = []; 411 | persist(); 412 | return {'state': 200}; 413 | } 414 | 415 | if (args.cmd && args.cmd == 'user-script') { 416 | // Test only for state 417 | if (args.test) { 418 | return {'state': SCRIPT.getState() == 0 ? 500 : 200}; 419 | } 420 | 421 | // Run the script 422 | if (args.run && SCRIPT.getState() == 0) { 423 | return {'state': SCRIPT.run() ? 200 : 400}; 424 | } 425 | 426 | if (args.exit) { 427 | SCRIPT.clear(); 428 | } 429 | 430 | return {'state': 200, 'data': SCRIPT.getOutput(), 'finished': SCRIPT.getState() == 2}; 431 | } 432 | 433 | if (args.cmd && args.cmd == 'halt' && args.code) { 434 | if (args.code == SHUTDOWN_CODE) { 435 | // Trigger shutdown 436 | require('child_process').execSync(SHUTDOWN_CMD); 437 | console.log(' *** Server will shutdown now (' + new Date().toString() + ')'); 438 | return {'state': 200}; 439 | } 440 | return {'state': 500}; 441 | } 442 | 443 | return {'state': 400}; 444 | } 445 | 446 | // Create the HTTP server 447 | http.createServer(function (req, res) { 448 | // Parse the requested URL 449 | var parsedUrl = url.parse(req.url, true); 450 | 451 | // Backend REST API on the path /rest/ 452 | if (parsedUrl.pathname.indexOf('/rest/') == 0) { 453 | // Get the arguments 454 | var args = parsedUrl.query; 455 | 456 | // Leth the REST API do the work ... 457 | var response = rest(args); 458 | 459 | // Return the result 460 | res.writeHead(200, {'Content-Type': 'application/json'}); 461 | res.end(JSON.stringify(response)); 462 | } else { 463 | 464 | // Parse the requested file name to get only the file 465 | // requested 466 | var file = parsedUrl.pathname; 467 | var index = file.lastIndexOf('/'); 468 | if (file.length < 1 && index < 0) { 469 | cry(res, 500, 'Requested filename invalid.'); 470 | return; 471 | } 472 | file = file.substring(index + 1); 473 | 474 | // Empty files are redirected to the index page 475 | if (file.length < 1) { 476 | file = "index.html"; 477 | } 478 | 479 | // Get the file extension (for MIME type) 480 | index = file.lastIndexOf('.'); 481 | var extension = file.substring(index + 1); 482 | 483 | // Check if file exists in the www/ dir and can be served 484 | if (!fs.existsSync('www/' + file)) { 485 | cry(res, 404); 486 | } else { 487 | try { 488 | var data = fs.readFileSync('www/' + file); 489 | res.writeHead(200, { 490 | 'Content-Type': mimeLookup(extension), 491 | 'Content-Length': data.length 492 | }); 493 | res.end(data); 494 | } catch (err) { 495 | cry(res, 500, err); 496 | } 497 | } 498 | } 499 | }).listen(SERVER_PORT, SERVER_HOST); 500 | 501 | // Init the server and print logging to the standard output 502 | console.log(' *** Welcome to ' + APP_NAME + ' (v ' + APP_VERSION + ')'); 503 | console.log(' *** Server running at http://' + SERVER_HOST + ':' + SERVER_PORT + '/'); 504 | console.log(' *** Press CTRL + C to quit') 505 | 506 | // Load all previous and possibly pending downloads after 507 | // everything is set-up 508 | load(); 509 | --------------------------------------------------------------------------------