diff --git a/.github/workflows/build-wgt.yml b/.github/workflows/build-wgt.yml index 47e9552..812280d 100644 --- a/.github/workflows/build-wgt.yml +++ b/.github/workflows/build-wgt.yml @@ -14,7 +14,7 @@ jobs: node-version: '20' - name: Syntax-check the service and UI run: | - for f in tizen-app/service/sdb.js tizen-app/service/zip.js tizen-app/service/index.js tizen-app/service/signer.js tizen-app/service/catalog.js tizen-app/service/catalog-key.js tizen-app/service/compat.js tizen-app/js/app.js test/fake-sdbd.js test/run.js test/sign.test.js test/config.test.js test/sdb-errors.test.js; do + for f in tizen-app/service/sdb.js tizen-app/service/zip.js tizen-app/service/index.js tizen-app/service/signer.js tizen-app/service/catalog.js tizen-app/service/catalog-key.js tizen-app/service/compat.js tizen-app/service/manifest.js tizen-app/js/app.js test/fake-sdbd.js test/run.js test/sign.test.js test/config.test.js test/sdb-errors.test.js test/manifest.test.js; do node --check "$f" done - name: config.xml ids (package id is 10 alphanumerics) @@ -31,6 +31,8 @@ jobs: run: node test/catalog.test.js - name: sdb connect errors (refused, dropped without a reply) run: node test/sdb-errors.test.js + - name: Package manifest ids (.wgt config.xml, .tpk tizen-manifest.xml) + run: node test/manifest.test.js # The TV's own Node: v4.4.3 on a 2019 Tizen 5.0 set (#881). Buffer.from, Buffer.alloc, # crypto.sign and friends are newer than that, and only fail there; run every test on it too. - uses: actions/setup-node@v4 @@ -46,6 +48,7 @@ jobs: node test/bundle.test.js node test/catalog.test.js node test/sdb-errors.test.js + node test/manifest.test.js - name: Build the .wgt run: scripts/build-wgt.sh - uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ea7c313..b3578aa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,7 @@ jobs: node test/bundle.test.js node test/catalog.test.js node test/sdb-errors.test.js + node test/manifest.test.js - name: Version in config.xml must match the tag run: | tag="${GITHUB_REF_NAME#v}" diff --git a/README.md b/README.md index 8868f96..c59b6fe 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,20 @@ an API key). The TV cannot do that yet, so those are shown greyed with a "Phone install from Apps2Samsung on a phone or PC instead. The same goes for apps that need root, and for any file the catalog has no hash for. +## Your own app (.wgt or .tpk) + +Open `http://:8077/upload` on a phone or PC in the same network (Settings shows it as a QR +code), pick a `.wgt` or `.tpk` and send it. The TV keeps the file and TV-Store asks on screen: +who sent it, what the package calls itself, its hash, then **Install on this TV**, **Not now** or +**Discard**. Nothing installs until OK is pressed on the remote. The file is signed with your +certificate and installed like a catalog app, without the catalog's hash check (the file is yours). + +One file waits at a time (a new one replaces it), at most 400 MB, and it must be a Tizen package +(a zip with `config.xml` or `tizen-manifest.xml` at its root). The upload page is open to the +network like the log; the install is not: only the TV-Store screen can start it, with a key the +service writes into the app's private data folder at every start, where no other app on the TV +can read it. The TV must be armed first. + ## Settings Press **▲** at the top of the list to reach **Settings**. The switches are remembered on the TV: @@ -109,6 +123,7 @@ The header counts what the switches hide ("12 apps · 3 hidden"). - `tizen-app/service/signer.js`: the package signer - `tizen-app/service/catalog.js` + `catalog-key.js`: the signed catalog check and its public key - `tizen-app/service/compat.js`: buffers that work on the TV's Node 4.4.3 +- `tizen-app/service/manifest.js`: the ids an uploaded package carries in its own manifest - `tizen-app/js/app.js`: the UI, and `window.A2SStore`, which Apps2Samsung calls to arm the TV - `tizen-app/js/qrcode.js`: QR codes for the log address and the Ko-fi link ([qrcode-generator](https://github.com/kazuhikoarase/qrcode-generator) 1.4.4, MIT) - `test/`: a fake `sdbd` plus signer and transport tests @@ -134,6 +149,8 @@ node test/sign.test.js # the signer, with a certificate chain node --expose-gc test/stream-sign.test.js # the file-to-file signer on a 24 MB package node test/bundle.test.js # the bundled service starts however the platform loads it node test/catalog.test.js # the signed catalog: signature, age, hashes +node test/manifest.test.js # the ids of a .wgt / .tpk from its manifest +node test/upload.test.js # send a file to the service, see it wait, token gate, discard ``` Install the `.wgt` with Apps2Samsung like any package. A `vX.Y.Z` tag (matching the version in diff --git a/test/manifest.test.js b/test/manifest.test.js new file mode 100644 index 0000000..a8a59a4 --- /dev/null +++ b/test/manifest.test.js @@ -0,0 +1,39 @@ +/* manifest.js reads the ids of a .wgt (config.xml) and a .tpk (tizen-manifest.xml) from the + * package on disk, and says what is wrong with anything else. */ +'use strict'; +var fs = require('fs'), os = require('os'), path = require('path'); +var zip = require('../tizen-app/service/zip.js'); +var manifest = require('../tizen-app/service/manifest.js'); +var Buf = require('../tizen-app/service/compat.js'); + +function fail(m) { console.error('FAIL: ' + m); process.exit(1); } +var dir = path.join(os.tmpdir(), 'a2s-manifest-' + process.pid + '-' + Date.now()); fs.mkdirSync(dir); // no mkdtempSync on the TV's Node 4 +function pkg(name, entries) { var f = path.join(dir, name); fs.writeFileSync(f, zip.write(entries)); return f; } + +var wgt = pkg('a.wgt', [ + { name: 'index.html', data: Buf.from('') }, + { name: 'config.xml', data: Buf.from('Nuvio Legacy\n ') } +]); +var tpk = pkg('b.tpk', [ + { name: 'bin/app', data: Buf.from('x') }, + { name: 'tizen-manifest.xml', data: Buf.from('') } +]); +var noManifest = pkg('c.wgt', [{ name: 'index.html', data: Buf.from('') }]); +var notZip = path.join(dir, 'd.wgt'); fs.writeFileSync(notZip, 'hello'); + +manifest.read(wgt, function (err, ids) { + if (err) fail('wgt: ' + err.message); + if (ids.kind !== 'wgt' || ids.appId !== 'NuvioTV002.Nuvio' || ids.packageId !== 'NuvioTV002' || ids.version !== '1.2.3' || ids.name !== 'Nuvio Legacy') fail('wgt ids ' + JSON.stringify(ids)); + manifest.read(tpk, function (err, ids) { + if (err) fail('tpk: ' + err.message); + if (ids.kind !== 'tpk' || ids.appId !== 'org.apps2samsung.overscan.app' || ids.packageId !== 'org.apps2samsung.overscan' || ids.version !== '0.2.0' || ids.name !== 'Overscan') fail('tpk ids ' + JSON.stringify(ids)); + manifest.read(noManifest, function (err) { + if (!err || err.message.indexOf('no config.xml') < 0) fail('a package without a manifest must be refused: ' + (err && err.message)); + manifest.read(notZip, function (err) { + if (!err || err.message.indexOf('not a Tizen package') < 0) fail('a non-zip must be refused: ' + (err && err.message)); + try { fs.readdirSync(dir).forEach(function (f) { fs.unlinkSync(path.join(dir, f)); }); fs.rmdirSync(dir); } catch (e) {} + console.log('PASS: manifest ids read from a .wgt and a .tpk; no manifest and not-a-zip refused'); + }); + }); + }); +}); diff --git a/test/upload.test.js b/test/upload.test.js new file mode 100644 index 0000000..9bea0cf --- /dev/null +++ b/test/upload.test.js @@ -0,0 +1,75 @@ +/* The upload path (#30) end to end against the real service: PUT /upload stores a Tizen package + * and names it under /uploads; junk is refused; POST /install { upload } without the token is + * refused and with the token reaches the signer (which has no certificate here); discard clears it. + * The service is started as a child, with its data folder pointed at a temp dir through TMPDIR (and HOME for the certificate). */ +'use strict'; +var fs = require('fs'), os = require('os'), path = require('path'), http = require('http'), cp = require('child_process'); +var zip = require('../tizen-app/service/zip.js'); +var Buf = require('../tizen-app/service/compat.js'); + +function fail(m) { console.error('FAIL: ' + m); process.exit(1); } +var dir = path.join(os.tmpdir(), 'a2s-upload-' + process.pid + '-' + Date.now()); fs.mkdirSync(dir); +var wgt = zip.write([{ name: 'index.html', data: Buf.from('') }, + { name: 'config.xml', data: Buf.from('Probe') }]); +var child = cp.spawn(process.execPath, [path.join(__dirname, '..', 'tizen-app', 'service', 'index.js')], { env: { HOME: dir, TMPDIR: dir, TMP: dir, TEMP: dir, PATH: process.env.PATH }, stdio: ['ignore', 'ignore', 'pipe'] }), err = ''; +child.stderr.on('data', function (d) { err += d; }); +var WD = setTimeout(function () { child.kill(); fail('watchdog (15 s)' + (err ? '\n' + err : '')); }, 15000); + +function call(method, p, data, cb) { + var req = http.request({ host: '127.0.0.1', port: 8077, path: p, method: method, headers: data ? { 'Content-Length': data.length, 'Content-Type': typeof data === 'string' ? 'application/json' : 'application/octet-stream' } : {} }, function (res) { + var b = ''; res.on('data', function (d) { b += d; }); res.on('end', function () { var j = null; try { j = JSON.parse(b); } catch (e) {} cb(null, res.statusCode, j, b); }); + }); + req.on('error', function (e) { cb(e); }); + if (data) req.write(data); + req.end(); +} +function done(ok) { clearTimeout(WD); child.kill(); try { fs.readdirSync(dir).forEach(function (f) { fs.unlinkSync(path.join(dir, f)); }); fs.rmdirSync(dir); } catch (e) {} if (!ok) process.exit(1); console.log('PASS: upload stored and named, junk refused, install needs the token, discard clears it'); process.exit(0); } + +var tries = 0; +(function waitUp() { + call('GET', '/ping', null, function (e, code) { + if (e || code !== 200) { if (++tries > 100 || child.exitCode !== null) fail('the service never answered /ping' + (err ? '\n' + err : '')); return setTimeout(waitUp, 100); } + run(); + }); +})(); + +function run() { + var token = fs.readFileSync(path.join(dir, '.a2s-upload-token'), 'utf8').trim(); + if (!/^[0-9a-f]{48}$/.test(token)) fail('no token file in the data folder: ' + JSON.stringify(token)); + call('GET', '/upload', null, function (e, code, j, body) { + if (e || code !== 200 || body.indexOf('Send an app to this TV') < 0) fail('GET /upload is not the upload page (' + code + ')'); + call('PUT', '/upload?name=junk.wgt', Buf.from('this is not a zip'), function (e, code, j) { + if (e || code !== 400 || !j || j.ok || !/not a Tizen package/.test(j.error)) fail('junk must be refused: ' + code + ' ' + JSON.stringify(j)); + call('PUT', '/upload?name=notes.txt', wgt, function (e, code, j) { + if (e || code !== 400 || !j || j.ok) fail('a .txt must be refused: ' + code + ' ' + JSON.stringify(j)); + call('PUT', '/upload?name=..%2F..%2FProbe%20App.wgt', wgt, function (e, code, j) { + if (e || code !== 200 || !j || !j.ok) fail('a .wgt must be taken: ' + code + ' ' + JSON.stringify(j)); + var up = j.upload; + if (up.name !== 'Probe App.wgt' || up.size !== wgt.length || !/^[0-9a-f]{64}$/.test(up.sha256) || up.ids.appId !== 'A2SProbe01.Probe' || up.ids.packageId !== 'A2SProbe01' || up.ids.version !== '1.0.0' || up.ids.name !== 'Probe') fail('odd upload record ' + JSON.stringify(up)); + if (!fs.existsSync(path.join(dir, 'upload.pkg'))) fail('the file is not in the data folder'); + call('GET', '/uploads', null, function (e, code, j) { + if (e || code !== 200 || !j.pending || j.pending.id !== up.id) fail('/uploads does not list it: ' + JSON.stringify(j)); + call('POST', '/install', JSON.stringify({ upload: up.id, token: 'wrong' }), function (e, code, j) { + if (e || code !== 200 || !j || j.ok || j.code !== 'NO_TOKEN') fail('install without the token must be refused: ' + JSON.stringify(j)); + call('POST', '/install', JSON.stringify({ upload: up.id, token: token }), function (e, code, j) { + if (e || code !== 200 || !j || j.ok || j.code === 'NO_TOKEN' || j.code === 'NO_UPLOAD') fail('install with the token must reach the signer: ' + JSON.stringify(j)); + // No certificate here, so the file stays and a discard clears it. + call('GET', '/uploads', null, function (e, code, j) { + if (e || !j.pending) fail('the upload must still wait after a failed signing: ' + JSON.stringify(j)); + call('POST', '/upload/discard', '{}', function (e, code, j) { + if (e || code !== 200 || !j.ok) fail('discard failed: ' + JSON.stringify(j)); + call('GET', '/uploads', null, function (e, code, j) { + if (e || j.pending) fail('still pending after discard'); + if (fs.existsSync(path.join(dir, 'upload.pkg'))) fail('the file is still there after discard'); + done(true); + }); + }); + }); + }); + }); + }); + }); + }); + }); + }); +} diff --git a/tizen-app/js/app.js b/tizen-app/js/app.js index 9cf7463..a721e2f 100644 --- a/tizen-app/js/app.js +++ b/tizen-app/js/app.js @@ -21,6 +21,11 @@ * A pinned card shows that build's own state (installed version, update waiting). For apps whose * builds share one package id the pin also tells TV-Store which build the TV has. * + * Your own file (#30): a .wgt or .tpk sent from a phone or PC to http://:8077/upload waits + * in the service until this screen shows it (who sent it, what it calls itself) and OK confirms + * the install. Only this page can: it sends the token the service writes into the package's + * private folder (wgt-private), which no other app on the TV can read. + * * Navigation is spatial: every focusable element carries the class `f`, and an arrow key moves to * the nearest one in that direction (inside the sheet while it is open). Scrolling follows the * focus: a row of cards sideways, the page and the variant list vertically. On the TV page, right @@ -110,6 +115,9 @@ var confirmingAdult = false; var device = ''; var tvIp = ''; // this TV's IP now, from systeminfo + var upload = null; // the file waiting in the service (#30), from /uploads + var seenUpload = null; // the id this page already asked about + var uploadToken = null, uploadTokenError = null; var tried = []; // what startService got from each address, for the error screen // ---- settings, remembered on the TV ------------------------------------------------------------- @@ -247,6 +255,19 @@ })(); } + // The upload token (#30): the service writes it into the package's private data folder at + // every start, and only TV-Store's own page can read that folder, so only this screen can + // confirm an upload. Read fresh before each use: the service may have restarted since. + function withUploadToken(cb) { + function got(t) { t = String(t || '').trim(); if (t) { uploadToken = t; uploadTokenError = null; } else uploadTokenError = 'empty token file'; cb(); } + function failed(e) { uploadTokenError = (e && e.message) || String(e || 'cannot read wgt-private'); cb(); } + try { + tizen.filesystem.resolve('wgt-private', function (dir) { + try { dir.resolve('.a2s-upload-token').readAsText(got, failed, 'UTF-8'); } catch (e) { failed(e); } + }, failed, 'r'); + } catch (e) { failed(e); } + } + // What the platform says is installed: packageId -> version. Empty off a TV. function readInstalled(cb) { try { @@ -652,6 +673,7 @@ appEl.className = 'app behind'; sheetEl.className = 'sheet on'; if (sheet.mode === 'settings') sheetEl.innerHTML = settingsHtml(); else if (sheet.mode === 'job') sheetEl.innerHTML = job && job.finished && job.ok ? doneHtml() : jobHtml(); + else if (sheet.mode === 'upload') sheetEl.innerHTML = uploadHtml(); else sheetEl.innerHTML = appHtml(); if (!(keep && focusKey(keep))) { var d = sheetEl.querySelector('[data-default]') || sheetEl.querySelector('.f:not([disabled])'); @@ -700,6 +722,34 @@ return h; } + // The file a phone or PC sent (#30), as the app the install screens show: one build, no catalog. + function uploadApp(up) { + var ids = up.ids || {}, v = variant(up.name, up.name, '', ids.version, ids.appId, ids.packageId); + v.upload = up.id; + return { key: 'upload:' + up.id, name: ids.name || up.name.replace(/\.(wgt|tpk)$/i, ''), category: 'other', description: '', author: '', variants: [v], needsHead: null, adult: false, root: false }; + } + function uploadHtml() { + var up = upload; + if (!up) return '
Nothing waiting
The file is no longer on the TV.
' + + '
'; + var ids = up.ids || {}, x = uploadApp(up), have = ids.packageId && installed.hasOwnProperty(ids.packageId) ? installed[ids.packageId] : null; + var h = '
' + tile(x, 88) + '
' + esc(x.name) + '
' + esc('Sent from ' + up.from + ' · ' + (up.size / 1048576).toFixed(1) + ' MB') + '
'; + h += '
A file arrived from a phone or PC in your network. It is not from the Apps2Samsung catalog, so TV-Store cannot check it. Install it only if you sent it yourself or know where it came from.
'; + var b = ''; + if (have !== null) b += newer(ids.version, have) ? badge('b-warn', 'Update from v' + have) : badge('b-ok', 'Installed · v' + have); + if (b) h += '
' + b + '
'; + h += '
File' + esc(up.name) + '
' + + '
Application' + esc((ids.appId || ids.packageId || 'unknown') + (ids.version ? ' · v' + ids.version : '')) + '
' + + '
SHA-256' + esc(String(up.sha256).slice(0, 20)) + '…
'; + if (!armed()) h += '
Not armed yet. Open this TV’s Toolbox in Apps2Samsung on your phone or PC and press Arm TV-Store once; the file keeps waiting.
'; + else if (uploadTokenError) h += '
TV-Store could not read its upload key on this TV (' + esc(uploadTokenError) + '), so it cannot confirm uploads here. Install the file with Apps2Samsung on a phone or PC instead.
'; + var can = armed() && !uploadTokenError && !(job && job.running); + h += '
' + + '
' + + '
Signed with your certificate, like every install. RETURN keeps the file waiting.
'; + return h; + } + var STEPS = [['Downloaded', 'Downloading'], ['Checked and signed for this TV', 'Checking and signing'], ['Installed on the TV', 'Installing on the TV'], ['Ready to open', 'Ready to open']]; function jobHtml() { var x = job.app, v = job.v, upd = job.update; @@ -713,6 +763,7 @@ else if (i === done && job.error) { ico = '' + CROSS + ''; cls = ''; } else if (i === done && job.running) { ico = ''; cls = ''; } else { ico = ''; cls = ' pending'; } + if (i === 0 && v.upload) s = ['Received from your phone or PC', 'Picking up the file']; if (i === 0) side = job.bytes ? (job.bytes / 1048576).toFixed(1) + ' MB' : ''; if (i === 1) side = 'with your certificate'; if (i === 2) side = 'the TV is doing this part'; @@ -759,6 +810,12 @@ h += '
Signing certificate' + (armed() ? 'handed over' : 'not yet') + '
' + '
Catalog' + esc(catalogApps.length + ' apps, signature checked') + '
' + '
TV-Storev' + VERSION + (ready && ready.node ? ' · Node ' + esc(ready.node) : '') + '
'; + if (confirmingAdult) return h; // the question needs the room the QR codes take + // Your own file (#30): the upload page's address, as a QR for a phone. + if (ready && ready.ownIp) { + var u = 'http://' + ready.ownIp + ':8077/upload'; + h += '
' + qr(u, 120) + '
Install your own appScan with your phone, or open ' + esc(u) + ' on a computer in the same network, and send a .wgt or .tpk. TV-Store then asks here whether to install it.
'; + } return h + logBlock(); } @@ -776,7 +833,8 @@ renderHeader(); var since = job.started - 2000, me = job; var timer = setInterval(function () { pollLog(me, since); }, 700); - xhr('POST', '/install', { url: v.url, file: v.file, appId: v.appId, packageId: v.packageId }, function (err, res) { + var req = v.upload ? { upload: v.upload, token: uploadToken } : { url: v.url, file: v.file, appId: v.appId, packageId: v.packageId }; + xhr('POST', '/install', req, function (err, res) { clearInterval(timer); pollLog(me, since, function () { me.running = false; me.finished = true; me.ended = Date.now(); @@ -802,7 +860,7 @@ var t = Date.parse(l.slice(0, 24)); if (t && t < since) return; if (l.indexOf(' install:') < 0) return; me.line = l.replace(/^\S+\s+install:\s*/, ''); - var m = /downloaded (\d+) bytes/.exec(l); + var m = /(?:downloaded|uploaded) (\d+) bytes/.exec(l); if (m) { me.bytes = parseInt(m[1], 10); me.step = Math.max(me.step, 1); } if (/pushing to/.test(l)) me.step = Math.max(me.step, 2); if (/vd_appinstall reply/.test(l)) me.step = Math.max(me.step, 3); @@ -813,6 +871,20 @@ }); } + // Install the waiting file (#30): with a fresh token, so a restarted service still takes it. + function installUpload() { + if (!upload || (job && job.running)) return; + var x = uploadApp(upload); + withUploadToken(function () { + if (uploadTokenError) { renderSheet(true); return; } + startInstall(x, 0); + }); + } + function discardUpload() { + var id = upload && upload.id; + xhr('POST', '/upload/discard', { id: id }, function () { if (upload && upload.id === id) upload = null; closeSheet(); }); + } + function launch(appId) { try { tizen.application.launch(appId, function () {}, function (e) { flash('Could not open it: ' + (e && e.message)); }); } catch (e) { flash('Could not open it: ' + e.message); } @@ -842,6 +914,8 @@ if ((k = el.getAttribute('data-var'))) { sheet.v = parseInt(k, 10); renderSheet(); return; } if ((k = el.getAttribute('data-app'))) { var x = byKey(k), vi = el.getAttribute('data-vi'); if (x) openApp(x, vi != null ? parseInt(vi, 10) : null); return; } switch (act) { + case 'upinstall': if (!el.disabled) installUpload(); return; + case 'updiscard': discardUpload(); return; case 'author': var by = sheet.app.author; closeSheet(function () { searchFor(by); }); return; case 'pin': togglePin(sheet.app, sheet.v); renderPage(); renderSheet(); focusKey('pin'); return; case 'retry': location.reload(); return; @@ -993,11 +1067,22 @@ }; // Until a head arms the TV, ask the service now and then, so the first-run screen turns into the - // store by itself (an arm from an older head never touches this page's API). - setInterval(function () { if (!loading && !problem && !armed()) xhr('GET', '/ping', null, function (err, res) { if (!err) setReady(res); }); }, 3000); + // store by itself (an arm from an older head never touches this page's API). The same tick asks + // for a file sent from a phone or PC (#30) and shows it once, when no sheet is open. + setInterval(function () { + if (loading || problem) return; + if (!armed()) xhr('GET', '/ping', null, function (err, res) { if (!err) setReady(res); }); + xhr('GET', '/uploads', null, function (err, res) { + if (err || !res) return; + var was = upload; upload = res.pending || null; + if (upload && upload.id !== seenUpload && !sheet) { seenUpload = upload.id; openSheet({ mode: 'upload' }); return; } + if (sheet && sheet.mode === 'upload' && (!!was !== !!upload || (was && upload && was.id !== upload.id))) renderSheet(true); + }, null, 2500); + }, 3000); // ---- boot ------------------------------------------------------------------------------------- renderHeader(); renderFoot(); renderPage(); readDevice(); + withUploadToken(function () {}); startService(function (err, ping) { if (err) { loading = false; problem = { title: 'The TV-Store service did not start', short: 'Service stopped', text: err.message + ' Close TV-Store and open it again; if it keeps happening, reinstall it from Apps2Samsung.' }; renderPage(); focusFirst(); return; } ready = ping; renderFoot(); syncVerbose(); diff --git a/tizen-app/service/index.js b/tizen-app/service/index.js index 133b60e..2a9b912 100644 --- a/tizen-app/service/index.js +++ b/tizen-app/service/index.js @@ -18,6 +18,7 @@ var signer = require('./signer.js'); var zip = require('./zip.js'); var catalogTrust = require('./catalog.js'); var CATALOG_KEY = require('./catalog-key.js'); +var manifest = require('./manifest.js'); var PORT = 8077; // Signed by the Apps2Samsung catalog workflow; see catalog.js for why the content is checked @@ -84,6 +85,99 @@ function logPlatform() { logv('platform: network ' + nets.join('; ')); logv('platform: package ' + PKG + ', data ' + DATA_DIR + ', cert ' + (CERT_FILE || 'none yet') + ', cert folders ' + CERT_DIRS.join(', ')); } +// Uploads (#30): a .wgt or .tpk that anyone in the network sends to http://:8077/upload +// waits here, one at a time, until the person at the TV presses Install or Discard on the +// TV-Store screen. Only that screen can start the install: it sends the token below, which the +// service writes into the package's private data folder at start, where only TV-Store's own page +// can read it. Other apps on the TV reach this port too, and none of them gets an upload signed +// with the user's certificate that way. The page is open to the LAN like the log; the file is +// capped, checked to be a Tizen package, and shown on the TV with where it came from. +var UPLOAD = null; // { id, name, size, sha256, from, at, ids }, the file at UPLOAD_FILE +var UPLOAD_BUSY = false; // its install is running: no replacing or discarding meanwhile +var UPLOAD_MAX = 400 * 1024 * 1024; +var UPLOAD_FILE = DATA_DIR ? path.join(DATA_DIR, 'upload.pkg') : null; +var UPLOAD_TOKEN = crypto.randomBytes(24).toString('hex'); +var TOKEN_FILE = DATA_DIR ? path.join(DATA_DIR, '.a2s-upload-token') : null; +try { if (TOKEN_FILE) fs.writeFileSync(TOKEN_FILE, UPLOAD_TOKEN); } catch (e) { log('upload: cannot write the token file: ' + e.message); } +if (UPLOAD_FILE) { try { fs.unlinkSync(UPLOAD_FILE); } catch (e) {} } // a previous run's leftover +function unlink(f) { try { fs.unlinkSync(f); } catch (e) {} } +function publicUpload() { return UPLOAD ? { id: UPLOAD.id, name: UPLOAD.name, size: UPLOAD.size, sha256: UPLOAD.sha256, from: UPLOAD.from, at: UPLOAD.at, ids: UPLOAD.ids } : null; } + +// PUT /upload?name= with the package as the body, from a browser on the upload page. +function receiveUpload(req, res) { + var name = String(url.parse(req.url, true).query.name || '').replace(/^.*[\\\/]/, '').replace(/[^A-Za-z0-9._ -]/g, '_').trim(); + var from = String(req.socket.remoteAddress || '').replace(/^::ffff:/, ''); + function refuse(code, why) { req.resume(); json(res, code, { ok: false, error: why }); } + if (!/\.(wgt|tpk)$/i.test(name)) return refuse(400, 'send a .wgt or .tpk file'); + if (!UPLOAD_FILE) return refuse(500, 'the service has no folder it can write to'); + if (UPLOAD_BUSY) return refuse(409, 'the TV is installing the previous file; try again in a minute'); + var declared = parseInt(req.headers['content-length'] || '0', 10); + if (declared > UPLOAD_MAX) return refuse(413, 'too big: at most ' + Math.round(UPLOAD_MAX / 1048576) + ' MB'); + var tmp = UPLOAD_FILE + '.part', out = fs.createWriteStream(tmp), h = crypto.createHash('sha256'), size = 0, done = false; + function fin(code, obj) { if (done) return; done = true; if (!obj.ok) { try { out.destroy(); } catch (e) {} unlink(tmp); log('upload: refused ' + name + ' from ' + from + ': ' + obj.error); } json(res, code, obj); } + log('upload: receiving ' + name + ' from ' + from + (declared ? ', ' + declared + ' bytes' : '')); + req.on('data', function (d) { + if (done) return; + size += d.length; h.update(d); + if (size > UPLOAD_MAX) { fin(413, { ok: false, error: 'too big: at most ' + Math.round(UPLOAD_MAX / 1048576) + ' MB' }); req.destroy(); } + }); + req.on('error', function (e) { fin(400, { ok: false, error: 'the upload broke off: ' + e.message }); }); + out.on('error', function (e) { fin(500, { ok: false, error: 'could not store the file: ' + e.message }); }); + out.on('finish', function () { + if (done) return; + if (!size) return fin(400, { ok: false, error: 'the file is empty' }); + manifest.read(tmp, function (err, ids) { + if (err) return fin(400, { ok: false, error: err.message }); + try { fs.renameSync(tmp, UPLOAD_FILE); } catch (e) { return fin(500, { ok: false, error: 'could not store the file: ' + e.message }); } + UPLOAD = { id: crypto.randomBytes(8).toString('hex'), name: name, size: size, sha256: h.digest('hex'), from: from, at: new Date().toISOString(), ids: ids }; + log('upload: ' + name + ' (' + size + ' bytes, ' + ids.kind + ' ' + (ids.appId || ids.packageId) + (ids.version ? ' v' + ids.version : '') + ') from ' + from + ' waits for OK on the TV'); + fin(200, { ok: true, upload: publicUpload() }); + }); + }); + req.pipe(out); +} + +// POST /install { upload, token } from the TV-Store screen: sign the waiting file and install it. +// No catalog, no hash: the person at the TV saw the file, where it came from and pressed Install. +function installUpload(b, cb) { + if (!UPLOAD || b.upload !== UPLOAD.id) return cb(coded('NO_UPLOAD', 'that file is no longer waiting on the TV; send it again')); + if (String(b.token || '') !== UPLOAD_TOKEN) { log('upload: refused an install of ' + UPLOAD.name + ' without the token'); return cb(coded('NO_TOKEN', 'only the TV-Store screen may install an uploaded file')); } + if (UPLOAD_BUSY) return cb(coded('BUSY', 'that file is being installed')); + if (!DATA_DIR) return cb(new Error('the service has no folder it can write to')); + var up = UPLOAD, outFile = path.join(DATA_DIR, 'job-' + Date.now() + '.signed.wgt'); + UPLOAD_BUSY = true; + log('install: uploaded ' + up.size + ' bytes, ' + up.name + ' from ' + up.from + ', SHA-256 ' + up.sha256 + ', confirmed on the TV; signing'); + signAndInstall({ appId: up.ids.appId, packageId: up.ids.packageId, file: up.name }, up.name.replace(/[^A-Za-z0-9._-]/g, '_'), UPLOAD_FILE, outFile, function (err, r) { + UPLOAD_BUSY = false; + unlink(outFile); + // The file is gone once signed (signAndInstall drops it); a failure before that (no + // certificate yet) keeps the upload waiting for another try. + if (!err || !fs.existsSync(UPLOAD_FILE)) { UPLOAD = null; unlink(UPLOAD_FILE); } + cb(err, r); + }); +} + +// The page a phone or PC gets at http://:8077/upload: pick a file, send it, then look at +// the TV. Plain, inline, works in any browser of the last ten years. +function uploadPage() { + var ip = ownIp() || 'this TV', waiting = UPLOAD ? '

