Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial version of the inline powerbox #1398

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions shell/client/grainview.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down
1 change: 1 addition & 0 deletions shell/client/shell.html
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ <h4>Notice from Admin</h4>
{{/if}}
</div>
{{/with}}
<input type="text" class="inline-powerbox">
</template>

<template name="invalidToken">
Expand Down
5 changes: 5 additions & 0 deletions shell/client/shell.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
34 changes: 34 additions & 0 deletions shell/server/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please update the schema in db.js for this new variant of ApiToken.frontendRef.


return sturdyRef.toString();
}
});

saveFrontendRef = (frontendRef, owner, requirements) => {
Expand Down Expand Up @@ -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.');
}
Expand Down
266 changes: 147 additions & 119 deletions shell/server/drivers/external-ui-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)};
}
};

Expand Down Expand Up @@ -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 || {};
}

Expand Down Expand Up @@ -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();
}
});
});
}
};
Loading