diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..69109601 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +**/node_modules +**/package-lock.json +react-ui/nojsx +wsc-chrome.min.js +assets +package.json +package.zip diff --git a/README.md b/README.md index 26945e61..078b374b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,31 @@ +> ⚠️ **Status: Being Rebuilt** +> +> Google is discontinuing Chrome Apps. Web Server for Chrome is being rebuilt as a Chrome Extension with a native helper app. +> +> **[Sign up to be notified when it's ready](https://forms.gle/88Q5rbZ81sKqXZTt8)** + +--- + +An HTTP web server for Chrome, used by 200,000+ people for local web development and file sharing. + +## What's Changing + +The original Chrome App used `chrome.sockets` APIs that are being removed. The new version will be: + +- **Chrome Extension** - The UI and HTTP logic +- **Native Helper App** - Handles TCP sockets and filesystem access + - ChromeOS: Android companion app (Play Store) + - Windows/Mac/Linux: Native binary + +Same functionality, new architecture. + +## Legacy Documentation + ![Try it now in CWS](https://raw.github.com/GoogleChrome/chrome-app-samples/master/tryitnowbutton.png "Click here to install this sample from the Chrome Web Store") -# Chrome Web Server - an HTTP web server for Chrome (chrome.sockets) +## Web Server for Chrome + +an HTTP web server for Chrome (chrome.sockets) Get it in the chrome web store: https://chrome.google.com/webstore/detail/web-server-for-chrome/ofhbbkphhbklhfoeikjpcbhemlocgigb @@ -28,8 +53,7 @@ How to include into your own chrome app run minimize.sh to concatenate all the required files together and then include the resulting wsc-chrome.min.js in your project. Here is an example of another project's usage: https://github.com/zebradog/kiosk/blob/f7a398f697edc1c22b90c14f959779f1e850012a/src/js/main.js#L124 -=== -Basic usage: +### Basic usage: ``` var app = new WSC.WebApplication(options) @@ -82,14 +106,26 @@ handlers is an array of 2 element arrays where the first item is a regular expre ``` -==== -Building -==== -Unfortunately there is a build process if you want to run this from source directly because I am using a Polymer (polymer-project.org) user interface. There is a bower.json in the polymer-ui folder and you will need to install node+npm+bower and then run bower install from that folder. Oh, and then you will need to "Refactor for CSP" (chrome apps do not allow inline scripts), one way of doing this is using https://chrome.google.com/webstore/detail/chrome-dev-editor-develop/pnoffddplpippgcfjdhbmhkofpnaalpg (open the folder and right click and select refactor for CSP) -I'm now using a script that can do this (look in polymer-ui/build.sh. You'll need to npm install -g vulcanize crisper) +### Building + +``` +cd web-server-chrome +mkdir assets +cd makedeps +npm install +npm run make # this builds the app dependencies such as react and material-ui into a bundle +cd ../react-ui +npm run watch # Press ctrl-c if you just want to build it once. +# press ctrl-C if you are done editing +cd ../ +bash package.sh +``` + +This creates package.zip, which can then be distributed. + -==== +### Where to get it Get it in the chrome web store: https://chrome.google.com/webstore/detail/web-server-for-chrome/ofhbbkphhbklhfoeikjpcbhemlocgigb @@ -101,7 +137,7 @@ handle range requests. It also sets mime types correctly. Here is an example project based on it: https://chrome.google.com/webstore/detail/flv-player/dhogabmliblgpadclikpkjfnnipeebjm -==== +--- MIT license diff --git a/background.js b/background.js index 9220f58c..30b1bf3f 100644 --- a/background.js +++ b/background.js @@ -33,7 +33,11 @@ function onchoosefolder(entry) { function settings_ready(d) { localOptions = d - console.log('settings:',d) + let dCpy = {}; + Object.assign(dCpy, d); + delete dCpy.optPrivateKey;// dont fill logs with crypto info + delete dCpy.optCertificate; + console.log('settings:',dCpy) setTimeout( maybeStartup, 2000 ) // give background accept handler some time to trigger //chrome.alarms.getAll( onAllAlarms ) } @@ -207,7 +211,7 @@ function launch(launchData) { height: 700 } } //var page = 'index.html' - var page = 'polymer-ui/index.html' + var page = 'react-ui/index.html' chrome.app.window.create(page, opts, function(mainWindow) { diff --git a/chromise.js b/chromise.js new file mode 100644 index 00000000..4b7b1694 --- /dev/null +++ b/chromise.js @@ -0,0 +1,183 @@ +/** + * @author Alexey Kuzmin + * @fileoverview Promise based wrapper for Chrome Extension API. + * @see https://developer.chrome.com/extensions/api_index + * @license MIT + * @version 3.1.0 + */ + + + +;(function(global) { + 'use strict'; + + let apiProxy = { + /** + * @param {!Object} apiObject + * @param {string} methodName + * @param {Arguments} callArguments Arguments to be passes to method call. + */ + callMethod(apiObject, methodName, callArguments) { + let originalMethod = apiObject[methodName]; + let callArgumentsArray = Array.from(callArguments); + + return new Promise((resolve, reject) => { + let callback = apiProxy.processResponse_.bind(null, resolve, reject); + callArgumentsArray.push(callback); + originalMethod.apply(apiObject, callArgumentsArray); + }); + }, + + /** + * @param {!Function} callback + * @param {!Function} errback + * @param {!Array} response Response from Extension API. + * @private + */ + processResponse_(callback, errback, ...response) { + let error = global.chrome.runtime.lastError; + if (typeof error == 'object') { + errback(new Error(error.message)); + return; + } + + if (response.length < 2) + response = response[0]; // undefined if response is empty + + callback(response); + } + }; + + + let classifier = { + /** + * @param {string} letter + * @return {boolean} + * @private + */ + isCapitalLetter_(letter) { + return letter == letter.toUpperCase(); + }, + + /** + * @param {string} string + * @return {boolean} + * @private + */ + startsWithCapitalLetter_(string) { + return classifier.isCapitalLetter_(string[0]); + }, + + /** + * We need to decide should given property be wrapped or not + * by its name only. Retrieving its value would cause API initialization, + * that can take a long time (dozens of ms). + * @param {string} propName + * @return {boolean} + */ + propertyNeedsWrapping(propName) { + if (classifier.startsWithCapitalLetter_(propName)) { + // Either constructor, enum, or constant. + return false; + } + + if (propName.startsWith('on') && + classifier.isCapitalLetter_(propName[2])) { + // Extension API event, e.g. 'onUpdated'. + return false; + } + + // Must be a namespace or a method. + return true; + } + }; + + + let wrapGuy = { + /** + * @param {!Object} api API object to wrap. + * @return {!Object} + */ + wrapApi(api) { + return wrapGuy.wrapObject_(api); + }, + + /** + * Wraps API object. + * @param {!Object} apiObject + * @return {!Object} + * @private + */ + wrapObject_(apiObject) { + let wrappedObject = {}; + + Object.keys(apiObject) + .filter(classifier.propertyNeedsWrapping) + .forEach(keyName => { + Object.defineProperty(wrappedObject, keyName, { + enumerable: true, + configurable: true, + get() { + return wrapGuy.wrapObjectField_(apiObject, keyName); + } + }); + }); + + return wrappedObject; + }, + + /** + * @type {!Map} + * @private + */ + wrappedFieldsCache_: new Map(), + + /** + * Wraps single object field. + * @param {!Object} apiObject + * @param {string} keyName + * @return {?|undefined} + * @private + */ + wrapObjectField_(apiObject, keyName) { + let apiEntry = apiObject[keyName]; + + if (wrapGuy.wrappedFieldsCache_.has(apiEntry)) { + return wrapGuy.wrappedFieldsCache_.get(apiEntry); + } + + let entryType = typeof apiEntry; + let wrappedField; + if (entryType == 'function') { + wrappedField = wrapGuy.wrapMethod_(apiObject, keyName); + } + if (entryType == 'object') { + wrappedField = wrapGuy.wrapObject_(apiEntry); + } + + if (wrappedField) { + wrapGuy.wrappedFieldsCache_.set(apiEntry, wrappedField); + return wrappedField; + } + }, + + /** + * Wraps API method. + * @param {!Object} apiObject + * @param {string} methodName + * @return {!Function} + * @private + */ + wrapMethod_(apiObject, methodName) { + return function() { + return apiProxy.callMethod(apiObject, methodName, arguments); + } + } + }; + + + let chromise = wrapGuy.wrapApi(global.chrome); + + global.chromise = chromise; + +}(this)); diff --git a/common.js b/common.js index dfcd6ab9..3af74507 100644 --- a/common.js +++ b/common.js @@ -128,7 +128,7 @@ if (! String.prototype.startsWith) { window.WSC.entryCache = new EntryCache window.WSC.entryFileCache = new EntryCache -WSC.recursiveGetEntry = function(filesystem, path, callback) { +WSC.recursiveGetEntry = function(filesystem, path, callback, allowFolderCreation) { var useCache = false // XXX duplication with jstorrent var cacheKey = filesystem.filesystem.name + @@ -159,7 +159,7 @@ WSC.recursiveGetEntry = function(filesystem, path, callback) { } else if (e.isDirectory) { if (path.length > 1) { // this is not calling error callback, simply timing out!!! - e.getDirectory(path.shift(), {create:false}, recurse, recurse) + e.getDirectory(path.shift(), {create:!!allowFolderCreation}, recurse, recurse) } else { state.e = e state.path = _.clone(path) @@ -205,11 +205,12 @@ function ui82arr(arr, startOffset) { return outarr } function str2ab(s) { - var arr = [] + var buf = new ArrayBuffer(s.length); + var bufView = new Uint8Array(buf); for (var i=0; i 0) { console.log('request had content length',clen) this.stream.readBytes(clen, this.onRequestBody.bind(this)) - return } else { - this.curRequest.body = null + console.log('request had an empty body') + this.curRequest.body = new Uint8Array(0) + this.onRequestBody(this.curRequest.body) } + return } - - if (method == 'GET') { - this.onRequest(this.curRequest) - } else if (method == 'HEAD') { - this.onRequest(this.curRequest) - } else if (method == 'PUT') { - // handle request BODY? + if (['GET','HEAD','PUT','OPTIONS'].includes(method)) { this.onRequest(this.curRequest) } else { console.error('how to handle',this.curRequest) @@ -96,7 +92,7 @@ var bodyparams = {} var items = bodydata.split('&') for (var i=0; i { return createCrypto(name, data || {}); } + +})(); + diff --git a/directory-listing-template.html b/directory-listing-template.html index c336b9e2..f8a158b9 100644 --- a/directory-listing-template.html +++ b/directory-listing-template.html @@ -98,13 +98,16 @@ td.detailsColumn { -webkit-padding-start: 2em; + padding-inline-start: 2em; text-align: end; white-space: nowrap; } a.icon { -webkit-padding-start: 1.5em; + padding-inline-start: 1.5em; text-decoration: none; + padding-left: 20px; } a.icon:hover { diff --git a/handlers.js b/handlers.js index f37202d5..5f9ab5ae 100644 --- a/handlers.js +++ b/handlers.js @@ -104,7 +104,7 @@ // if upload enabled in options... // check if file exists... - this.fs.getByPath(this.request.path, this.onPutEntry.bind(this)) + this.fs.getByPath(this.request.path, this.onPutEntry.bind(this), true) }, onPutEntry: function(entry) { var parts = this.request.path.split('/') @@ -118,7 +118,11 @@ var allowReplaceFile = true console.log('file already exists', entry) if (allowReplaceFile) { - this.fs.getByPath(path, this.onPutFolder.bind(this,filename)) + // truncate file + var onremove = function(evt) { + this.fs.getByPath(path, this.onPutFolder.bind(this,filename)) + }.bind(this) + entry.remove( onremove, onremove ) } } }, @@ -273,7 +277,12 @@ function alldone(results) { if (this.app.opts.optRenderIndex) { for (var i=0; i ../assets/bundle.js" + }, + "dependencies": { + "@material-ui/core": "^4.6.1", + "@material-ui/icons": "^4.5.1", + "@material-ui/lab": "^4.0.0-alpha.57", + "browserify": "^16.5.0", + "node-forge": "^0.10.0", + "react": "^16.12.0", + "react-dom": "^16.12.0", + "underscore": "^1.9.1" + } +} diff --git a/manifest.json b/manifest.json index c216a9da..81854e39 100644 --- a/manifest.json +++ b/manifest.json @@ -4,13 +4,15 @@ "short_name": "Web Server", "description": "A Web Server for Chrome, serves web pages from a local folder over the network, using HTTP. Runs offline.", "author": "Kyle Graehl", - "version": "0.4.5", + "version": "0.5.0", "manifest_version": 2, "offline_enabled": true, "minimum_chrome_version": "45", "app": { "background": { - "scripts": ["underscore.js","encoding.js","common.js","log-full.js","mime.js","buffer.js","request.js","stream.js","chromesocketxhr.js","connection.js","webapp.js","websocket.js","handlers.js","httplib.js","upnp.js","background.js"] + "scripts": ["underscore.js","encoding.js","common.js","assets/bundle.js", + "log-full.js", "mime.js", "buffer.js","request.js","crypto.js","stream.js", "chromesocketxhr.js", + "connection.js","webapp.js","websocket.js","handlers.js","httplib.js","upnp.js","background.js"] } }, "permissions": [ diff --git a/manifest.json.scratch b/manifest.json.scratch index 0ee54d06..cee76590 100644 --- a/manifest.json.scratch +++ b/manifest.json.scratch @@ -1 +1,2 @@ - "key":"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv7ZULSqbpKKQ1QP5Tb9f8g306PlY87OYYlXBrp7PlBHd/LJMUBDNWMCrWhpFR8sIjpvHjYipDr60j+2i7vj3PZlwbxZ7e3x+2A4cQt1LaC1PVZ6avnbsV0YMkFQi8H5f7NQiBKE2i2/Z/kB2r/DzyiUdGW63/sgjpBvgDCCMysHl3NWCnHqIOOtGD8SFlT5clgNJgVOgosFwHE4yYJDpIkvJ+nrLia9v6V/Cyc8ITEd0njvsp0q0aFJp332Ua/RPvh/m1UKcj8f3FNbaCrdScFzfKo5UNmifKLGhT377xhnvhOKuEJbyghNkPheMUquwVpTEHdRFMm7nVcLAt/kuZwIDAQAB", \ No newline at end of file +// this is the production app key +"key":"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv7ZULSqbpKKQ1QP5Tb9f8g306PlY87OYYlXBrp7PlBHd/LJMUBDNWMCrWhpFR8sIjpvHjYipDr60j+2i7vj3PZlwbxZ7e3x+2A4cQt1LaC1PVZ6avnbsV0YMkFQi8H5f7NQiBKE2i2/Z/kB2r/DzyiUdGW63/sgjpBvgDCCMysHl3NWCnHqIOOtGD8SFlT5clgNJgVOgosFwHE4yYJDpIkvJ+nrLia9v6V/Cyc8ITEd0njvsp0q0aFJp332Ua/RPvh/m1UKcj8f3FNbaCrdScFzfKo5UNmifKLGhT377xhnvhOKuEJbyghNkPheMUquwVpTEHdRFMm7nVcLAt/kuZwIDAQAB", \ No newline at end of file diff --git a/mime.js b/mime.js index ec0772d4..8a0f9cc4 100644 --- a/mime.js +++ b/mime.js @@ -438,7 +438,8 @@ var MIMETYPES = { "mif": "application/vnd.mif", "mime": "message/rfc822", "mj2": "video/mj2", - "mjp2": "video/mj2", + "mjp2": "video/mj2", + "mjs": "application/javascript", "mk3d": "video/x-matroska", "mka": "audio/x-matroska", "mks": "video/x-matroska", @@ -871,6 +872,7 @@ var MIMETYPES = { "vxml": "application/voicexml+xml", "w3d": "application/x-director", "wad": "application/x-doom", + "wasm": "application/wasm", "wav": "audio/x-wav", "wax": "audio/x-ms-wax", "wbmp": "image/vnd.wap.wbmp", diff --git a/minimize.sh b/minimize.sh index cb494d45..2862f793 100644 --- a/minimize.sh +++ b/minimize.sh @@ -1,10 +1 @@ - -if [ -f wsc-chrome.min.js ]; then - rm wsc-chrome.min.js -fi - -for f in "underscore.js" "encoding.js" "common.js" "log-full.js" "mime.js" "buffer.js" "request.js" "stream.js" "chromesocketxhr.js" "connection.js" "webapp.js" "websocket.js" "upnp.js" "handlers.js" "httplib.js"; do cat $f >> wsc-chrome.min.js; done - - - - +cat "underscore.js" "encoding.js" "common.js" "log-full.js" "mime.js" "buffer.js" "request.js" "crypto.js" "stream.js" "chromesocketxhr.js" "connection.js" "webapp.js" "websocket.js" "upnp.js" "handlers.js" "httplib.js" > wsc-chrome.min.js diff --git a/package.sh b/package.sh index f996811b..cc56b384 100644 --- a/package.sh +++ b/package.sh @@ -2,4 +2,6 @@ rm package.zip #zip package.zip -r * -x package.sh -x *.git* -x "*.*~" -x images/cws_*.png -x *.scratch -x polymer-ui/node-modules/**\* -x wsc-chrome.min.js -zip package.zip manifest.json *.js *.html images/200*.png polymer-ui/*.html polymer-ui/*.js polymer-ui/*.css polymer-ui/bower_components/font-roboto/fonts/roboto/Roboto-Bold.ttf -x wsc-chrome.min.js +#zip package.zip manifest.json *.js *.html images/200*.png polymer-ui/*.html polymer-ui/*.js polymer-ui/*.css polymer-ui/bower_components/font-roboto/fonts/roboto/Roboto-Bold.ttf -x wsc-chrome.min.js + +zip package.zip manifest.json *.js *.html images/200*.png react-ui/*.html react-ui/nojsx/* assets/* -x wsc-chrome.min.js diff --git a/polymer-ui/README.md b/polymer-ui/README.md new file mode 100644 index 00000000..ee7eb2cb --- /dev/null +++ b/polymer-ui/README.md @@ -0,0 +1 @@ +not used any more. see https://github.com/kzahel/web-server-chrome/issues/199 \ No newline at end of file diff --git a/react-ui/.babelrc b/react-ui/.babelrc new file mode 100644 index 00000000..59566c26 --- /dev/null +++ b/react-ui/.babelrc @@ -0,0 +1,6 @@ +{ + "plugins": [ + ["@babel/plugin-transform-react-jsx",{useBuiltIns:true}], + "@babel/plugin-syntax-class-properties" + ] +} diff --git a/react-ui/index.html b/react-ui/index.html new file mode 100644 index 00000000..f0e6355d --- /dev/null +++ b/react-ui/index.html @@ -0,0 +1,8 @@ + + + + +
+ + + diff --git a/react-ui/js/index.js b/react-ui/js/index.js new file mode 100644 index 00000000..65776158 --- /dev/null +++ b/react-ui/js/index.js @@ -0,0 +1,399 @@ +import {AppOptions, AppOption} from './options.js' + +const { + FormControlLabel, + Card, + CardContent, + Tooltip, + FormGroup, + Switch, + AppBar, + Container, + Toolbar, + Typography, + Button, + ThemeProvider +} = MaterialUI + +const {Alert} = MaterialUILab; + +const {createMuiTheme, colors, withStyles} = MaterialUI; +const styles = { + card: {margin: '10px'}, + appicon: {marginRight: '10px'} +}; +const theme = createMuiTheme({ + palette: { + primary: { + main: '#3f51b5', + }, + secondary: colors.blueGrey, + }, + status: { + danger: 'orange', + }, +}); + + +const functions = { + optVerbose: function(app, k, val) { + const {bg} = app; + bg.WSC.VERBOSE = bg.WSC.DEBUG = val + }, + optAllInterfaces: function(app, k, val) { + app.webapp.interfaces = [] + }, + optIPV6: function(app, k, val) { + // reset the list of interfaces + app.webapp.interfaces = [] + }, + optPreventSleep: function(app, k, val) { + // do it after the setting is changed + setTimeout(() => { + app.webapp.updatedSleepSetting() + }, 1); + }, + optBackground: function(app, k, val) { + const {webapp, bg} = app; + console.log('background setting changed',val) + webapp.updateOption('optBackground',val) + // appOptions.set('optBackground', val) + bg.backgroundSettingChange({'optBackground':val}) + }, + port: (app, k, v) => { + console.log('persist port', v) + console.assert(typeof v === 'number') + app.webapp.opts.port = v + app.webapp.port = v // does it still need to be set here? + }, + optAutoStart: function(app, k, val) { + const {bg, webapp} = app; + if (val) { + chrome.permissions.request({permissions:['background']}, function(result) { + console.log('request perm bg',result) + if (result) { + success() + } + }) + } else { + chrome.permissions.remove({permissions:['background']}, function(result) { + console.log('drop perm bg',result) + success() + }) + } + function success() { + console.log('persist setting start in background',val) + webapp.opts.optBackground = val + bg.backgroundSettingChange({'optBackground':val}) + } + }, + optPrivateKey: (app, k, val) => { + //console.log('privateKey') + console.assert(typeof val === 'string') + app.webapp.updateOption('optPrivateKey', val); + }, + optCertificate: (app, k, val) => { + //console.log('certificate'); + console.assert(typeof val === 'string') + app.webapp.updateOption('optCertificate', val); + }, + optUseHttps: (app, k, val) => { + console.log("useHttps", val); + app.webapp.updateOption('optUseHttps', val); + if (app.webapp.started) { + app.webapp.stop(); + app.webapp.start(); + } + } +}; + + +window.reload = chrome.runtime.reload +setup_events() +function setup_events() { + function keydown(evt) { + if (evt.metaKey || evt.ctrlKey) { + if (evt.keyCode == 82) { + // ctrl-r + console.log('received ctrl(meta)-r, reload app') + if (window.fgapp) { + fgapp.reload() + } else { + chrome.runtime.reload() + } + } + //evt.preventDefault() // dont prevent ctrl-w + } + } + document.body.addEventListener('keydown', keydown) +} + + +class App extends React.Component { + state = { + showAdvanced: false, + interfaces: [], + port: 6669, + started: false, + starting: false, + lasterr: null, + folder: null, + message: '' + } + constructor(props) { + super(props) + this.classes = props.classes; // styling api + window.app = this + console.log('app created'); + this.init() + } + async init() { + this.bg = await chromise.runtime.getBackgroundPage() + this.appOptions = new AppOptions(this.settings_ready.bind(this)) + } + settings_ready() { + const allOpts = this.appOptions.getAll() + let dCpy = {}; + Object.assign(dCpy, allOpts); + delete dCpy.optPrivateKey;// dont fill logs with crypto info + delete dCpy.optCertificate; + + console.log('fetched local settings', this.appOptions, dCpy) + this.webapp = this.bg.get_webapp(allOpts) // retainStr in here + this.bg.WSC.VERBOSE = this.bg.WSC.DEBUG = this.appOptions.get('optVerbose') + this.webapp.on_status_change = this.on_webapp_change.bind(this) + this.setState(allOpts); + this.on_webapp_change() + this.ui_ready() + } + get_status() { + const result = { + starting: this.webapp && this.webapp.starting, + started: this.webapp && this.webapp.started, + lasterr: this.webapp && this.webapp.lasterr, + folder: this.webapp && + this.webapp.fs && + this.webapp.fs.entry && + this.webapp.fs.entry.fullPath, + } + result.message = this.computeMessage(result) + return result + } + computeMessage({started, starting, lasterr}) { + if (lasterr) { + return JSON.stringify(lasterr) + } else if (starting) { + return 'STARTING' + } else if (started) { + return 'STARTED' + } else { + return 'STOPPED' + } + } + on_webapp_change() { + var status = this.get_status() + console.log('webapp changed',status) + this.setState({ + ...status, + port: this.webapp.port, + interfaces: this.webapp.urls.slice() + }) + } + gen_crypto() { + let reasonStr = this.webapp.opts.optPrivateKey ? "private key" : + this.webapp.opts.optCertificate ? "certificate" : ""; + if (reasonStr) { + console.warn("Would overwrite existing " + reasonStr + ", erase it first\nMake sure to save a copy first"); + return; + } + let cn = "WebServerForChrome" + (new Date()).toISOString(); + let data = this.webapp.createCrypto(cn); + this.appOptions.set('optPrivateKey', data[cn].privateKey); + this.appOptions.set('optCertificate', data[cn].cert); + this.webapp.updateOption('optPrivateKey', data[cn].privateKey); + this.webapp.updateOption('optCertificate', data[cn].cert); + this.setState({optPrivateKey: data[cn].privateKey, optCertificate: data[cn].cert}); + setTimeout(this.render, 50); // prevent race condition when ReactElement get set before opts have value + } + ui_ready() { + if (this.webapp) { + if (! (this.webapp.started || this.webapp.starting)) { + // autostart ? + this.webapp.start() + } + } + } + choose_folder() { + console.log('clicked choose folder') + function onfolder(folder) { + this.bg.onchoosefolder(folder) + } + chrome.fileSystem.chooseEntry({type:'openDirectory'}, onfolder.bind(this)) + } + startStop(evt, checked) { + console.log('startstop', checked) + if (checked) this.webapp.start() + else this.webapp.stop() + } + onChange(k, v) { + console.log('update and save',k,v) + this.webapp.updateOption(k,v) // also set on webapp.opts ? + // certain options require special manual handling (e.g. port has to set this.webapp.opts.port) + if (functions[k]) { + console.log('special handling for', k); + functions[k](this, k, v) + } + this.appOptions.set(k,v) + this.setState({[k]:v}) + } + render() { + // option: [dependencies] + const optDisplay = { + optBackground: null, + optAutoStart: ['optBackground'], + optAllInterfaces: null, + optDoPortMapping: ['optAllInterfaces'], + optPreventSleep: null, + optRenderIndex: null, + port: null, + }; + const optAdvanced = { + optCORS: null, + optIPV6: null, + optStatic: null, + optUpload: null, + optVerbose: null, + optModRewriteEnable: null, + optModRewriteRegexp: ['optModRewriteEnable'], + optModRewriteNegate: ['optModRewriteEnable'], + optModRewriteTo: ['optModRewriteEnable'], + optUseHttps: null + }; + const optHttpsInfo = { + optPrivateKey: null, + optCertificate: null + }; + console.assert(this); + + const renderOpts = (opts) => { + const _this = this; + const options = [Object.keys(opts).map(k => { + const deps = opts[k] || [] + let enabled = true + let indent = false + for (const dep of deps) { + indent = true + if (!this.state[dep]) { + enabled = false + } + } + return + })]; + return options; + } + + const options = renderOpts(optDisplay) + const advOptions = renderOpts(optAdvanced) + const advancedButton = () + + const httpsOptions = (() => { + let disable = (!this.webapp || !this.webapp.opts.optUseHttps); + let hasCrypto = this.webapp && (this.webapp.opts.optPrivateKey || this.webapp.opts.optCertificate); + const textBoxes = renderOpts(optHttpsInfo) + return [(
{!disable && textBoxes} + {hasCrypto && !disable && To regenerate, remove key and cert. Be sure to take a copy first, for possible later use!} + {!disable && } +
)]; + })(); + + const {state} = this; + return (
+ + + + + + + Web Server for Chrome + + + + + + +

Please leave a review to help others find this software. +

+
+
+ + + + + + + )} + /> + + + +
+ + {state.folder ? ` Current: ${state.folder}` : 'NO FOLDER SELECTED'} +
+ +