' + escHtml(UPLOAD.name) + ' is already waiting on the TV. Sending another file replaces it.

' : ''; + return 'TV-Store: send an app to the TV' + + '' + + '

Send an app to this TV

Pick a Tizen package (.wgt or .tpk). It goes to TV-Store on ' + escHtml(ip) + '. Nothing installs until you press Install on the TV screen.

' + waiting + + '
' + + ''; +} +function escHtml(s) { return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } + function logText() { var t = ''; try { t += '--- previous run ---\n' + fs.readFileSync(LOG_PREV, 'utf8') + '\n'; } catch (e) {} @@ -104,7 +198,7 @@ function fromThisTv(req) { return /^127\./.test(a) || ownAddresses().indexOf(a) >= 0; } -// This TV's own LAN IPv4 — the address sdbd accepts a connection on (phase 0: not loopback). +// This TV's own LAN IPv4 is the address sdbd accepts a connection on (phase 0: not loopback). function ownIp() { try { var ifs = os.networkInterfaces(); for (var n in ifs) for (var i = 0; i < ifs[n].length; i++) { var a = ifs[n][i]; if (a.family === 'IPv4' && !a.internal) return a.address; } @@ -162,7 +256,8 @@ function install(entry, cb) { var downloadUrl = entry.url, remoteName = (entry.file || 'package.wgt').replace(/[^A-Za-z0-9._-]/g, '_'); // Only what the signed catalog lists, with the hash it gives: the loopback port is open to // every app on the TV, and none of them gets to choose what is signed with the user's - // certificate. + // certificate. (A file the user sent in by hand goes through installUpload instead, behind + // the token only the TV-Store screen has.) withCatalog(function (err, cat) { if (err) return cb(err); if (!cat.index.hasOwnProperty(downloadUrl)) return cb(coded('NOT_IN_CATALOG', 'this download is not in the signed catalog')); @@ -270,7 +365,7 @@ function withCatalog(cb, fresh) { } // The signing certificate a head hands over once (see #844): author/distributor key+cert as PEM -// (not a .p12 — Node has no PKCS#12 parser). Kept in memory and persisted best-effort. +// (not a .p12: Node has no PKCS#12 parser). Kept in memory and persisted best-effort. function loadCerts() { if (CERTS) return CERTS; for (var i = 0; i < CERT_DIRS.length; i++) { @@ -296,8 +391,12 @@ http.createServer(function (req, res) { if (req.method === 'OPTIONS') { cors(res, 204, 'text/plain'); return res.end(); } var route = url.parse(req.url).pathname; // The page polls /ping and /logs all the time; those would drown the rest. - if (VERBOSE && !/^\/(ping|logs|log\.txt)$/.test(route)) res.on('finish', function () { logv('http: ' + req.method + ' ' + route + ' -> ' + res.statusCode); }); + if (VERBOSE && !/^\/(ping|logs|log\.txt|uploads)$/.test(route)) res.on('finish', function () { logv('http: ' + req.method + ' ' + route + ' -> ' + res.statusCode); }); if (route === '/log.txt') { res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Access-Control-Allow-Origin': '*' }); return res.end(logText()); } + // The upload page and the upload itself come from a phone or PC (#30); the install of it only + // from the TV-Store screen, below the gate and with the token. + if (route === '/upload' && req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(uploadPage()); } + if (route === '/upload' && (req.method === 'PUT' || req.method === 'POST')) return receiveUpload(req, res); // The rest only for this TV: the port is open to the LAN because Tizen 10 may not route a page's // request to the service over loopback, but nothing else on the network gets to hand over a // certificate or start an install. @@ -310,6 +409,13 @@ http.createServer(function (req, res) { json(res, 200, { ok: true, verbose: VERBOSE }); }); if (route === '/logs') return json(res, 200, { logs: LOGS }); + if (route === '/uploads') return json(res, 200, { pending: publicUpload(), busy: UPLOAD_BUSY }); + if (route === '/upload/discard' && req.method === 'POST') return body(req, function () { + if (UPLOAD_BUSY) return json(res, 409, { ok: false, error: 'that file is being installed' }); + if (UPLOAD) log('upload: ' + UPLOAD.name + ' discarded on the TV'); + UPLOAD = null; unlink(UPLOAD_FILE); + json(res, 200, { ok: true }); + }); if (route === '/catalog') return withCatalog(function (err, cat) { if (err) { log('catalog: failed: ' + err.message); return json(res, 502, { ok: false, error: err.message }); } cors(res, 200, 'application/json'); res.end(cat.payload); }); if (route === '/cert' && req.method === 'POST') return body(req, function (err, b) { if (err || !b || !b.authorKeyPem || !b.authorCertPem || !b.distributorKeyPem || !b.distributorCertPem) { @@ -320,8 +426,8 @@ http.createServer(function (req, res) { json(res, 200, { ok: true, certFile: CERT_FILE, certError: CERT_FILE ? null : CERT_ERROR }); }); if (route === '/install' && req.method === 'POST') return body(req, function (err, b) { - if (err || !b || !b.url) return json(res, 400, { ok: false, error: 'need { url, file, appId }' }); - install(b, function (err, r) { if (err) { log('install: failed' + (err.code ? ' [' + err.code + ']' : '') + ': ' + err.message); return json(res, 200, { ok: false, error: err.message, code: err.code || null }); } json(res, 200, { ok: true, result: r }); }); + if (err || !b || !(b.url || b.upload)) return json(res, 400, { ok: false, error: 'need { url, file, appId } or { upload, token }' }); + (b.upload ? installUpload : install)(b, function (err, r) { if (err) { log('install: failed' + (err.code ? ' [' + err.code + ']' : '') + ': ' + err.message); return json(res, 200, { ok: false, error: err.message, code: err.code || null }); } json(res, 200, { ok: true, result: r }); }); }); cors(res, 404, 'text/plain'); res.end('not found'); }).listen(PORT, '0.0.0.0', function () { log('TV-Store service on port ' + PORT + ', node ' + process.version + ' ' + process.arch + ', this TV ' + ownIp() + ', data ' + DATA_DIR + (VERBOSE ? ', verbose logging on' : '')); logPlatform(); }); diff --git a/tizen-app/service/manifest.js b/tizen-app/service/manifest.js new file mode 100644 index 0000000..102ceb4 --- /dev/null +++ b/tizen-app/service/manifest.js @@ -0,0 +1,54 @@ +/* What a package calls itself (#30): the ids vd_appinstall and the screens need, read from the + * package's own manifest. A .wgt carries config.xml at its root (), + * a .tpk carries tizen-manifest.xml ( with ). + * The catalog gives these for its own files; an uploaded file has only itself. Pure, file-based, + * on the zip reader the signer uses. ES5 for the TV's Node 4.4.3. */ +'use strict'; +var zip = require('./zip.js'); + +function attr(xml, tag, name) { + var tagRe = new RegExp('<' + tag + '\\b([^>]*)>', 'i'), m = tagRe.exec(xml); + if (!m) return ''; + var a = new RegExp('(?:^|\\s)' + name + '\\s*=\\s*"([^"]*)"', 'i').exec(m[1]); + return a ? a[1] : ''; +} +function text(xml, tag) { + var m = new RegExp('<' + tag + '\\b[^>]*>([^<]*)<\\/' + tag + '>', 'i').exec(xml); + return m ? m[1].trim() : ''; +} + +// The ids in a manifest's XML: { kind, appId, packageId, version, name }. Missing ones are ''. +function parse(kind, xml) { + if (kind === 'wgt') { + var pkg = attr(xml, 'tizen:application', 'package'); + return { kind: kind, appId: attr(xml, 'tizen:application', 'id'), packageId: pkg, version: attr(xml, 'widget', 'version'), name: text(xml, 'name') }; + } + var p = attr(xml, 'manifest', 'package'); + var appId = attr(xml, 'ui-application', 'appid') || attr(xml, 'service-application', 'appid') || attr(xml, 'widget-application', 'appid') || attr(xml, 'watch-application', 'appid'); + return { kind: kind, appId: appId, packageId: p, version: attr(xml, 'manifest', 'version'), name: text(xml, 'label') }; +} + +// cb(err, ids) for the package at `file`; err when it is not a zip or has no manifest at its root. +function read(file, cb) { + var entries; + try { entries = zip.readCentral(file); } catch (e) { return cb(new Error('not a Tizen package: ' + e.message)); } + var e = null, kind = null; + for (var i = 0; i < entries.length; i++) { + if (entries[i].name === 'config.xml') { e = entries[i]; kind = 'wgt'; break; } + if (entries[i].name === 'tizen-manifest.xml') { e = entries[i]; kind = 'tpk'; break; } + } + if (!e) return cb(new Error('not a Tizen package: no config.xml (web app) or tizen-manifest.xml (native app) at its root')); + if (e.uncompSize > 1024 * 1024) return cb(new Error('the manifest is unreasonably large')); + var chunks = [], done = false; + function fin(err, r) { if (done) return; done = true; cb(err, r); } + var st = zip.entryStream(file, e); + st.on('data', function (d) { chunks.push(d); }); + st.on('error', function (err) { fin(new Error('could not read the manifest: ' + err.message)); }); + st.on('end', function () { + var ids = parse(kind, Buffer.concat(chunks).toString('utf8')); + if (!ids.appId && !ids.packageId) return fin(new Error('the manifest names no application')); + fin(null, ids); + }); +} + +module.exports = { read: read, parse: parse };