Merge pull request #3083 from onjen/captive-optional-args
[oweals/luci.git] / modules / luci-mod-system / htdocs / luci-static / resources / view / system / flash.js
1 'use strict';
2 'require form';
3 'require rpc';
4
5 var callFileStat, callFileRead, callFileWrite, callFileExec, callFileRemove;
6
7 callFileStat = rpc.declare({
8         object: 'file',
9         method: 'stat',
10         params: [ 'path' ],
11         expect: { '': {} }
12 });
13
14 callFileRead = rpc.declare({
15         object: 'file',
16         method: 'read',
17         params: [ 'path' ],
18         expect: { data: '' },
19         filter: function(s) { return (s || '').trim() }
20 });
21
22 callFileWrite = rpc.declare({
23         object: 'file',
24         method: 'write',
25         params: [ 'path', 'data' ]
26 });
27
28 callFileExec = rpc.declare({
29         object: 'file',
30         method: 'exec',
31         params: [ 'command', 'params' ],
32         expect: { '': { code: -1 } }
33 });
34
35 callFileRemove = rpc.declare({
36         object: 'file',
37         method: 'remove',
38         params: [ 'path' ]
39 });
40
41 function pingDevice(proto, ipaddr) {
42         var target = '%s://%s%s?%s'.format(proto || 'http', ipaddr || window.location.host, L.resource('icons/loading.gif'), Math.random());
43
44         return new Promise(function(resolveFn, rejectFn) {
45                 var img = new Image();
46
47                 img.onload = resolveFn;
48                 img.onerror = rejectFn;
49
50                 window.setTimeout(rejectFn, 1000);
51
52                 img.src = target;
53         });
54 }
55
56 function awaitReconnect(/* ... */) {
57         var ipaddrs = arguments.length ? arguments : [ window.location.host ];
58
59         window.setTimeout(function() {
60                 L.Poll.add(function() {
61                         var tasks = [], reachable = false;
62
63                         for (var i = 0; i < 2; i++)
64                                 for (var j = 0; j < ipaddrs.length; j++)
65                                         tasks.push(pingDevice(i ? 'https' : 'http', ipaddrs[j])
66                                                 .then(function(ev) { reachable = ev.target.src.replace(/^(https?:\/\/[^\/]+).*$/, '$1/') }, function() {}));
67
68                         return Promise.all(tasks).then(function() {
69                                 if (reachable) {
70                                         L.Poll.stop();
71                                         window.location = reachable;
72                                 }
73                         });
74                 })
75         }, 5000);
76 }
77
78 function fileUpload(node, path) {
79         return new Promise(function(resolveFn, rejectFn) {
80                 L.ui.showModal(_('Uploading file…'), [
81                         E('p', _('Please select the file to upload.')),
82                         E('div', { 'style': 'display:flex' }, [
83                                 E('div', { 'class': 'left', 'style': 'flex:1' }, [
84                                         E('input', {
85                                                 type: 'file',
86                                                 style: 'display:none',
87                                                 change: function(ev) {
88                                                         L.dom.parent(ev.target, '.modal').querySelector('.cbi-button-action.important').disabled = false;
89                                                 }
90                                         }),
91                                         E('button', {
92                                                 'class': 'btn',
93                                                 'click': function(ev) {
94                                                         ev.target.previousElementSibling.click();
95                                                 }
96                                         }, [ _('Browse…') ])
97                                 ]),
98                                 E('div', { 'class': 'right', 'style': 'flex:1' }, [
99                                         E('button', {
100                                                 'class': 'btn',
101                                                 'click': function() {
102                                                         L.ui.hideModal();
103                                                         rejectFn(new Error('Upload has been cancelled'));
104                                                 }
105                                         }, [ _('Cancel') ]),
106                                         ' ',
107                                         E('button', {
108                                                 'class': 'btn cbi-button-action important',
109                                                 'disabled': true,
110                                                 'click': function(ev) {
111                                                         var input = L.dom.parent(ev.target, '.modal').querySelector('input[type="file"]');
112
113                                                         if (!input.files[0])
114                                                                 return;
115
116                                                         var progress = E('div', { 'class': 'cbi-progressbar', 'title': '0%' }, E('div', { 'style': 'width:0' }));
117
118                                                         L.ui.showModal(_('Uploading file…'), [ progress ]);
119
120                                                         var data = new FormData();
121
122                                                         data.append('sessionid', rpc.getSessionID());
123                                                         data.append('filename', path);
124                                                         data.append('filedata', input.files[0]);
125
126                                                         L.Request.post('/cgi-bin/cgi-upload', data, {
127                                                                 timeout: 0,
128                                                                 progress: function(pev) {
129                                                                         var percent = (pev.loaded / pev.total) * 100;
130
131                                                                         node.data = '%.2f%%'.format(percent);
132
133                                                                         progress.setAttribute('title', '%.2f%%'.format(percent));
134                                                                         progress.firstElementChild.style.width = '%.2f%%'.format(percent);
135                                                                 }
136                                                         }).then(function(res) {
137                                                                 var reply = res.json();
138
139                                                                 L.ui.hideModal();
140
141                                                                 if (L.isObject(reply) && reply.failure) {
142                                                                         L.ui.addNotification(null, E('p', _('Upload request failed: %s').format(reply.message)));
143                                                                         rejectFn(new Error(reply.failure));
144                                                                 }
145                                                                 else {
146                                                                         resolveFn(reply);
147                                                                 }
148                                                         }, function(err) {
149                                                                 L.ui.hideModal();
150                                                                 rejectFn(err);
151                                                         });
152                                                 }
153                                         }, [ _('Upload') ])
154                                 ])
155                         ])
156                 ]);
157         });
158 }
159
160 function findStorageSize(procmtd, procpart) {
161         var kernsize = 0, rootsize = 0, wholesize = 0;
162
163         procmtd.split(/\n/).forEach(function(ln) {
164                 var match = ln.match(/^mtd\d+: ([0-9a-f]+) [0-9a-f]+ "(.+)"$/);
165
166                 switch (match ? match[2] : '') {
167                 case 'linux':
168                 case 'firmware':
169                         wholesize = parseInt(match[1], 16);
170                         break;
171
172                 case 'kernel':
173                 case 'kernel0':
174                         kernsize = parseInt(match[1], 16);
175                         break;
176
177                 case 'rootfs':
178                 case 'rootfs0':
179                 case 'ubi':
180                 case 'ubi0':
181                         rootsize = parseInt(match[1], 16);
182                         break;
183                 }
184         });
185
186         if (wholesize > 0)
187                 return wholesize;
188         else if (kernsize > 0 && rootsize > kernsize)
189                 return kernsize + rootsize;
190
191         procpart.split(/\n/).forEach(function(ln) {
192                 var match = ln.match(/^\s*\d+\s+\d+\s+(\d+)\s+(\S+)$/);
193                 if (match) {
194                         var size = parseInt(match[1], 10);
195
196                         if (!match[2].match(/\d/) && size > 2048 && wholesize == 0)
197                                 wholesize = size * 1024;
198                 }
199         });
200
201         return wholesize;
202 }
203
204
205 var mapdata = { actions: {}, config: {} };
206
207 return L.view.extend({
208         load: function() {
209                 var max_mtd = 10, max_ubi = 2, max_ubi_vol = 4;
210                 var tasks = [
211                         callFileStat('/lib/upgrade/platform.sh'),
212                         callFileRead('/proc/sys/kernel/hostname'),
213                         callFileRead('/proc/mtd'),
214                         callFileRead('/proc/partitions')
215                 ];
216
217                 for (var i = 0; i < max_mtd; i++)
218                         tasks.push(callFileRead('/sys/devices/virtual/mtd/mtd%d/name'.format(i)));
219
220                 for (var i = 0; i < max_ubi; i++)
221                         for (var j = 0; j < max_ubi_vol; j++)
222                                 tasks.push(callFileRead('/sys/devices/virtual/ubi/ubi%d/ubi%d_%d/name'.format(i, i, j)));
223
224                 return Promise.all(tasks);
225         },
226
227         handleBackup: function(ev) {
228                 var form = E('form', {
229                         method: 'post',
230                         action: '/cgi-bin/cgi-backup',
231                         enctype: 'application/x-www-form-urlencoded'
232                 }, E('input', { type: 'hidden', name: 'sessionid', value: rpc.getSessionID() }));
233
234                 ev.currentTarget.parentNode.appendChild(form);
235
236                 form.submit();
237                 form.parentNode.removeChild(form);
238         },
239
240         handleReset: function(ev) {
241                 if (!confirm(_('Do you really want to erase all settings?')))
242                         return;
243
244                 return callFileExec('/sbin/firstboot', [ '-r', '-y' ]).then(function(res) {
245                         if (res.code != 0)
246                                 return L.ui.addNotification(null, E('p', _('The firstboot command failed with code %d').format(res.code)));
247
248                         L.ui.showModal(_('Erasing...'), [
249                                 E('p', { 'class': 'spinning' }, _('The system is erasing the configuration partition now and will reboot itself when finished.'))
250                         ]);
251
252                         awaitReconnect('192.168.1.1', 'openwrt.lan');
253                 });
254         },
255
256         handleRestore: function(ev) {
257                 return fileUpload(ev.target, '/tmp/backup.tar.gz')
258                         .then(L.bind(function(btn, res) {
259                                 btn.firstChild.data = _('Checking archive…');
260                                 return callFileExec('/bin/tar', [ '-tzf', '/tmp/backup.tar.gz' ]);
261                         }, this, ev.target))
262                         .then(L.bind(function(btn, res) {
263                                 if (res.code != 0) {
264                                         L.ui.addNotification(null, E('p', _('The uploaded backup archive is not readable')));
265                                         return callFileRemove('/tmp/backup.tar.gz');
266                                 }
267
268                                 L.ui.showModal(_('Apply backup?'), [
269                                         E('p', _('The uploaded backup archive appears to be valid and contains the files listed below. Press "Continue" to restore the backup and reboot, or "Cancel" to abort the operation.')),
270                                         E('pre', {}, [ res.stdout ]),
271                                         E('div', { 'class': 'right' }, [
272                                                 E('button', {
273                                                         'class': 'btn',
274                                                         'click': L.ui.createHandlerFn(this, function(ev) {
275                                                                 return callFileRemove('/tmp/backup.tar.gz').finally(L.ui.hideModal);
276                                                         })
277                                                 }, [ _('Cancel') ]), ' ',
278                                                 E('button', {
279                                                         'class': 'btn cbi-button-action important',
280                                                         'click': L.ui.createHandlerFn(this, 'handleRestoreConfirm', btn)
281                                                 }, [ _('Continue') ])
282                                         ])
283                                 ]);
284                         }, this, ev.target))
285                         .finally(L.bind(function(btn, input) {
286                                 btn.firstChild.data = _('Upload archive...');
287                         }, this, ev.target));
288         },
289
290         handleRestoreConfirm: function(btn, ev) {
291                 return callFileExec('/sbin/sysupgrade', [ '--restore-backup', '/tmp/backup.tar.gz' ])
292                         .then(L.bind(function(btn, res) {
293                                 if (res.code != 0) {
294                                         L.ui.addNotification(null, [
295                                                 E('p', _('The restore command failed with code %d').format(res.code)),
296                                                 res.stderr ? E('pre', {}, [ res.stderr ]) : ''
297                                         ]);
298                                         L.raise('Error', 'Unpack failed');
299                                 }
300
301                                 btn.firstChild.data = _('Rebooting…');
302                                 return callFileExec('/sbin/reboot');
303                         }, this, ev.target))
304                         .then(L.bind(function(res) {
305                                 if (res.code != 0) {
306                                         L.ui.addNotification(null, E('p', _('The reboot command failed with code %d').format(res.code)));
307                                         L.raise('Error', 'Reboot failed');
308                                 }
309
310                                 L.ui.showModal(_('Rebooting…'), [
311                                         E('p', { 'class': 'spinning' }, _('The system is rebooting now. If the restored configuration changed the current LAN IP address, you might need to reconnect manually.'))
312                                 ]);
313
314                                 awaitReconnect(window.location.host, '192.168.1.1', 'openwrt.lan');
315                         }, this))
316                         .catch(function() { btn.firstChild.data = _('Upload archive...') });
317         },
318
319         handleBlock: function(hostname, ev) {
320                 var mtdblock = L.dom.parent(ev.target, '.cbi-section').querySelector('[data-name="mtdselect"] select').value;
321                 var form = E('form', {
322                         'method': 'post',
323                         'action': '/cgi-bin/cgi-download',
324                         'enctype': 'application/x-www-form-urlencoded'
325                 }, [
326                         E('input', { 'type': 'hidden', 'name': 'sessionid', 'value': rpc.getSessionID() }),
327                         E('input', { 'type': 'hidden', 'name': 'path',      'value': '/dev/mtdblock%d'.format(mtdblock) }),
328                         E('input', { 'type': 'hidden', 'name': 'filename',  'value': '%s.mtd%d.bin'.format(hostname, mtdblock) })
329                 ]);
330
331                 ev.currentTarget.parentNode.appendChild(form);
332
333                 form.submit();
334                 form.parentNode.removeChild(form);
335         },
336
337         handleSysupgrade: function(storage_size, ev) {
338                 return fileUpload(ev.target.firstChild, '/tmp/firmware.bin')
339                         .then(L.bind(function(btn, reply) {
340                                 btn.firstChild.data = _('Checking image…');
341
342                                 L.ui.showModal(_('Checking image…'), [
343                                         E('span', { 'class': 'spinning' }, _('Verifying the uploaded image file.'))
344                                 ]);
345
346                                 return callFileExec('/sbin/sysupgrade', [ '--test', '/tmp/firmware.bin' ])
347                                         .then(function(res) { return [ reply, res ] });
348                         }, this, ev.target))
349                         .then(L.bind(function(btn, res) {
350                                 var keep = document.querySelector('[data-name="keep"] input[type="checkbox"]'),
351                                     force = E('input', { type: 'checkbox' }),
352                                     is_invalid = (res[1].code != 0),
353                                     is_too_big = (storage_size > 0 && res[0].size > storage_size),
354                                     body = [];
355
356                                 body.push(E('p', _('The flash image was uploaded. Below is the checksum and file size listed, compare them with the original file to ensure data integrity. <br /> Click "Proceed" below to start the flash procedure.')));
357                                 body.push(E('ul', {}, [
358                                         res[0].size ? E('li', {}, '%s: %1024.2mB'.format(_('Size'), res[0].size)) : '',
359                                         res[0].checksum ? E('li', {}, '%s: %s'.format(_('MD5'), res[0].checksum)) : '',
360                                         res[0].sha256sum ? E('li', {}, '%s: %s'.format(_('SHA256'), res[0].sha256sum)) : '',
361                                         E('li', {}, keep.checked ? _('Configuration files will be kept') : _('Caution: Configuration files will be erased'))
362                                 ]));
363
364                                 if (is_invalid || is_too_big)
365                                         body.push(E('hr'));
366
367                                 if (is_too_big)
368                                         body.push(E('p', { 'class': 'alert-message' }, [
369                                                 _('It appears that you are trying to flash an image that does not fit into the flash memory, please verify the image file!')
370                                         ]));
371
372                                 if (is_invalid)
373                                         body.push(E('p', { 'class': 'alert-message' }, [
374                                                 res[1].stderr ? res[1].stderr : '',
375                                                 res[1].stderr ? E('br') : '',
376                                                 res[1].stderr ? E('br') : '',
377                                                 _('The uploaded image file does not contain a supported format. Make sure that you choose the generic image format for your platform.')
378                                         ]));
379
380                                 if (is_invalid || is_too_big)
381                                         body.push(E('p', {}, E('label', { 'class': 'btn alert-message danger' }, [
382                                                 force, ' ', _('Force upgrade'),
383                                                 E('br'), E('br'),
384                                                 _('Select \'Force upgrade\' to flash the image even if the image format check fails. Use only if you are sure that the firmware is correct and meant for your device!')
385                                         ])));
386
387                                 var cntbtn = E('button', {
388                                         'class': 'btn cbi-button-action important',
389                                         'click': L.ui.createHandlerFn(this, 'handleSysupgradeConfirm', btn, keep.checked, force.checked),
390                                         'disabled': (is_invalid || is_too_big) ? true : null
391                                 }, [ _('Continue') ]);
392
393                                 body.push(E('div', { 'class': 'right' }, [
394                                         E('button', {
395                                                 'class': 'btn',
396                                                 'click': L.ui.createHandlerFn(this, function(ev) {
397                                                         return callFileRemove('/tmp/firmware.bin').finally(L.ui.hideModal);
398                                                 })
399                                         }, [ _('Cancel') ]), ' ', cntbtn
400                                 ]));
401
402                                 force.addEventListener('change', function(ev) {
403                                         cntbtn.disabled = !ev.target.checked;
404                                 });
405
406                                 L.ui.showModal(_('Flash image?'), body);
407                         }, this, ev.target))
408                         .finally(L.bind(function(btn) {
409                                 btn.firstChild.data = _('Flash image...');
410                         }, this, ev.target));
411         },
412
413         handleSysupgradeConfirm: function(btn, keep, force, ev) {
414                 btn.firstChild.data = _('Flashing…');
415
416                 L.ui.showModal(_('Flashing…'), [
417                         E('p', { 'class': 'spinning' }, _('The system is flashing now.<br /> DO NOT POWER OFF THE DEVICE!<br /> Wait a few minutes before you try to reconnect. It might be necessary to renew the address of your computer to reach the device again, depending on your settings.'))
418                 ]);
419
420                 var opts = [];
421
422                 if (!keep)
423                         opts.push('-n');
424
425                 if (force)
426                         opts.push('--force');
427
428                 opts.push('/tmp/firmware.bin');
429
430                 /* Currently the sysupgrade rpc call will not return, hence no promise handling */
431                 callFileExec('/sbin/sysupgrade', opts);
432
433                 awaitReconnect(window.location.host, '192.168.1.1', 'openwrt.lan');
434         },
435
436         handleBackupList: function(ev) {
437                 return callFileExec('/sbin/sysupgrade', [ '--list-backup' ]).then(function(res) {
438                         if (res.code != 0) {
439                                 L.ui.addNotification(null, [
440                                         E('p', _('The sysupgrade command failed with code %d').format(res.code)),
441                                         res.stderr ? E('pre', {}, [ res.stderr ]) : ''
442                                 ]);
443                                 L.raise('Error', 'Sysupgrade failed');
444                         }
445
446                         L.ui.showModal(_('Backup file list'), [
447                                 E('p', _('Below is the determined list of files to backup. It consists of changed configuration files marked by opkg, essential base files and the user defined backup patterns.')),
448                                 E('ul', {}, (res.stdout || '').trim().split(/\n/).map(function(ln) { return E('li', {}, ln) })),
449                                 E('div', { 'class': 'right' }, [
450                                         E('button', {
451                                                 'class': 'btn',
452                                                 'click': L.ui.hideModal
453                                         }, [ _('Dismiss') ])
454                                 ])
455                         ], 'cbi-modal');
456                 });
457         },
458
459         handleBackupSave: function(m, ev) {
460                 return m.save(function() {
461                         return callFileWrite('/etc/sysupgrade.conf', mapdata.config.editlist.trim().replace(/\r\n/g, '\n') + '\n');
462                 }).then(function() {
463                         L.ui.addNotification(null, E('p', _('Contents have been saved.')), 'info');
464                 }).catch(function(e) {
465                         L.ui.addNotification(null, E('p', _('Unable to save contents: %s').format(e)));
466                 });
467         },
468
469         render: function(rpc_replies) {
470                 var has_sysupgrade = (rpc_replies[0].type == 'file'),
471                     hostname = rpc_replies[1],
472                     procmtd = rpc_replies[2],
473                     procpart = rpc_replies[3],
474                     has_rootfs_data = rpc_replies.slice(4).filter(function(n) { return n == 'rootfs_data' })[0],
475                     storage_size = findStorageSize(procmtd, procpart),
476                     m, s, o, ss;
477
478                 m = new form.JSONMap(mapdata, _('Flash operations'));
479                 m.tabbed = true;
480
481                 s = m.section(form.NamedSection, 'actions', _('Actions'));
482
483
484                 o = s.option(form.SectionValue, 'actions', form.NamedSection, 'actions', 'actions', _('Backup'), _('Click "Generate archive" to download a tar archive of the current configuration files.'));
485                 ss = o.subsection;
486
487                 o = ss.option(form.Button, 'dl_backup', _('Download backup'));
488                 o.inputstyle = 'action important';
489                 o.inputtitle = _('Generate archive');
490                 o.onclick = this.handleBackup;
491
492
493                 o = s.option(form.SectionValue, 'actions', form.NamedSection, 'actions', 'actions', _('Restore'), _('To restore configuration files, you can upload a previously generated backup archive here. To reset the firmware to its initial state, click "Perform reset" (only possible with squashfs images).'));
494                 ss = o.subsection;
495
496                 if (has_rootfs_data) {
497                         o = ss.option(form.Button, 'reset', _('Reset to defaults'));
498                         o.inputstyle = 'negative important';
499                         o.inputtitle = _('Perform reset');
500                         o.onclick = this.handleReset;
501                 }
502
503                 o = ss.option(form.Button, 'restore', _('Restore backup'), _('Custom files (certificates, scripts) may remain on the system. To prevent this, perform a factory-reset first.'));
504                 o.inputstyle = 'action important';
505                 o.inputtitle = _('Upload archive...');
506                 o.onclick = L.bind(this.handleRestore, this);
507
508
509                 o = s.option(form.SectionValue, 'actions', form.NamedSection, 'actions', 'actions', _('Save mtdblock contents'), _('Click "Save mtdblock" to download specified mtdblock file. (NOTE: THIS FEATURE IS FOR PROFESSIONALS! )'));
510                 ss = o.subsection;
511
512                 o = ss.option(form.ListValue, 'mtdselect', _('Choose mtdblock'));
513                 procmtd.split(/\n/).forEach(function(ln) {
514                         var match = ln.match(/^mtd(\d+): .+ "(.+?)"$/);
515                         if (match)
516                                 o.value(match[1], match[2]);
517                 });
518
519                 o = ss.option(form.Button, 'mtddownload', _('Download mtdblock'));
520                 o.inputstyle = 'action important';
521                 o.inputtitle = _('Save mtdblock');
522                 o.onclick = L.bind(this.handleBlock, this, hostname);
523
524
525                 o = s.option(form.SectionValue, 'actions', form.NamedSection, 'actions', 'actions', _('Flash new firmware image'),
526                         has_sysupgrade
527                                 ? _('Upload a sysupgrade-compatible image here to replace the running firmware. Check "Keep settings" to retain the current configuration (requires a compatible firmware image).')
528                                 : _('Sorry, there is no sysupgrade support present; a new firmware image must be flashed manually. Please refer to the wiki for device specific install instructions.'));
529
530                 ss = o.subsection;
531
532                 if (has_sysupgrade) {
533                         o = ss.option(form.Flag, 'keep', _('Keep settings'));
534                         o.default = o.enabled;
535
536                         o = ss.option(form.Button, 'sysupgrade', _('Image'));
537                         o.inputstyle = 'action important';
538                         o.inputtitle = _('Flash image...');
539                         o.onclick = L.bind(this.handleSysupgrade, this, storage_size);
540                 }
541
542
543                 s = m.section(form.NamedSection, 'config', 'config', _('Configuration'), _('This is a list of shell glob patterns for matching files and directories to include during sysupgrade. Modified files in /etc/config/ and certain other configurations are automatically preserved.'));
544                 s.render = L.bind(function(view /*, ... */) {
545                         return form.NamedSection.prototype.render.apply(this, this.varargs(arguments, 1))
546                                 .then(L.bind(function(node) {
547                                         node.appendChild(E('div', { 'class': 'cbi-page-actions' }, [
548                                                 E('button', {
549                                                         'class': 'cbi-button cbi-button-save',
550                                                         'click': L.ui.createHandlerFn(view, 'handleBackupSave', this.map)
551                                                 }, [ _('Save') ])
552                                         ]));
553
554                                         return node;
555                                 }, this));
556                 }, s, this);
557
558                 o = s.option(form.Button, 'showlist', _('Show current backup file list'));
559                 o.inputstyle = 'action';
560                 o.inputtitle = _('Open list...');
561                 o.onclick = L.bind(this.handleBackupList, this);
562
563                 o = s.option(form.TextValue, 'editlist');
564                 o.forcewrite = true;
565                 o.rows = 30;
566                 o.load = function(section_id) {
567                         return callFileRead('/etc/sysupgrade.conf');
568                 };
569
570
571                 return m.render();
572         },
573
574         handleSaveApply: null,
575         handleSave: null,
576         handleReset: null
577 });