├── 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 | 
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 | 
50 |
51 |
--------------------------------------------------------------------------------
/www/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Bert
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
No connection to the backend!
20 |
21 |
Reload the page
22 |
23 |
24 |
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 |
--------------------------------------------------------------------------------