diff --git a/__mocks__/bent.js b/__mocks__/bent.js new file mode 100644 index 0000000..e3a3591 --- /dev/null +++ b/__mocks__/bent.js @@ -0,0 +1,13 @@ +const fs = require('fs-extra'); +const tar = require('tar'); +const path = require('path'); + +module.exports = () => async () => { + const root = path.resolve(__dirname, 'user-installable-package'); + const files = await fs.readdir(root); + + return tar.c({ + gzip: true, + cwd: root + }, files); +}; diff --git a/__mocks__/user-installable-package/metadata.json b/__mocks__/user-installable-package/metadata.json new file mode 100644 index 0000000..455dabd --- /dev/null +++ b/__mocks__/user-installable-package/metadata.json @@ -0,0 +1,3 @@ +{ + "name": "UserInstallablePackage" +} diff --git a/__tests__/packages.js b/__tests__/packages.js index 940c477..bbb7644 100644 --- a/__tests__/packages.js +++ b/__tests__/packages.js @@ -2,6 +2,8 @@ const osjs = require('osjs'); const path = require('path'); const Packages = require('../src/packages.js'); +jest.mock('bent'); + describe('Packages', () => { let core; let packages; @@ -25,6 +27,22 @@ describe('Packages', () => { .toBe(true); }); + test('#installPackage', async () => { + await expect(packages.installPackage('jest:/UserInstallablePackage.tgz?redacted', { + root: 'home:/.packages' + }, {username: 'packages'})).resolves.toEqual({ + reload: true + }); + }); + + test('#uninstallPackage', async () => { + await expect(packages.uninstallPackage('UserInstallablePackage', { + root: 'home:/.packages' + }, {username: 'packages'})).resolves.toEqual({ + reload: true + }); + }); + test('#handleMessage', () => { const params = [{ pid: 1, diff --git a/package.json b/package.json index 5e6cc54..b76ff34 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "homepage": "https://github.com/os-js/osjs-server#readme", "dependencies": { "@osjs/common": "^3.0.8", + "bent": "^7.1.2", "body-parser": "^1.19.0", "chokidar": "^3.3.1", "connect-loki": "^1.1.0", @@ -53,6 +54,7 @@ "morgan": "^1.9.1", "nocache": "^2.1.0", "sanitize-filename": "^1.6.3", + "tar": "^6.0.1", "uuid": "^3.4.0" }, "devDependencies": { diff --git a/src/packages.js b/src/packages.js index 072c12f..07ef235 100644 --- a/src/packages.js +++ b/src/packages.js @@ -31,15 +31,25 @@ const fs = require('fs-extra'); const fg = require('fast-glob'); const path = require('path'); -const Package = require('./package.js'); const consola = require('consola'); -const logger = consola.withTag('Packages'); +const Package = require('./package.js'); +const {getPrefix} = require('./utils/vfs.js'); +const { + relative, + archiveName, + fetchSteam, + readOrDefault, + extract +} = require('./utils/packages.js'); -const relative = filename => filename.replace(process.cwd(), ''); +const logger = consola.withTag('Packages'); -const readOrDefault = filename => fs.existsSync(filename) - ? fs.readJsonSync(filename) - : []; +/** + * @typedef InstallPackageOptions + * @param {string} root + * @param {boolean} system + * @param {object} [headers] + */ /** * OS.js Package Management @@ -88,11 +98,11 @@ class Packages { * Loads all packages * @return {Promise} */ - createLoader() { + async createLoader() { let result = []; const {discoveredFile, manifestFile} = this.options; - const discovered = readOrDefault(discoveredFile); - const manifest = readOrDefault(manifestFile); + const discovered = await readOrDefault(discoveredFile); + const manifest = await readOrDefault(manifestFile); const sources = discovered.map(d => path.join(d, 'metadata.json')); logger.info('Using package discovery file', relative(discoveredFile)); @@ -133,6 +143,129 @@ class Packages { }, 500); } + /** + * Installs a package from given url + * @param {string} url + * @param {InstallPackageOptions} options + * @param {object} user + */ + async installPackage(url, options, user) { + const {realpath} = this.core.make('osjs/vfs'); + + if (!options.root) { + throw new Error('Missing package installation root path'); + } + + const name = archiveName(url); + const target = await realpath(`${options.root}/${name}`, user); + + if (path.resolve(target) === path.resolve(options.root)) { + throw new Error('Invalid package source'); + } else if (await fs.exists(target)) { + throw new Error('Target already exists'); + } else if (options.system) { + throw new Error('System packages not yet implemented'); + } + + const stream = await fetchSteam(url, options); + + await fs.mkdirp(target); + await extract(stream, target); + + // FIXME: npm packages have a 'package' subdirectory + const exists = await fs.exists(path.resolve(target, 'metadata.json')); + if (!exists) { + await fs.remove(target); + + throw new Error('Invalid package'); + } + + await this.writeUserManifest(options.root, user); + + return { + reload: !options.system + }; + } + + /** + * Uninstalls a package by name + * @param {string} name + * @param {InstallPackageOptions} options + * @param {object} user + */ + async uninstallPackage(name, options, user) { + const {realpath} = this.core.make('osjs/vfs'); + + if (!options.root) { + throw new Error('Missing package installation root path'); + } + + const userRoot = options.root; + const target = await realpath(`${userRoot}/${name}`, user); + + if (await fs.exists(target)) { + // FIXME: Secure this + await fs.remove(target); + await this.writeUserManifest(userRoot, user); + } else { + throw new Error('Package not found in root directory'); + } + + return { + reload: !options.system + }; + } + + /** + * Writes user installed package manifest + * @param {string} userRoot + * @param {object} user + */ + async writeUserManifest(userRoot, user) { + const {realpath} = this.core.make('osjs/vfs'); + + // TODO: Check conflicts ? + const root = await realpath(userRoot, user); + const manifest = await realpath(`${userRoot}/metadata.json`, user); + const filenames = await fg(root.replace(/\\/g, '/') + '/*/metadata.json'); + const metadatas = await Promise.all(filenames.map(f => fs.readJson(f))); + + await fs.writeJson(manifest, metadatas); + } + + /** + * Reads package manifests + * @param {string[]} paths + * @param {object} user + * @return {Package[]} List of packages + */ + async readPackageManifests(paths, user) { + const {realpath, mountpoints} = this.core.make('osjs/vfs'); + const {manifestFile} = this.options; + const systemManifest = await readOrDefault(manifestFile); + + const isValidVfs = p => { + const prefix = getPrefix(p); + const mount = mountpoints.find(m => m.name === prefix); + return mount && mount.attributes.root; + }; + + const userManifests = await Promise.all(paths.filter(isValidVfs).map(async p => { + const real = await realpath(`${p}/metadata.json`, user); + const list = await readOrDefault(real); + + return list.map(pkg => Object.assign({}, pkg, { + _vfs: p, + server: null + })); + })); + + return [ + ...systemManifest, + ...[].concat(...userManifests) + ]; + } + /** * Loads package data * @param {string} filename Filename diff --git a/src/providers/packages.js b/src/providers/packages.js index 938bac3..3a02ad4 100644 --- a/src/providers/packages.js +++ b/src/providers/packages.js @@ -55,6 +55,12 @@ class PackageServiceProvider extends ServiceProvider { }); } + depends() { + return [ + 'osjs/express' + ]; + } + provides() { return [ 'osjs/packages' @@ -62,8 +68,35 @@ class PackageServiceProvider extends ServiceProvider { } init() { + const {routeAuthenticated} = this.core.make('osjs/express'); + this.core.singleton('osjs/packages', () => this.packages); + const usingPackageManager = cb => (req, res) => cb(req, res) + .then(json => res.json(json)) + .catch((error) => { + console.error(error); + res.status(400).json({error: 'Action failed'}); + }); + + routeAuthenticated( + 'GET', + '/api/packages/metadata', + usingPackageManager(req => this.packages.readPackageManifests(req.query.root || [], req.session.user)) + ); + + routeAuthenticated( + 'POST', + '/api/packages/install', + usingPackageManager(req => this.packages.installPackage(req.body.url, req.body.options, req.session.user)) + ); + + routeAuthenticated( + 'POST', + '/api/packages/uninstall', + usingPackageManager(req => this.packages.uninstallPackage(req.body.name, req.body.options, req.session.user)) + ); + return this.packages.init(); } diff --git a/src/utils/packages.js b/src/utils/packages.js new file mode 100644 index 0000000..5c4f677 --- /dev/null +++ b/src/utils/packages.js @@ -0,0 +1,63 @@ +/* + * OS.js - JavaScript Cloud/Web Desktop Platform + * + * Copyright (c) 2011-2020, Anders Evenrud + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * @author Anders Evenrud + * @licence Simplified BSD License + */ + +const bent = require('bent'); +const tar = require('tar'); +const path = require('path'); +const fs = require('fs-extra'); + +const relative = filename => filename + .replace(process.cwd(), ''); + +const archiveName = url => path + .basename(url.split('?')[0]) + .replace(/\.[^/.]+$/, ''); + +const fetchSteam = (url, options) => bent()(url, null, { + headers: options.headers || {} +}); + +const readOrDefault = async (filename) => await fs.exists(filename) + ? fs.readJson(filename) + : Promise.resolve([]); + +const extract = (stream, target) => new Promise((resolve, reject) => { + const s = stream.pipe(tar.extract({C: target})); + s.once('end', () => resolve()); + s.once('error', error => reject(error)); +}); + +module.exports = { + relative, + archiveName, + fetchSteam, + readOrDefault, + extract +};