Web Server URL(s)

+
    + {state.interfaces.map((item) => { + return
  • {item.url}
  • + })} +
+
+
+ + + + + Options (may require restart) + + + {options} + + {advancedButton} + {state.showAdvanced &&
{advOptions}{httpsOptions}
} +
+
+ + + +

Need to Report a problem? + Open source, MIT license.

+
+
+
+ +
+
) + } +} + +const AppWithStyles = withStyles(styles)(App); + +ReactDOM.render(, document.getElementById('app')) + diff --git a/react-ui/js/options.js b/react-ui/js/options.js new file mode 100644 index 00000000..98b26163 --- /dev/null +++ b/react-ui/js/options.js @@ -0,0 +1,252 @@ +const { FormControlLabel } = MaterialUI +const { FormGroup } = MaterialUI +const { + Switch, + Checkbox, + Tooltip, + TextField, +} = MaterialUI + +export function AppOption({disabled, indent, name, value, appOptions, onChange: parentOnChange}) { + if (! appOptions) return 'Loading...' + const meta = appOptions.meta[name]; + const {type, label, validation} = meta; + + const [error, setError] = React.useState(false); + React.useEffect(() => { + // onChange(null, value) // why ? + }, []); + + function onChange(evt, inval) { + let val = inval === undefined ? evt.target.value : inval; + if (meta.process) val = meta.process(val) + console.log('onChange', val); + if (validation) { + const newError = !validation(val) + if (error != newError) { + setError(newError) + } + } + parentOnChange(name, val) + } + + function renderOption() { + switch(type) { + case Number: { + return ( + + ) + } + case Boolean: { + return ( + + )} + /> + + ) + } + case String: { + return ( + + ) + } + default: + return
Option with {name} ({meta.type}) - {appOptions.get(name)}
+ } + } + + return (
+ {renderOption()} +
) + +} + +const options = { + port: { + name: "Port", + label: 'Enter Port', + help: 'Which port the web server will listen on', + process: val => parseInt(val, 10), + validation: (val) => { + return val >= 1024 && val <= 65535 + }, + validationError: 'Enter a number between 1024 and 65535', + type: Number, + default: 8887 + }, + optAllInterfaces: { + label: 'Accessible on local network', + help: 'Make the web server available to other computers on the local area network', + type: Boolean, + default: false + }, + optDoPortMapping: { + label: 'Also on internet', + help: 'Attempt to open up a port on your internet router to make the server also available on the internet', + type: Boolean, + default: false + }, + optIPV6: { + label: 'Listen on IPV6', + help: 'To have the server listen with IPV6', + type: Boolean, + default: false + }, + optCORS: { + label: 'Set CORS headers', + help: 'To allow XMLHttpRequests from other origins', + type: Boolean, + default: false + }, + optVerbose: { + label: 'Verbose logging', + help: 'To see web server logs, (navigate to "chrome://inspect", Extensions)', + type: Boolean, + default: false + }, + optStatic: { + label: 'Plain (static) files view', + help: 'The files directory listing will not use any javascript', + type: Boolean, + default: false + }, + optTryOtherPorts: { + type: Boolean, + default: false + }, + optRetryInterfaces: { + type: Boolean, + visible: false, + default: true + }, + optPreventSleep: { + label: 'Prevent computer from sleeping', + help: 'If the server is running, prevent the computer from going into sleep mode', + type: Boolean, + default: false + }, + optBackground: { + label: 'Run in background', + help: 'Allow the web server to continue running, even if you close this window', + type: Boolean, + default: false + }, + optAutoStart: { + label: 'Start on login', + help: 'Start the web server when you login, even if the web server window is not opened', + depends: [{optBackground: true}], + type: Boolean, + default: false + }, + optRenderIndex: { + label: 'Automatically show index.html', + help: 'If the URL is a directory, automatically show an index.html if one is present', + type: Boolean, + default: true + }, + optUpload: { + label: 'Allow File upload', + help: 'The files directory listing allows drag-and-drop to upload small files', + type: Boolean, + default: false + }, + optModRewriteEnable: { + label: 'Enable mod-rewrite (for SPA)', + help: 'For SPA (single page apps) that support HTML5 history location', + type: Boolean, + default: false + }, + optModRewriteRegexp: { + label: 'Regular Expression', + help: 'Any URL matching this regular expression will be rewritten', + type: String, + default: ".*\\.[\\d\\w]+$" // looks like a file extension + }, + optModRewriteNegate: { + label: 'Negate Regexp', + help: 'Negate the matching logic in the regexp', + type: Boolean, + default: true + }, + optModRewriteTo: { + label: 'Rewrite To', + help: 'Which file to server instead of the actual path. For example, /index.html', + type: String, + default: '/index.html' + }, + optUseHttps: { + label: 'Use https://', + help: 'Serve pages through https://', + type: Boolean, + default: false + }, + optPrivateKey: { + label: 'Private key string', + help: "String containg private key, used in pair with certificate string.\nEdit them in pairs", + type: String + }, + optCertificate: { + label: 'Certificate string', + help: "String containg certificate, used in pair with private key string.\nEdit them in pairs", + type: String + } +} + +export class AppOptions { + constructor(callback) { + this.meta = options + this.options = null + + chrome.storage.local.get(null, function(d) { + this.options = d + // update options with default options + callback() + }.bind(this)) + } + get(k) { + if (this.options[k] !== undefined) return this.options[k] + return this.meta[k].default + } + getAll() { + var d = {} + Object.assign(d, this.options) + for (var key in this.meta) { + if (d[key] === undefined && this.meta[key].default !== undefined) { + d[key] = this.meta[key].default + } + } + return d + } + set(k,v) { + this.options[k] = v + var d = {} + d[k] = v + chrome.storage.local.set(d, function(){}) + } +} diff --git a/react-ui/package.json b/react-ui/package.json new file mode 100644 index 00000000..b86eadcb --- /dev/null +++ b/react-ui/package.json @@ -0,0 +1,11 @@ +{ + "scripts": { + "watch": "./node_modules/.bin/babel js --verbose --watch --out-dir nojsx --source-maps" + }, + "dependencies": { + "@babel/cli": "^7.7.0", + "@babel/core": "^7.7.2", + "@babel/plugin-syntax-class-properties": "^7.2.0", + "@babel/plugin-transform-react-jsx": "^7.7.0" + } +} diff --git a/stream.js b/stream.js index 03ff0478..098b727d 100644 --- a/stream.js +++ b/stream.js @@ -13,7 +13,6 @@ chrome.sockets.tcp.onReceive.addListener( onTCPReceive ) chrome.sockets.tcp.onReceiveError.addListener( onTCPReceive ) - var sockets = chrome.sockets function IOStream(sockId) { this.sockId = sockId peerSockMap[this.sockId] = this @@ -94,12 +93,17 @@ var data = this.writeBuffer.consume_any_max(4096) //console.log(this.sockId,'tcp.send',data.byteLength) //console.log(this.sockId,'tcp.send',WSC.ui82str(new Uint8Array(data))) - sockets.tcp.send( this.sockId, data, this.onWrite.bind(this, callback) ) + this._writeToTcp(data, this.onWrite.bind(this, callback)); }, write: function(data) { this.writeBuffer.add(data) this.tryWrite() }, + // may be overridden by StreamTls + _writeToTcp: function(data, cb) { + chrome.sockets.tcp.send( this.sockId, data, cb); + }, + onWrite: function(callback, evt) { var err = chrome.runtime.lastError if (err) { @@ -145,11 +149,15 @@ this.log('remote killed connection',evt.resultCode) this.error({message:'error code',errno:evt.resultCode}) } else { - this.readBuffer.add(evt.data) - if (this.onread) { this.onread() } - this.checkBuffer() + this._fillReadBuffer(evt.data) } }, + // specialized so IOStreamTls can subclass + _fillReadBuffer: function(data) { + this.readBuffer.add(data); + if (this.onread) { this.onread() } + this.checkBuffer() + }, log: function(msg,msg2,msg3) { if (WSC.VERBOSE) { console.log(this.sockId,msg,msg2,msg3) @@ -185,7 +193,7 @@ this.runCloseCallbacks() //console.log('tcp sock close',this.sockId) delete peerSockMap[this.sockId] - sockets.tcp.close(this.sockId, this.onClosed.bind(this,reason)) + chrome.sockets.tcp.close(this.sockId, this.onClosed.bind(this,reason)) //this.sockId = null this.cleanup() }, @@ -219,13 +227,96 @@ return } console.log(this.sockId,'tryClose') - sockets.tcp.send(this.sockId, new ArrayBuffer, callback) + chrome.sockets.tcp.send(this.sockId, new ArrayBuffer, callback) }, cleanup: function() { this.writeBuffer = new WSC.Buffer } } + + var arrayBuffer2String = function(buf) { + var bufView = new Uint8Array(buf); + var chunkSize = 65536; + var result = ''; + for (var i = 0; i < bufView.length; i += chunkSize) { + result += String.fromCharCode.apply(null, bufView.subarray(i, Math.min(i + chunkSize, bufView.length))); + } + return result; + } + + + var IOStreamTls = function(sockId, privateKey, serverCert) { + this.writeCallbacks = []; + this.readCallbacks = []; + var _t = this; + + this.tlsServer = forge.tls.createConnection({ + server: true, + sessionCache: {}, + // supported cipher suites in order of preference + cipherSuites: [ + forge.tls.CipherSuites.TLS_RSA_WITH_AES_128_CBC_SHA, + forge.tls.CipherSuites.TLS_RSA_WITH_AES_256_CBC_SHA], + connected: function(c) { + //console.log('Server connected'); + //c.prepareHeartbeatRequest('heartbeat'); + }, + verifyClient: false, + getCertificate: function(c, hint) { + //console.log('Server getting certificate for \"' + hint[0] + '\"...'); + return serverCert; //WSC.Tls.data.server.cert; + }, + getPrivateKey: function(c, cert) { + //console.log('Server getting privateKey for \"' + cert + '\"...'); + return privateKey;//WSC.Tls.data.server.privateKey; + }, + tlsDataReady: function(c) { + // send TLS data to client + var cb = _t.writeCallbacks.pop() || function(){}; + let str = c.tlsData.getBytes(); + var b = WSC.str2ab(str); + //console.log('encrypt to client: ' + str); + if (this.connected) + chrome.sockets.tcp.send( _t.sockId, b, cb); + else + _t.error("tlsData on closed socket"); + }, + dataReady: function(c) { + // decrypted data from client + let str = c.data.getBytes(); + //console.log('client sent \"' + str + '\"'); + _t.readBuffer.add(WSC.str2ab(str)); + if (_t.onread) { _t.onread() } + _t.checkBuffer() + }, + heartbeatReceived: function(c, payload) { + //console.log('Server received heartbeat: ' + payload.getBytes()); + }, + closed: function(c) { + console.log('Server disconnected.'); + }, + error: function(c, error) { + console.log(error.origin + ' error: ' + error.message + ' at level:' + error.alert.level + ' desc:' + error.alert.description); + } + }); + + IOStream.apply(this, arguments); + } + IOStreamTls.prototype = { + _writeToTcp: function(data, cb) { + let s = WSC.ui82str(new Uint8Array(data)); + this.writeCallbacks.push(cb); + this.tlsServer.prepare(s); + }, + _fillReadBuffer: function(data) { + let str = arrayBuffer2String(data); + let n = this.tlsServer.process(str); + } + }; + IOStreamTls.prototype.__proto__ = IOStream.prototype; + + WSC.IOStreamTls = IOStreamTls; WSC.IOStream = IOStream; })(); diff --git a/upnp.js b/upnp.js index cf0e9eeb..10d5c826 100644 --- a/upnp.js +++ b/upnp.js @@ -373,7 +373,7 @@ this.searching = false // clear out all sockets in sockmap this.cleanup() - this.allDone(false) + if (this.allDone) this.allDone(false) }, cleanup: function() { for (var socketId in this.sockMap) { diff --git a/webapp.js b/webapp.js index 7f630340..584456b8 100644 --- a/webapp.js +++ b/webapp.js @@ -1,5 +1,4 @@ (function(){ - var sockets = chrome.sockets function WebApplication(opts) { // need to support creating multiple WebApplication... @@ -30,6 +29,10 @@ this.error('error setting up retained entry') } }.bind(this)) + //Unchecked runtime.lastError while running fileSystem.restoreEntry: Error getting fileEntry, code: 0 + /* + Select a folder in "Linux files" (ChromeOS) and then delete the folder. You will get this error. If you re-create the folder with the same name and path, it will work again. + */ } if (opts.entry) { this.on_entry(opts.entry) @@ -60,6 +63,8 @@ } }, updateOption: function(k,v) { + if (WSC.VERBOSE) console.log('updateOption', k, v) + this.opts[k] = v switch(k) { case 'optDoPortMapping': @@ -88,6 +93,7 @@ lasterr: this.lasterr } }, + createCrypto: WSC.createCrypto, updatedSleepSetting: function() { if (! this.started) { chrome.power.releaseKeepAwake() @@ -320,20 +326,22 @@ //console.log('onListen',result) this.starting = false this.started = true - console.log('Listening on','http://'+ this.get_host() + ':' + this.port+'/') + let prot = this.opts.optUseHttps ? 'https' : 'http'; + console.log('Listening on',prot+'://'+ this.get_host() + ':' + this.port+'/') this.bindAcceptCallbacks() this.init_urls() this.start_success({urls:this.urls}) // initialize URLs ? }, init_urls: function() { this.urls = [].concat(this.extra_urls) - this.urls.push({url:'http://127.0.0.1:' + this.port}) + let prot = this.opts.optUseHttps ? 'https' : 'http'; + this.urls.push({url:prot+'://127.0.0.1:' + this.port}) for (var i=0; i 24) { - this.urls.push({url:'http://['+iface.address+']:' + this.port}) + if (iface.prefixLength === 64) { + this.urls.push({url:prot+'://['+iface.address+']:' + this.port}) } else { - this.urls.push({url:'http://'+iface.address+':' + this.port}) + this.urls.push({url:prot+'://'+iface.address+':' + this.port}) } } return this.urls @@ -342,7 +350,7 @@ return this.port + i*3 + Math.pow(i,2)*2 }, tryListenOnPort: function(state, callback) { - sockets.tcpServer.getSockets( function(sockets) { + chrome.sockets.tcpServer.getSockets( function(sockets) { if (sockets.length == 0) { this.doTryListenOnPort(state, callback) } else { @@ -361,15 +369,15 @@ }, doTryListenOnPort: function(state, callback) { var opts = this.opts.optBackground ? {name:"WSCListenSocket", persistent:true} : {} - sockets.tcpServer.create(opts, this.onServerSocket.bind(this,state,callback)) + chrome.sockets.tcpServer.create(opts, this.onServerSocket.bind(this,state,callback)) }, onServerSocket: function(state,callback,sockInfo) { var host = this.get_host() this.sockInfo = sockInfo var tryPort = this.computePortRetry(state.port_attempts) - state.port_attempts++ - //console.log('attempting to listen on port',host,tryPort) - sockets.tcpServer.listen(this.sockInfo.socketId, + state.port_attempts++; + console.log('attempting to listen on port',host,tryPort) + chrome.sockets.tcpServer.listen(this.sockInfo.socketId, host, tryPort, function(result) { @@ -395,7 +403,7 @@ console.log('network interfaces',result) if (result) { for (var i=0; i= 24) { if (result[i].address.startsWith('fe80::')) { continue } this.interfaces.push(result[i]) console.log('found interface address: ' + result[i].address) @@ -455,8 +463,8 @@ } }, bindAcceptCallbacks: function() { - sockets.tcpServer.onAcceptError.addListener(this.onAcceptError.bind(this)) - sockets.tcpServer.onAccept.addListener(this.onAccept.bind(this)) + chrome.sockets.tcpServer.onAcceptError.addListener(this.onAcceptError.bind(this)) + chrome.sockets.tcpServer.onAccept.addListener(this.onAccept.bind(this)) }, onAcceptError: function(acceptInfo) { if (acceptInfo.socketId != this.sockInfo.socketId) { return } @@ -468,7 +476,13 @@ //console.log('onAccept',acceptInfo,this.sockInfo) if (acceptInfo.socketId != this.sockInfo.socketId) { return } if (acceptInfo.socketId) { - var stream = new WSC.IOStream(acceptInfo.clientSocketId) + let stream; + if (this.opts.optUseHttps) { + //this._initializeTls(); + //this._tls.handshake(null); // No handshake in server mode + stream = new WSC.IOStreamTls(acceptInfo.clientSocketId, this.opts.optPrivateKey, this.opts.optCertificate); + } else + stream = new WSC.IOStream(acceptInfo.clientSocketId) this.adopt_stream(acceptInfo, stream) } }, @@ -563,7 +577,8 @@ handler.finish() } } - } + }; + function BaseHandler() { this.headersWritten = false @@ -574,7 +589,7 @@ } _.extend(BaseHandler.prototype, { options: function() { - if (this.app.optCORS) { + if (this.app.opts.optCORS) { this.set_status(200) this.finish() } else { @@ -584,7 +599,7 @@ }, setCORS: function() { this.setHeader('access-control-allow-origin','*') - this.setHeader('access-control-allow-methods','GET, POST') + this.setHeader('access-control-allow-methods','GET, POST, PUT') this.setHeader('access-control-max-age','120') }, get_argument: function(key,def) { @@ -692,7 +707,7 @@ Changes with nginx 0.7.9 12 Aug 2008 } this.responseData = [] if (opt_finish !== false) { - this.finish() + this.finish() } }, finish: function() { @@ -708,10 +723,13 @@ Changes with nginx 0.7.9 12 Aug 2008 //console.log('webapp.finish(keepalive)') } } else { + console.assert(! this.request.connection.stream.onWriteBufferEmpty) + this.request.connection.stream.onWriteBufferEmpty = () => { this.request.connection.close() if (WSC.DEBUG) { //console.log('webapp.finish(close)') } + } } } }) @@ -720,14 +738,14 @@ Changes with nginx 0.7.9 12 Aug 2008 this.entry = entry } _.extend(FileSystem.prototype, { - getByPath: function(path, callback) { + getByPath: function(path, callback, allowFolderCreation) { if (path == '/') { callback(this.entry) return } var parts = path.split('/') var newpath = parts.slice(1,parts.length) - WSC.recursiveGetEntry(this.entry, newpath, callback) + WSC.recursiveGetEntry(this.entry, newpath, callback, allowFolderCreation) } })