luci-mod-system: flash.js: improve storage size detection heuristics
[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                     size = match ? parseInt(match[1], 16) : 0;
166
167                 switch (match ? match[2] : '') {
168                 case 'linux':
169                 case 'firmware':
170                         if (size > wholesize)
171                                 wholesize = size;
172                         break;
173
174                 case 'kernel':
175                 case 'kernel0':
176                         kernsize = size;
177                         break;
178
179                 case 'rootfs':
180                 case 'rootfs0':
181                 case 'ubi':
182                 case 'ubi0':
183                         rootsize = size;
184                         break;
185                 }
186         });
187
188         if (wholesize > 0)
189                 return wholesize;
190         else if (kernsize > 0 && rootsize > kernsize)
191                 return kernsize + rootsize;
192
193         procpart.split(/\n/).forEach(function(ln) {
194                 var match = ln.match(/^\s*\d+\s+\d+\s+(\d+)\s+(\S+)$/);
195                 if (match) {
196                         var size = parseInt(match[1], 10);
197
198                         if (!match[2].match(/\d/) && size > 2048 && wholesize == 0)
199                                 wholesize = size * 1024;
200                 }
201         });
202
203         return wholesize;
204 }
205
206
207 var mapdata = { actions: {}, config: {} };
208
209 return L.view.extend({
210         load: function() {
211                 var max_mtd = 10, max_ubi = 2, max_ubi_vol = 4;
212                 var tasks = [
213                         callFileStat('/lib/upgrade/platform.sh'),
214                         callFileRead('/proc/sys/kernel/hostname'),
215                         callFileRead('/proc/mtd'),
216                         callFileRead('/proc/partitions')
217                 ];
218
219                 for (var i = 0; i < max_mtd; i++)
220                         tasks.push(callFileRead('/sys/devices/virtual/mtd/mtd%d/name'.format(i)));
221
222                 for (var i = 0; i < max_ubi; i++)
223                         for (var j = 0; j < max_ubi_vol; j++)
224                                 tasks.push(callFileRead('/sys/devices/virtual/ubi/ubi%d/ubi%d_%d/name'.format(i, i, j)));
225
226                 return Promise.all(tasks);
227         },
228
229         handleBackup: function(ev) {
230                 var form = E('form', {
231                         method: 'post',
232                         action: '/cgi-bin/cgi-backup',
233                         enctype: 'application/x-www-form-urlencoded'
234                 }, E('input', { type: 'hidden', name: 'sessionid', value: rpc.getSessionID() }));
235
236                 ev.currentTarget.parentNode.appendChild(form);
237
238                 form.submit();
239                 form.parentNode.removeChild(form);
240         },
241
242         handleReset: function(ev) {
243                 if (!confirm(_('Do you really want to erase all settings?')))
244                         return;
245
246                 return callFileExec('/sbin/firstboot', [ '-r', '-y' ]).then(function(res) {
247                         if (res.code != 0)
248                                 return L.ui.addNotification(null, E('p', _('The firstboot command failed with code %d').format(res.code)));
249
250                         L.ui.showModal(_('Erasing...'), [
251                                 E('p', { 'class': 'spinning' }, _('The system is erasing the configuration partition now and will reboot itself when finished.'))
252                         ]);
253
254                         awaitReconnect('192.168.1.1', 'openwrt.lan');
255                 });
256         },
257
258         handleRestore: function(ev) {
259                 return fileUpload(ev.target, '/tmp/backup.tar.gz')
260                         .then(L.bind(function(btn, res) {
261                                 btn.firstChild.data = _('Checking archive…');
262                                 return callFileExec('/bin/tar', [ '-tzf', '/tmp/backup.tar.gz' ]);
263                         }, this, ev.target))
264                         .then(L.bind(function(btn, res) {
265                                 if (res.code != 0) {
266                                         L.ui.addNotification(null, E('p', _('The uploaded backup archive is not readable')));
267                                         return callFileRemove('/tmp/backup.tar.gz');
268                                 }
269
270                                 L.ui.showModal(_('Apply backup?'), [
271                                         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.')),
272                                         E('pre', {}, [ res.stdout ]),
273                                         E('div', { 'class': 'right' }, [
274                                                 E('button', {
275                                                         'class': 'btn',
276                                                         'click': L.ui.createHandlerFn(this, function(ev) {
277                                                                 return callFileRemove('/tmp/backup.tar.gz').finally(L.ui.hideModal);
278                                                         })
279                                                 }, [ _('Cancel') ]), ' ',
280                                                 E('button', {
281                                                         'class': 'btn cbi-button-action important',
282                                                         'click': L.ui.createHandlerFn(this, 'handleRestoreConfirm', btn)
283                                                 }, [ _('Continue') ])
284                                         ])
285                                 ]);
286                         }, this, ev.target))
287                         .finally(L.bind(function(btn, input) {
288                                 btn.firstChild.data = _('Upload archive...');
289                         }, this, ev.target));
290         },
291
292         handleRestoreConfirm: function(btn, ev) {
293                 return callFileExec('/sbin/sysupgrade', [ '--restore-backup', '/tmp/backup.tar.gz' ])
294                         .then(L.bind(function(btn, res) {
295                                 if (res.code != 0) {
296                                         L.ui.addNotification(null, [
297                                                 E('p', _('The restore command failed with code %d').format(res.code)),
298                                                 res.stderr ? E('pre', {}, [ res.stderr ]) : ''
299                                         ]);
300                                         L.raise('Error', 'Unpack failed');
301                                 }
302
303                                 btn.firstChild.data = _('Rebooting…');
304                                 return callFileExec('/sbin/reboot');
305                         }, this, ev.target))
306                         .then(L.bind(function(res) {
307                                 if (res.code != 0) {
308                                         L.ui.addNotification(null, E('p', _('The reboot command failed with code %d').format(res.code)));
309                                         L.raise('Error', 'Reboot failed');
310                                 }
311
312                                 L.ui.showModal(_('Rebooting…'), [
313                                         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.'))
314                                 ]);
315
316                                 awaitReconnect(window.location.host, '192.168.1.1', 'openwrt.lan');
317                         }, this))
318                         .catch(function() { btn.firstChild.data = _('Upload archive...') });
319         },
320
321         handleBlock: function(hostname, ev) {
322                 var mtdblock = L.dom.parent(ev.target, '.cbi-section').querySelector('[data-name="mtdselect"] select').value;
323                 var form = E('form', {
324                         'method': 'post',
325                         'action': '/cgi-bin/cgi-download',
326                         'enctype': 'application/x-www-form-urlencoded'
327                 }, [
328                         E('input', { 'type': 'hidden', 'name': 'sessionid', 'value': rpc.getSessionID() }),
329                         E('input', { 'type': 'hidden', 'name': 'path',      'value': '/dev/mtdblock%d'.format(mtdblock) }),
330                         E('input', { 'type': 'hidden', 'name': 'filename',  'value': '%s.mtd%d.bin'.format(hostname, mtdblock) })
331                 ]);
332
333                 ev.currentTarget.parentNode.appendChild(form);
334
335                 form.submit();
336                 form.parentNode.removeChild(form);
337         },
338
339         handleSysupgrade: function(storage_size, ev) {
340                 return fileUpload(ev.target.firstChild, '/tmp/firmware.bin')
341                         .then(L.bind(function(btn, reply) {
342                                 btn.firstChild.data = _('Checking image…');
343
344                                 L.ui.showModal(_('Checking image…'), [
345                                         E('span', { 'class': 'spinning' }, _('Verifying the uploaded image file.'))
346                                 ]);
347
348                                 return callFileExec('/sbin/sysupgrade', [ '--test', '/tmp/firmware.bin' ])
349                                         .then(function(res) { return [ reply, res ] });
350                         }, this, ev.target))
351                         .then(L.bind(function(btn, res) {
352                                 var keep = document.querySelector('[data-name="keep"] input[type="checkbox"]'),
353                                     force = E('input', { type: 'checkbox' }),
354                                     is_invalid = (res[1].code != 0),
355                                     is_too_big = (storage_size > 0 && res[0].size > storage_size),
356                                     body = [];
357
358                                 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.')));
359                                 body.push(E('ul', {}, [
360                                         res[0].size ? E('li', {}, '%s: %1024.2mB'.format(_('Size'), res[0].size)) : '',
361                                         res[0].checksum ? E('li', {}, '%s: %s'.format(_('MD5'), res[0].checksum)) : '',
362                                         res[0].sha256sum ? E('li', {}, '%s: %s'.format(_('SHA256'), res[0].sha256sum)) : '',
363                                         E('li', {}, keep.checked ? _('Configuration files will be kept') : _('Caution: Configuration files will be erased'))
364                                 ]));
365
366                                 if (is_invalid || is_too_big)
367                                         body.push(E('hr'));
368
369                                 if (is_too_big)
370                                         body.push(E('p', { 'class': 'alert-message' }, [
371                                                 _('It appears that you are trying to flash an image that does not fit into the flash memory, please verify the image file!')
372                                         ]));
373
374                                 if (is_invalid)
375                                         body.push(E('p', { 'class': 'alert-message' }, [
376                                                 res[1].stderr ? res[1].stderr : '',
377                                                 res[1].stderr ? E('br') : '',
378                                                 res[1].stderr ? E('br') : '',
379                                                 _('The uploaded image file does not contain a supported format. Make sure that you choose the generic image format for your platform.')
380                                         ]));
381
382                                 if (is_invalid || is_too_big)
383                                         body.push(E('p', {}, E('label', { 'class': 'btn alert-message danger' }, [
384                                                 force, ' ', _('Force upgrade'),
385                                                 E('br'), E('br'),
386                                                 _('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!')
387                                         ])));
388
389                                 var cntbtn = E('button', {
390                                         'class': 'btn cbi-button-action important',
391                                         'click': L.ui.createHandlerFn(this, 'handleSysupgradeConfirm', btn, keep.checked, force.checked),
392                                         'disabled': (is_invalid || is_too_big) ? true : null
393                                 }, [ _('Continue') ]);
394
395                                 body.push(E('div', { 'class': 'right' }, [
396                                         E('button', {
397                                                 'class': 'btn',
398                                                 'click': L.ui.createHandlerFn(this, function(ev) {
399                                                         return callFileRemove('/tmp/firmware.bin').finally(L.ui.hideModal);
400                                                 })
401                                         }, [ _('Cancel') ]), ' ', cntbtn
402                                 ]));
403
404                                 force.addEventListener('change', function(ev) {
405                                         cntbtn.disabled = !ev.target.checked;
406                                 });
407
408                                 L.ui.showModal(_('Flash image?'), body);
409                         }, this, ev.target))
410                         .finally(L.bind(function(btn) {
411                                 btn.firstChild.data = _('Flash image...');
412                         }, this, ev.target));
413         },
414
415         handleSysupgradeConfirm: function(btn, keep, force, ev) {
416                 btn.firstChild.data = _('Flashing…');
417
418                 L.ui.showModal(_('Flashing…'), [
419                         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.'))
420                 ]);
421
422                 var opts = [];
423
424                 if (!keep)
425                         opts.push('-n');
426
427                 if (force)
428                         opts.push('--force');
429
430                 opts.push('/tmp/firmware.bin');
431
432                 /* Currently the sysupgrade rpc call will not return, hence no promise handling */
433                 callFileExec('/sbin/sysupgrade', opts);
434
435                 awaitReconnect(window.location.host, '192.168.1.1', 'openwrt.lan');
436         },
437
438         handleBackupList: function(ev) {
439                 return callFileExec('/sbin/sysupgrade', [ '--list-backup' ]).then(function(res) {
440                         if (res.code != 0) {
441                                 L.ui.addNotification(null, [
442                                         E('p', _('The sysupgrade command failed with code %d').format(res.code)),
443                                         res.stderr ? E('pre', {}, [ res.stderr ]) : ''
444                                 ]);
445                                 L.raise('Error', 'Sysupgrade failed');
446                         }
447
448                         L.ui.showModal(_('Backup file list'), [
449                                 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.')),
450                                 E('ul', {}, (res.stdout || '').trim().split(/\n/).map(function(ln) { return E('li', {}, ln) })),
451                                 E('div', { 'class': 'right' }, [
452                                         E('button', {
453                                                 'class': 'btn',
454                                                 'click': L.ui.hideModal
455                                         }, [ _('Dismiss') ])
456                                 ])
457                         ], 'cbi-modal');
458                 });
459         },
460
461         handleBackupSave: function(m, ev) {
462                 return m.save(function() {
463                         return callFileWrite('/etc/sysupgrade.conf', mapdata.config.editlist.trim().replace(/\r\n/g, '\n') + '\n');
464                 }).then(function() {
465                         L.ui.addNotification(null, E('p', _('Contents have been saved.')), 'info');
466                 }).catch(function(e) {
467                         L.ui.addNotification(null, E('p', _('Unable to save contents: %s').format(e)));
468                 });
469         },
470
471         render: function(rpc_replies) {
472                 var has_sysupgrade = (rpc_replies[0].type == 'file'),
473                     hostname = rpc_replies[1],
474                     procmtd = rpc_replies[2],
475                     procpart = rpc_replies[3],
476                     has_rootfs_data = rpc_replies.slice(4).filter(function(n) { return n == 'rootfs_data' })[0],
477                     storage_size = findStorageSize(procmtd, procpart),
478                     m, s, o, ss;
479
480                 m = new form.JSONMap(mapdata, _('Flash operations'));
481                 m.tabbed = true;
482
483                 s = m.section(form.NamedSection, 'actions', _('Actions'));
484
485
486                 o = s.option(form.SectionValue, 'actions', form.NamedSection, 'actions', 'actions', _('Backup'), _('Click "Generate archive" to download a tar archive of the current configuration files.'));
487                 ss = o.subsection;
488
489                 o = ss.option(form.Button, 'dl_backup', _('Download backup'));
490                 o.inputstyle = 'action important';
491                 o.inputtitle = _('Generate archive');
492                 o.onclick = this.handleBackup;
493
494
495                 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).'));
496                 ss = o.subsection;
497
498                 if (has_rootfs_data) {
499                         o = ss.option(form.Button, 'reset', _('Reset to defaults'));
500                         o.inputstyle = 'negative important';
501                         o.inputtitle = _('Perform reset');
502                         o.onclick = this.handleReset;
503                 }
504
505                 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.'));
506                 o.inputstyle = 'action important';
507                 o.inputtitle = _('Upload archive...');
508                 o.onclick = L.bind(this.handleRestore, this);
509
510
511                 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! )'));
512                 ss = o.subsection;
513
514                 o = ss.option(form.ListValue, 'mtdselect', _('Choose mtdblock'));
515                 procmtd.split(/\n/).forEach(function(ln) {
516                         var match = ln.match(/^mtd(\d+): .+ "(.+?)"$/);
517                         if (match)
518                                 o.value(match[1], match[2]);
519                 });
520
521                 o = ss.option(form.Button, 'mtddownload', _('Download mtdblock'));
522                 o.inputstyle = 'action important';
523                 o.inputtitle = _('Save mtdblock');
524                 o.onclick = L.bind(this.handleBlock, this, hostname);
525
526
527                 o = s.option(form.SectionValue, 'actions', form.NamedSection, 'actions', 'actions', _('Flash new firmware image'),
528                         has_sysupgrade
529                                 ? _('Upload a sysupgrade-compatible image here to replace the running firmware. Check "Keep settings" to retain the current configuration (requires a compatible firmware image).')
530                                 : _('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.'));
531
532                 ss = o.subsection;
533
534                 if (has_sysupgrade) {
535                         o = ss.option(form.Flag, 'keep', _('Keep settings'));
536                         o.default = o.enabled;
537
538                         o = ss.option(form.Button, 'sysupgrade', _('Image'));
539                         o.inputstyle = 'action important';
540                         o.inputtitle = _('Flash image...');
541                         o.onclick = L.bind(this.handleSysupgrade, this, storage_size);
542                 }
543
544
545                 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.'));
546                 s.render = L.bind(function(view /*, ... */) {
547                         return form.NamedSection.prototype.render.apply(this, this.varargs(arguments, 1))
548                                 .then(L.bind(function(node) {
549                                         node.appendChild(E('div', { 'class': 'cbi-page-actions' }, [
550                                                 E('button', {
551                                                         'class': 'cbi-button cbi-button-save',
552                                                         'click': L.ui.createHandlerFn(view, 'handleBackupSave', this.map)
553                                                 }, [ _('Save') ])
554                                         ]));
555
556                                         return node;
557                                 }, this));
558                 }, s, this);
559
560                 o = s.option(form.Button, 'showlist', _('Show current backup file list'));
561                 o.inputstyle = 'action';
562                 o.inputtitle = _('Open list...');
563                 o.onclick = L.bind(this.handleBackupList, this);
564
565                 o = s.option(form.TextValue, 'editlist');
566                 o.forcewrite = true;
567                 o.rows = 30;
568                 o.load = function(section_id) {
569                         return callFileRead('/etc/sysupgrade.conf');
570                 };
571
572
573                 return m.render();
574         },
575
576         handleSaveApply: null,
577         handleSave: null,
578         handleReset: null
579 });