diff --git a/shell/client/grainview.js b/shell/client/grainview.js
index 7bf8e051e5..f6ee1ab0f6 100644
--- a/shell/client/grainview.js
+++ b/shell/client/grainview.js
@@ -42,6 +42,8 @@ GrainView = class GrainView {
this.revealIdentity();
}
+ this.enableInlinePowerbox = new ReactiveVar(false);
+
// We manage our Blaze view directly in order to get more control over when iframes get
// re-rendered. E.g. if we were to instead use a template with {{#each grains}} iterating over
// the list of open grains, all grains might get re-rendered whenever a grain is removed from the
@@ -617,6 +619,18 @@ GrainView = class GrainView {
this._generatedApiToken = newApiToken;
this._dep.changed();
}
+
+ startInlinePowerbox(inlinePowerboxState) {
+ this.inlinePowerboxState = inlinePowerboxState;
+ if (inlinePowerboxState.isForeground) {
+ this.enableInlinePowerbox.set(true);
+ } else {
+ state.source.postMessage({
+ rpcId: inlinePowerboxState.rpcId,
+ error: "Cannot start inline powerbox when app is not in foreground",
+ }, inlinePowerboxState.origin);
+ }
+ }
};
const onceConditionIsTrue = (condition, continuation) => {
diff --git a/shell/client/shell.html b/shell/client/shell.html
index 9343663632..af64ae8bf0 100644
--- a/shell/client/shell.html
+++ b/shell/client/shell.html
@@ -155,6 +155,7 @@
Notice from Admin
{{/if}}
{{/with}}
+
diff --git a/shell/client/shell.scss b/shell/client/shell.scss
index 0399b06895..3d82ffd339 100644
--- a/shell/client/shell.scss
+++ b/shell/client/shell.scss
@@ -450,3 +450,8 @@ body>.popup.admin-alert>.frame>p {
font-size: 30px;
}
}
+
+.inline-powerbox {
+ // Hack to make it effectively invisible, but not be treated as un-focusable by browsers
+ margin: -10000px;
+}
diff --git a/shell/server/core.js b/shell/server/core.js
index 47feab5b1a..a264b335cb 100644
--- a/shell/server/core.js
+++ b/shell/server/core.js
@@ -226,6 +226,38 @@ Meteor.methods({
Notifications.update({userId: Meteor.userId()}, {$set: {isUnread: false}}, {multi: true});
},
+ offerExternalWebSession(grainId, identityId, url) {
+ check(grainId, String);
+ check(identityId, String);
+ check(url, String);
+
+ const db = this.connection.sandstormDb;
+
+ // Check that the the identityId matches and has permission to view this grain
+ if (!db.userHasIdentity(Meteor.userId(), identityId)) {
+ throw new Meteor.Error(403, "Logged in user doesn't own the supplied identity.");
+ }
+ const requirement = {
+ permissionsHeld: {
+ grainId: grainId,
+ identityId: identityId,
+ permissions: [], // We only want to check for the implicit view permission
+ },
+ };
+ if (!checkRequirements([requirement])) {
+ throw new Meteor.Error(403, "This identity doesn't have view permissions to the grain.");
+ }
+ const requirements = []; // We don't actually want the user's permission check as a requirement.
+ const grainOwner = {grain: {
+ grainId: grainId,
+ introducerIdentity: identityId,
+ saveLabel: url + " websession",
+ }};
+ const sturdyRef = waitPromise(saveFrontendRef(
+ {externalWebSession: {url: url}}, grainOwner, requirements)).sturdyRef;
+
+ return sturdyRef.toString();
+ }
});
saveFrontendRef = (frontendRef, owner, requirements) => {
@@ -320,6 +352,8 @@ restoreInternal = (tokenId, ownerPattern, requirements, parentToken) => {
return {cap: makeIpNetwork(tokenId)};
} else if (token.frontendRef.ipInterface) {
return {cap: makeIpInterface(tokenId)};
+ } else if (token.frontendRef.externalWebSession) {
+ return {cap: makeExternalWebSession(token.frontendRef.externalWebSession.url)};
} else {
throw new Meteor.Error(500, 'Unknown frontend token type.');
}
diff --git a/shell/server/drivers/external-ui-view.js b/shell/server/drivers/external-ui-view.js
index 39ad25276d..b50ef36b97 100644
--- a/shell/server/drivers/external-ui-view.js
+++ b/shell/server/drivers/external-ui-view.js
@@ -20,7 +20,9 @@ const Capnp = Npm.require('capnp');
const Url = Npm.require('url');
const Http = Npm.require('http');
const Https = Npm.require('https');
+const Dns = Npm.require('dns');
const ApiSession = Capnp.importSystem('sandstorm/api-session.capnp').ApiSession;
+const WebSession = Capnp.importSystem('sandstorm/web-session.capnp').WebSession;
WrappedUiView = class WrappedUiView {
constructor(token, proxy) {
@@ -83,7 +85,7 @@ ExternalUiView = class ExternalUiView {
};
}
- return {session: new Capnp.Capability(new ExternalWebSession(this.url, this.grainId, options), ApiSession)};
+ return {session: new Capnp.Capability(new ExternalWebSession(this.url, options), ApiSession)};
}
};
@@ -125,13 +127,17 @@ const responseCodes = {
505: {type: 'serverError'},
};
+makeExternalWebSession = function (url, options) {
+ return new Capnp.Capability(new ExternalWebSession(url, options), WebSession);
+}
+
ExternalWebSession = class ExternalWebSession {
- constructor(url, grainId, options) {
+ constructor(url, options) {
const parsedUrl = Url.parse(url);
this.host = parsedUrl.hostname;
this.port = parsedUrl.port;
this.protocol = parsedUrl.protocol;
- this.grainId = grainId;
+ this.path = parsedUrl.path;
this.options = options || {};
}
@@ -165,129 +171,151 @@ ExternalWebSession = class ExternalWebSession {
const _this = this;
const session = _this;
return new Promise((resolve, reject) => {
- const options = _.clone(session.options);
- options.headers = options.headers || {};
- options.path = path;
- options.method = method;
- if (contentType) {
- options.headers['content-type'] = contentType;
- }
-
- // set accept header
- if ('accept' in context) {
- options.headers.accept = context.accept.map((acceptedType) => {
- return acceptedType.mimeType + '; ' + acceptedType.qValue;
- }).join(', ');
- } else if (!('accept' in options.headers)) {
- options.headers.accept = '*/*';
- }
-
- // set cookies
- if (context.cookies && context.cookies.length > 0) {
- options.headers.cookies = options.headers.cookies || '';
- context.cookies.forEach((keyVal) => {
- options.headers.cookies += keyVal.key + '=' + keyVal.val + ',';
- });
- options.headers.cookies = options.headers.cookies.slice(0, -1);
- }
-
- options.host = session.host;
- options.port = session.port;
-
- let requestMethod = Http.request;
- if (session.protocol === 'https:') {
- requestMethod = Https.request;
- }
-
- req = requestMethod(options, (resp) => {
- const buffers = [];
- const statusInfo = responseCodes[resp.statusCode];
-
- const rpcResponse = {};
-
- switch (statusInfo.type) {
- case 'content':
- resp.on('data', (buf) => {
- buffers.push(buf);
- });
-
- resp.on('end', () => {
- const content = {};
- rpcResponse.content = content;
-
- content.statusCode = statusInfo.code;
- if ('content-encoding' in resp.headers) content.encoding = resp.headers['content-encoding'];
- if ('content-language' in resp.headers) content.language = resp.headers['content-language'];
- if ('content-type' in resp.headers) content.language = resp.headers['content-type'];
- if ('content-disposition' in resp.headers) {
- const disposition = resp.headers['content-disposition'];
- const parts = disposition.split(';');
- if (parts[0].toLowerCase().trim() === 'attachment') {
- parts.forEach((part) => {
- const splitPart = part.split('=');
- if (splitPart[0].toLowerCase().trim() === 'filename') {
- content.disposition = {download: splitPart[1].trim()};
- }
- });
+ Dns.lookup(_this.host, 4, (err, address) => { // TODO(someday): handle ipv6
+ if (err) {
+ reject(err);
+ return;
+ }
+ if (address.lastIndexOf("10.", 0) === 0 ||
+ address.lastIndexOf("127.", 0) === 0 ||
+ address.lastIndexOf("192.168.", 0) === 0) {
+ // Block the most commonly used private ip ranges as a security measure.
+ reject("Domain resolved to an invalid IP: " + address);
+ return;
+ }
+ const options = _.clone(session.options);
+ options.headers = options.headers || {};
+ if (_this.path) {
+ options.path = _this.path;
+ if (_this.path[_this.path.length - 1] !== "/") {
+ options.path += "/";
+ }
+ options.path += path;
+ } else {
+ options.path = path;
+ }
+ options.path = (_this.path || "") + path;
+ options.method = method;
+ if (contentType) {
+ options.headers['content-type'] = contentType;
+ }
+
+ // set accept header
+ if ('accept' in context) {
+ options.headers.accept = context.accept.map((acceptedType) => {
+ return acceptedType.mimeType + '; ' + acceptedType.qValue;
+ }).join(', ');
+ } else if (!('accept' in options.headers)) {
+ options.headers.accept = '*/*';
+ }
+
+ // set cookies
+ if (context.cookies && context.cookies.length > 0) {
+ options.headers.cookies = options.headers.cookies || '';
+ context.cookies.forEach((keyVal) => {
+ options.headers.cookies += keyVal.key + '=' + keyVal.val + ',';
+ });
+ options.headers.cookies = options.headers.cookies.slice(0, -1);
+ }
+
+ options.host = session.host;
+ options.port = session.port;
+
+ let requestMethod = Http.request;
+ if (session.protocol === 'https:') {
+ requestMethod = Https.request;
+ }
+
+ req = requestMethod(options, (resp) => {
+ const buffers = [];
+ const statusInfo = responseCodes[resp.statusCode];
+
+ const rpcResponse = {};
+
+ switch (statusInfo.type) {
+ case 'content':
+ resp.on('data', (buf) => {
+ buffers.push(buf);
+ });
+
+ resp.on('end', () => {
+ const content = {};
+ rpcResponse.content = content;
+
+ content.statusCode = statusInfo.code;
+ if ('content-encoding' in resp.headers) content.encoding = resp.headers['content-encoding'];
+ if ('content-language' in resp.headers) content.language = resp.headers['content-language'];
+ if ('content-type' in resp.headers) content.language = resp.headers['content-type'];
+ if ('content-disposition' in resp.headers) {
+ const disposition = resp.headers['content-disposition'];
+ const parts = disposition.split(';');
+ if (parts[0].toLowerCase().trim() === 'attachment') {
+ parts.forEach((part) => {
+ const splitPart = part.split('=');
+ if (splitPart[0].toLowerCase().trim() === 'filename') {
+ content.disposition = {download: splitPart[1].trim()};
+ }
+ });
+ }
}
- }
- content.body = {};
- content.body.bytes = Buffer.concat(buffers);
+ content.body = {};
+ content.body.bytes = Buffer.concat(buffers);
+ resolve(rpcResponse);
+ });
+ break;
+ case 'noContent':
+ const noContent = {};
+ rpcResponse.noContent = noContent;
+ noContent.setShouldResetForm = statusInfo.shouldResetForm;
resolve(rpcResponse);
- });
- break;
- case 'noContent':
- const noContent = {};
- rpcResponse.noContent = noContent;
- noContent.setShouldResetForm = statusInfo.shouldResetForm;
- resolve(rpcResponse);
- break;
- case 'redirect':
- const redirect = {};
- rpcResponse.redirect = redirect;
- redirect.isPermanent = statusInfo.isPermanent;
- redirect.switchToGet = statusInfo.switchToGet;
- if ('location' in resp.headers) redirect.location = resp.headers.location;
- resolve(rpcResponse);
- break;
- case 'clientError':
- const clientError = {};
- rpcResponse.clientError = clientError;
- clientError.statusCode = statusInfo.clientErrorCode;
- clientError.descriptionHtml = statusInfo.descriptionHtml;
- resolve(rpcResponse);
- break;
- case 'serverError':
- const serverError = {};
- rpcResponse.serverError = serverError;
- clientError.descriptionHtml = statusInfo.descriptionHtml;
- resolve(rpcResponse);
- break;
- default: // ???
- err = new Error('Invalid status code ' + resp.statusCode + ' received in response.');
- reject(err);
- break;
- }
- });
+ break;
+ case 'redirect':
+ const redirect = {};
+ rpcResponse.redirect = redirect;
+ redirect.isPermanent = statusInfo.isPermanent;
+ redirect.switchToGet = statusInfo.switchToGet;
+ if ('location' in resp.headers) redirect.location = resp.headers.location;
+ resolve(rpcResponse);
+ break;
+ case 'clientError':
+ const clientError = {};
+ rpcResponse.clientError = clientError;
+ clientError.statusCode = statusInfo.clientErrorCode;
+ clientError.descriptionHtml = statusInfo.descriptionHtml;
+ resolve(rpcResponse);
+ break;
+ case 'serverError':
+ const serverError = {};
+ rpcResponse.serverError = serverError;
+ clientError.descriptionHtml = statusInfo.descriptionHtml;
+ resolve(rpcResponse);
+ break;
+ default: // ???
+ err = new Error('Invalid status code ' + resp.statusCode + ' received in response.');
+ reject(err);
+ break;
+ }
+ });
- req.on('error', (e) => {
- reject(e);
- });
+ req.on('error', (e) => {
+ reject(e);
+ });
- req.setTimeout(15000, () => {
- req.abort();
- err = new Error('Request timed out.');
- err.kjType = 'overloaded';
- reject(err);
- });
+ req.setTimeout(15000, () => {
+ req.abort();
+ err = new Error('Request timed out.');
+ err.kjType = 'overloaded';
+ reject(err);
+ });
- if (content) {
- req.end(content);
- } else {
- req.end();
- }
+ if (content) {
+ req.end(content);
+ } else {
+ req.end();
+ }
+ });
});
}
};
diff --git a/shell/shared/grain.js b/shell/shared/grain.js
index 63c4d62e10..16233eaee3 100644
--- a/shell/shared/grain.js
+++ b/shell/shared/grain.js
@@ -814,6 +814,80 @@ if (Meteor.isClient) {
}
});
+ Template.grainView.onCreated(function () {
+ var template = Template.instance();
+ template.savedKeys = [];
+ this.autorun(function () {
+ var inlinePowerbox = template.data.enableInlinePowerbox.get();
+ if (inlinePowerbox) {
+ template.find(".inline-powerbox").focus();
+ }
+ });
+ });
+ function handleInlinePowerboxKey (event, template) {
+ var state = template.data.inlinePowerboxState;
+ switch (event.keyCode) {
+ case 13: // Enter
+ case 32: // Space
+ var url = template.savedKeys.map(function (char) {
+ return String.fromCharCode(char);
+ }).join("");
+ if (url.lastIndexOf("http", 0) === 0) {
+ var activeGrain = getActiveGrain(globalGrains.get());
+ Meteor.call("offerExternalWebSession",
+ activeGrain.grainId(), activeGrain.identityId(), url, function (err, sturdyRef) {
+ if (err) {
+ // TODO(someday): do something
+ return;
+ }
+ state.source.postMessage({
+ rpcId: state.rpcId,
+ event: {
+ webSession: {
+ token: sturdyRef,
+ },
+ },
+ }, state.origin);
+ }
+ );
+ }
+ // Start over
+ template.savedKeys = [];
+ break;
+ default:
+ template.savedKeys.push(event.keyCode);
+ }
+ state.source.postMessage({
+ rpcId: state.rpcId,
+ event: {
+ keyCode: event.keyCode,
+ },
+ }, state.origin);
+ }
+ Template.grainView.events({
+ "blur .inline-powerbox": function (event, template) {
+ template.data.enableInlinePowerbox.set(false);
+ },
+ "keydown .inline-powerbox": function (event, template) {
+ var keyCode = event.keyCode;
+ if (keyCode === 8) { // backspace
+ handleInlinePowerboxKey(event, template);
+ }
+ },
+ "keypress .inline-powerbox": handleInlinePowerboxKey,
+ "testInput .inline-powerbox": function (ev, template) {
+ // This custom event is used solely for testing purposes. See tests/apps/powerbox.js.
+ _.each(ev.originalEvent.detail.keys, function (key) {
+ handleInlinePowerboxKey({keyCode: key.charCodeAt(0)}, template);
+ });
+ },
+ "paste .inline-powerbox": function (event, template) {
+ _.each(event.originalEvent.clipboardData.getData('text'), function (key) {
+ handleInlinePowerboxKey({keyCode: key.charCodeAt(0)}, template);
+ });
+ },
+ })
+
Template.grainView.helpers({
unpackedGrainState: function () {
return mapGrainStateToTemplateData(this);
@@ -831,7 +905,7 @@ if (Meteor.isClient) {
var grain = getActiveGrain(globalGrains.get());
return {identities: identities,
onPicked: function(identityId) { grain.revealIdentity(identityId) }};
- }
+ },
});
Template.grain.helpers({
@@ -1358,6 +1432,19 @@ if (Meteor.isClient) {
return "remove";
}
});
+ } else if (event.data.inlinePowerbox) {
+ var inlinePowerbox = event.data.inlinePowerbox;
+ check(inlinePowerbox, Object);
+ var rpcId = inlinePowerbox.rpcId;
+ var currentGrain = getActiveGrain(globalGrains.get());
+ var inlinePowerboxState = {
+ source: event.source,
+ rpcId: rpcId,
+ grainId: senderGrain.grainId(),
+ origin: event.origin,
+ isForeground: senderGrain === currentGrain,
+ };
+ senderGrain.startInlinePowerbox(inlinePowerboxState);
} else {
console.log("postMessage from app not understood: " + event.data);
console.log(event);
diff --git a/tests/apps/powerbox.js b/tests/apps/powerbox.js
index 07f07939d8..e52313b367 100644
--- a/tests/apps/powerbox.js
+++ b/tests/apps/powerbox.js
@@ -55,7 +55,8 @@ module.exports["Test Powerbox"] = function (browser) {
.click("#powerbox-request-form button")
.frame("grain-frame")
.waitForElementVisible("#request-result", short_wait)
- .assert.containsText("#request-result", "request: footest");
+ .assert.containsText("#request-result", "request: footest")
+ .end();
});
};
@@ -87,7 +88,8 @@ module.exports["Test PowerboxSave"] = function (browser) {
.click("#powerbox-request-form button")
.frame("grain-frame")
.waitForElementVisible("#request-result", short_wait)
- .assert.containsText("#request-result", "request: footest");
+ .assert.containsText("#request-result", "request: footest")
+ .end();
});
};
@@ -129,7 +131,70 @@ module.exports["Test Powerbox with failing requirements"] = function (browser) {
.switchWindow(windows.value[1])
.waitForElementVisible(".grainlog-contents > pre", short_wait)
.assert.containsText(".grainlog-contents > pre", "Error: Requirements not satisfied")
+ .end();
});
})
+};
+
+// This tests the basic functionality of the inline powerbox.
+// Source: https://github.com/jparyani/sandstorm-test-app/tree/inline-powerbox
+module.exports["Install Inline Powerbox"] = function (browser) {
+ browser
+ .init()
+ .installApp("http://sandstorm.io/apps/jparyani/inline-powebox-test-1.spk", "b58a280b3ca4dc72c8fc4c7b41d3e03d", "8stkfx4ez54109qzmzjtthaq105nf7f4sqfdzzp00g22p1r3uxg0")
+ .assert.containsText("#grainTitle", "Untitled InlinePowerboxTest");
+};
+
+module.exports["Test Inline Powerbox"] = function (browser) {
+ browser
+ .waitForElementVisible("#grain-frame", short_wait)
+ .frame("grain-frame")
+ .waitForElementVisible("#inline-powerbox", short_wait)
+ .click("#inline-powerbox")
+ .frame()
+ .execute(function () {
+ // Sandstorm's inline powerbox defines a special event called testInput for testing purposes
+ var ev = new CustomEvent("testInput", {
+ detail: {
+ keys: "https://sandstorm.io/apps/jparyani/test_inline_powerbox ",
+ },
+ bubbles: false,
+ cancelable: true,
+ });
+ document.querySelector(".inline-powerbox").dispatchEvent(ev);
+ })
+ .frame("grain-frame")
+ .waitForElementVisible("#request-result", short_wait)
+ .assert.containsText("#request-result", "successfully fetched page");
+};
+
+module.exports["Install Faling Inline Powerbox"] = function (browser) {
+ browser
+ .init()
+ .installApp("http://sandstorm.io/apps/jparyani/inline-powebox-test-1.spk", "b58a280b3ca4dc72c8fc4c7b41d3e03d", "8stkfx4ez54109qzmzjtthaq105nf7f4sqfdzzp00g22p1r3uxg0")
+ .assert.containsText("#grainTitle", "Untitled InlinePowerboxTest");
+};
+
+module.exports["Test Faling Inline Powerbox"] = function (browser) {
+ browser
+ .waitForElementVisible("#grain-frame", short_wait)
+ .frame("grain-frame")
+ .waitForElementVisible("#inline-powerbox", short_wait)
+ .click("#inline-powerbox")
+ .frame()
+ .execute(function () {
+ // Sandstorm's inline powerbox defines a special event called testInput for testing purposes
+ var ev = new CustomEvent("testInput", {
+ detail: {
+ keys: "http://local.sandstorm.io " // This resolves to 127.0.0.1
+ },
+ bubbles: false,
+ cancelable: true,
+ });
+ document.querySelector(".inline-powerbox").dispatchEvent(ev);
+ })
+ .frame("grain-frame")
+ .waitForElementPresent("#request-error", short_wait)
+ .assert.containsText("#request-error", "Domain resolved to an invalid IP")
.end();
};