Skip to content

Commit 5b682ae

Browse files
committed
Initial version of the inline powerbox
It's still a bit rough around the edges, but the basic functionality works.
1 parent c4cf509 commit 5b682ae

File tree

7 files changed

+351
-120
lines changed

7 files changed

+351
-120
lines changed

shell/client/grainview.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ GrainView = class GrainView {
4242
this.revealIdentity();
4343
}
4444

45+
this.enableInlinePowerbox = new ReactiveVar(false);
46+
4547
// We manage our Blaze view directly in order to get more control over when iframes get
4648
// re-rendered. E.g. if we were to instead use a template with {{#each grains}} iterating over
4749
// 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 {
617619
this._generatedApiToken = newApiToken;
618620
this._dep.changed();
619621
}
622+
623+
startInlinePowerbox(inlinePowerboxState) {
624+
this.inlinePowerboxState = inlinePowerboxState;
625+
if (inlinePowerboxState.isForeground) {
626+
this.enableInlinePowerbox.set(true);
627+
} else {
628+
state.source.postMessage({
629+
rpcId: inlinePowerboxState.rpcId,
630+
error: "Cannot start inline powerbox when app is not in foreground",
631+
}, inlinePowerboxState.origin);
632+
}
633+
}
620634
};
621635

622636
const onceConditionIsTrue = (condition, continuation) => {

shell/client/shell.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ <h4>Notice from Admin</h4>
155155
{{/if}}
156156
</div>
157157
{{/with}}
158+
<input type="text" class="inline-powerbox">
158159
</template>
159160

160161
<template name="invalidToken">

shell/client/shell.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,3 +450,8 @@ body>.popup.admin-alert>.frame>p {
450450
font-size: 30px;
451451
}
452452
}
453+
454+
.inline-powerbox {
455+
// Hack to make it effectively invisible, but not be treated as un-focusable by browsers
456+
margin: -10000px;
457+
}

shell/server/core.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,38 @@ Meteor.methods({
226226

227227
Notifications.update({userId: Meteor.userId()}, {$set: {isUnread: false}}, {multi: true});
228228
},
229+
offerExternalWebSession(grainId, identityId, url) {
230+
check(grainId, String);
231+
check(identityId, String);
232+
check(url, String);
233+
234+
const db = this.connection.sandstormDb;
235+
236+
// Check that the the identityId matches and has permission to view this grain
237+
if (!db.userHasIdentity(Meteor.userId(), identityId)) {
238+
throw new Meteor.Error(403, "Logged in user doesn't own the supplied identity.");
239+
}
240+
const requirement = {
241+
permissionsHeld: {
242+
grainId: grainId,
243+
identityId: identityId,
244+
permissions: [], // We only want to check for the implicit view permission
245+
},
246+
};
247+
if (!checkRequirements([requirement])) {
248+
throw new Meteor.Error(403, "This identity doesn't have view permissions to the grain.");
249+
}
250+
const requirements = []; // We don't actually want the user's permission check as a requirement.
251+
const grainOwner = {grain: {
252+
grainId: grainId,
253+
introducerIdentity: identityId,
254+
saveLabel: url + " websession",
255+
}};
256+
const sturdyRef = waitPromise(saveFrontendRef(
257+
{externalWebSession: {url: url}}, grainOwner, requirements)).sturdyRef;
258+
259+
return sturdyRef.toString();
260+
}
229261
});
230262

231263
saveFrontendRef = (frontendRef, owner, requirements) => {
@@ -320,6 +352,8 @@ restoreInternal = (tokenId, ownerPattern, requirements, parentToken) => {
320352
return {cap: makeIpNetwork(tokenId)};
321353
} else if (token.frontendRef.ipInterface) {
322354
return {cap: makeIpInterface(tokenId)};
355+
} else if (token.frontendRef.externalWebSession) {
356+
return {cap: makeExternalWebSession(token.frontendRef.externalWebSession.url)};
323357
} else {
324358
throw new Meteor.Error(500, 'Unknown frontend token type.');
325359
}

shell/server/drivers/external-ui-view.js

Lines changed: 147 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ const Capnp = Npm.require('capnp');
2020
const Url = Npm.require('url');
2121
const Http = Npm.require('http');
2222
const Https = Npm.require('https');
23+
const Dns = Npm.require('dns');
2324
const ApiSession = Capnp.importSystem('sandstorm/api-session.capnp').ApiSession;
25+
const WebSession = Capnp.importSystem('sandstorm/web-session.capnp').WebSession;
2426

2527
WrappedUiView = class WrappedUiView {
2628
constructor(token, proxy) {
@@ -83,7 +85,7 @@ ExternalUiView = class ExternalUiView {
8385
};
8486
}
8587

86-
return {session: new Capnp.Capability(new ExternalWebSession(this.url, this.grainId, options), ApiSession)};
88+
return {session: new Capnp.Capability(new ExternalWebSession(this.url, options), ApiSession)};
8789
}
8890
};
8991

@@ -125,13 +127,17 @@ const responseCodes = {
125127
505: {type: 'serverError'},
126128
};
127129

130+
makeExternalWebSession = function (url, options) {
131+
return new Capnp.Capability(new ExternalWebSession(url, options), WebSession);
132+
}
133+
128134
ExternalWebSession = class ExternalWebSession {
129-
constructor(url, grainId, options) {
135+
constructor(url, options) {
130136
const parsedUrl = Url.parse(url);
131137
this.host = parsedUrl.hostname;
132138
this.port = parsedUrl.port;
133139
this.protocol = parsedUrl.protocol;
134-
this.grainId = grainId;
140+
this.path = parsedUrl.path;
135141
this.options = options || {};
136142
}
137143

@@ -165,129 +171,151 @@ ExternalWebSession = class ExternalWebSession {
165171
const _this = this;
166172
const session = _this;
167173
return new Promise((resolve, reject) => {
168-
const options = _.clone(session.options);
169-
options.headers = options.headers || {};
170-
options.path = path;
171-
options.method = method;
172-
if (contentType) {
173-
options.headers['content-type'] = contentType;
174-
}
175-
176-
// set accept header
177-
if ('accept' in context) {
178-
options.headers.accept = context.accept.map((acceptedType) => {
179-
return acceptedType.mimeType + '; ' + acceptedType.qValue;
180-
}).join(', ');
181-
} else if (!('accept' in options.headers)) {
182-
options.headers.accept = '*/*';
183-
}
184-
185-
// set cookies
186-
if (context.cookies && context.cookies.length > 0) {
187-
options.headers.cookies = options.headers.cookies || '';
188-
context.cookies.forEach((keyVal) => {
189-
options.headers.cookies += keyVal.key + '=' + keyVal.val + ',';
190-
});
191-
options.headers.cookies = options.headers.cookies.slice(0, -1);
192-
}
193-
194-
options.host = session.host;
195-
options.port = session.port;
196-
197-
let requestMethod = Http.request;
198-
if (session.protocol === 'https:') {
199-
requestMethod = Https.request;
200-
}
201-
202-
req = requestMethod(options, (resp) => {
203-
const buffers = [];
204-
const statusInfo = responseCodes[resp.statusCode];
205-
206-
const rpcResponse = {};
207-
208-
switch (statusInfo.type) {
209-
case 'content':
210-
resp.on('data', (buf) => {
211-
buffers.push(buf);
212-
});
213-
214-
resp.on('end', () => {
215-
const content = {};
216-
rpcResponse.content = content;
217-
218-
content.statusCode = statusInfo.code;
219-
if ('content-encoding' in resp.headers) content.encoding = resp.headers['content-encoding'];
220-
if ('content-language' in resp.headers) content.language = resp.headers['content-language'];
221-
if ('content-type' in resp.headers) content.language = resp.headers['content-type'];
222-
if ('content-disposition' in resp.headers) {
223-
const disposition = resp.headers['content-disposition'];
224-
const parts = disposition.split(';');
225-
if (parts[0].toLowerCase().trim() === 'attachment') {
226-
parts.forEach((part) => {
227-
const splitPart = part.split('=');
228-
if (splitPart[0].toLowerCase().trim() === 'filename') {
229-
content.disposition = {download: splitPart[1].trim()};
230-
}
231-
});
174+
Dns.lookup(_this.host, 4, (err, address) => { // TODO(someday): handle ipv6
175+
if (err) {
176+
reject(err);
177+
return;
178+
}
179+
if (address.lastIndexOf("10.", 0) === 0 ||
180+
address.lastIndexOf("127.", 0) === 0 ||
181+
address.lastIndexOf("192.168.", 0) === 0) {
182+
// Block the most commonly used private ip ranges as a security measure.
183+
reject("Domain resolved to an invalid IP: " + address);
184+
return;
185+
}
186+
const options = _.clone(session.options);
187+
options.headers = options.headers || {};
188+
if (_this.path) {
189+
options.path = _this.path;
190+
if (_this.path[_this.path.length - 1] !== "/") {
191+
options.path += "/";
192+
}
193+
options.path += path;
194+
} else {
195+
options.path = path;
196+
}
197+
options.path = (_this.path || "") + path;
198+
options.method = method;
199+
if (contentType) {
200+
options.headers['content-type'] = contentType;
201+
}
202+
203+
// set accept header
204+
if ('accept' in context) {
205+
options.headers.accept = context.accept.map((acceptedType) => {
206+
return acceptedType.mimeType + '; ' + acceptedType.qValue;
207+
}).join(', ');
208+
} else if (!('accept' in options.headers)) {
209+
options.headers.accept = '*/*';
210+
}
211+
212+
// set cookies
213+
if (context.cookies && context.cookies.length > 0) {
214+
options.headers.cookies = options.headers.cookies || '';
215+
context.cookies.forEach((keyVal) => {
216+
options.headers.cookies += keyVal.key + '=' + keyVal.val + ',';
217+
});
218+
options.headers.cookies = options.headers.cookies.slice(0, -1);
219+
}
220+
221+
options.host = session.host;
222+
options.port = session.port;
223+
224+
let requestMethod = Http.request;
225+
if (session.protocol === 'https:') {
226+
requestMethod = Https.request;
227+
}
228+
229+
req = requestMethod(options, (resp) => {
230+
const buffers = [];
231+
const statusInfo = responseCodes[resp.statusCode];
232+
233+
const rpcResponse = {};
234+
235+
switch (statusInfo.type) {
236+
case 'content':
237+
resp.on('data', (buf) => {
238+
buffers.push(buf);
239+
});
240+
241+
resp.on('end', () => {
242+
const content = {};
243+
rpcResponse.content = content;
244+
245+
content.statusCode = statusInfo.code;
246+
if ('content-encoding' in resp.headers) content.encoding = resp.headers['content-encoding'];
247+
if ('content-language' in resp.headers) content.language = resp.headers['content-language'];
248+
if ('content-type' in resp.headers) content.language = resp.headers['content-type'];
249+
if ('content-disposition' in resp.headers) {
250+
const disposition = resp.headers['content-disposition'];
251+
const parts = disposition.split(';');
252+
if (parts[0].toLowerCase().trim() === 'attachment') {
253+
parts.forEach((part) => {
254+
const splitPart = part.split('=');
255+
if (splitPart[0].toLowerCase().trim() === 'filename') {
256+
content.disposition = {download: splitPart[1].trim()};
257+
}
258+
});
259+
}
232260
}
233-
}
234261

235-
content.body = {};
236-
content.body.bytes = Buffer.concat(buffers);
262+
content.body = {};
263+
content.body.bytes = Buffer.concat(buffers);
237264

265+
resolve(rpcResponse);
266+
});
267+
break;
268+
case 'noContent':
269+
const noContent = {};
270+
rpcResponse.noContent = noContent;
271+
noContent.setShouldResetForm = statusInfo.shouldResetForm;
238272
resolve(rpcResponse);
239-
});
240-
break;
241-
case 'noContent':
242-
const noContent = {};
243-
rpcResponse.noContent = noContent;
244-
noContent.setShouldResetForm = statusInfo.shouldResetForm;
245-
resolve(rpcResponse);
246-
break;
247-
case 'redirect':
248-
const redirect = {};
249-
rpcResponse.redirect = redirect;
250-
redirect.isPermanent = statusInfo.isPermanent;
251-
redirect.switchToGet = statusInfo.switchToGet;
252-
if ('location' in resp.headers) redirect.location = resp.headers.location;
253-
resolve(rpcResponse);
254-
break;
255-
case 'clientError':
256-
const clientError = {};
257-
rpcResponse.clientError = clientError;
258-
clientError.statusCode = statusInfo.clientErrorCode;
259-
clientError.descriptionHtml = statusInfo.descriptionHtml;
260-
resolve(rpcResponse);
261-
break;
262-
case 'serverError':
263-
const serverError = {};
264-
rpcResponse.serverError = serverError;
265-
clientError.descriptionHtml = statusInfo.descriptionHtml;
266-
resolve(rpcResponse);
267-
break;
268-
default: // ???
269-
err = new Error('Invalid status code ' + resp.statusCode + ' received in response.');
270-
reject(err);
271-
break;
272-
}
273-
});
273+
break;
274+
case 'redirect':
275+
const redirect = {};
276+
rpcResponse.redirect = redirect;
277+
redirect.isPermanent = statusInfo.isPermanent;
278+
redirect.switchToGet = statusInfo.switchToGet;
279+
if ('location' in resp.headers) redirect.location = resp.headers.location;
280+
resolve(rpcResponse);
281+
break;
282+
case 'clientError':
283+
const clientError = {};
284+
rpcResponse.clientError = clientError;
285+
clientError.statusCode = statusInfo.clientErrorCode;
286+
clientError.descriptionHtml = statusInfo.descriptionHtml;
287+
resolve(rpcResponse);
288+
break;
289+
case 'serverError':
290+
const serverError = {};
291+
rpcResponse.serverError = serverError;
292+
clientError.descriptionHtml = statusInfo.descriptionHtml;
293+
resolve(rpcResponse);
294+
break;
295+
default: // ???
296+
err = new Error('Invalid status code ' + resp.statusCode + ' received in response.');
297+
reject(err);
298+
break;
299+
}
300+
});
274301

275-
req.on('error', (e) => {
276-
reject(e);
277-
});
302+
req.on('error', (e) => {
303+
reject(e);
304+
});
278305

279-
req.setTimeout(15000, () => {
280-
req.abort();
281-
err = new Error('Request timed out.');
282-
err.kjType = 'overloaded';
283-
reject(err);
284-
});
306+
req.setTimeout(15000, () => {
307+
req.abort();
308+
err = new Error('Request timed out.');
309+
err.kjType = 'overloaded';
310+
reject(err);
311+
});
285312

286-
if (content) {
287-
req.end(content);
288-
} else {
289-
req.end();
290-
}
313+
if (content) {
314+
req.end(content);
315+
} else {
316+
req.end();
317+
}
318+
});
291319
});
292320
}
293321
};

0 commit comments

Comments
 (0)