Skip to content

Commit b2dbb44

Browse files
committed
Merge github.com:brownplt/code.pyret.org into code.pyret.org
2 parents cac3bba + 61019d0 commit b2dbb44

24 files changed

Lines changed: 616 additions & 232 deletions

code.pyret.org/package-lock.json

Lines changed: 50 additions & 18 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

code.pyret.org/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"react-dom": "^15.7.0",
5656
"redis": "^0.10.3",
5757
"request": "^2.88.2",
58+
"request-filtering-agent": "^3.2.0",
5859
"requirejs": "2.1.14",
5960
"s-expression": "~2.2.0",
6061
"script-loader": "^0.7.2",
@@ -88,7 +89,7 @@
8889
"author": "Joe Politz",
8990
"license": "Apache-2.0",
9091
"devDependencies": {
91-
"chromedriver": "^141.0.1",
92+
"chromedriver": "^146.0.4",
9293
"selenium-webdriver": "^3.6.0",
9394
"webpack-cli": "^5.1.4"
9495
}

code.pyret.org/src/google-auth.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ var OAuth2 = gapi.auth.OAuth2;
44

55
var DEFAULT_OAUTH_SCOPES = [
66
"email",
7+
"profile",
78
"https://www.googleapis.com/auth/drive.file",
89
"https://www.googleapis.com/auth/drive.install",
910
];
1011

1112
var FULL_OAUTH_SCOPES = [
1213
"email",
14+
"profile",
1315
"https://www.googleapis.com/auth/spreadsheets",
1416
"https://www.googleapis.com/auth/drive.file",
1517
"https://www.googleapis.com/auth/drive",

code.pyret.org/src/run.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ var res = Q.fcall(function(db) {
2323
development: process.env["NODE_ENV"] !== "production",
2424
baseUrl: process.env["BASE_URL"],
2525
logURL: process.env["LOG_URL"],
26+
logUser: process.env["LOG_USER"],
27+
logPassword: process.env["LOG_PASSWORD"],
2628
gitRev: process.env["GIT_REV"] || git.short(),
2729
gitBranch: process.env["GIT_BRANCH"] || git.branch(),
2830
port: process.env["PORT"],

code.pyret.org/src/server.js

Lines changed: 114 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,39 @@ const { drive } = require("googleapis/build/src/apis/drive/index.js");
77

88
var BACKREF_KEY = "originalProgram";
99

10+
// Limits for the streaming proxy. /downloadImg gets larger/looser caps because
11+
// images can legitimately be tens of MB; also we've seen e.g. Drive ?export=
12+
// take a while to get going. SHAREURL is intended to always be program
13+
// plaintext.
14+
// NOTE(joe + claude): really the timeout maybe should be on idleness at
15+
// startup/between bytes, not overall per completed request, but that's work to
16+
// plumb into `request`
17+
var IMAGE_PROXY_MAX_BYTES = 20 * 1024 * 1024; // 20 MB
18+
var IMAGE_PROXY_TIMEOUT_MS = 30 * 1000; // 30 s
19+
var SHAREURL_PROXY_MAX_BYTES = 1 * 1024 * 1024; // 1 MB
20+
var SHAREURL_PROXY_TIMEOUT_MS = 10 * 1000; // 10 s
21+
1022
function start(config, onServerReady) {
23+
var defaultOpts = {
24+
PYRET: process.env.PYRET,
25+
BASE_URL: config.baseUrl,
26+
GOOGLE_API_KEY: config.google.apiKey,
27+
GOOGLE_APP_ID: config.google.appId,
28+
LOG_URL: config.logURL,
29+
LOG_PASSWORD: config.logPassword,
30+
LOG_USER: config.logUser,
31+
GIT_REV : config.gitRev,
32+
GIT_BRANCH: config.gitBranch,
33+
POSTMESSAGE_ORIGIN: process.env.POSTMESSAGE_ORIGIN
34+
};
1135
var express = require('express');
1236
var cookieSession = require('cookie-session');
1337
var cookieParser = require('cookie-parser');
1438
var bodyParser = require('body-parser');
1539
var csrf = require('csurf');
1640
var googleAuth = require('./google-auth.js');
1741
var request = require('request');
42+
var requestFilteringAgent = require('request-filtering-agent');
1843
var mustache = require('mustache-express');
1944
var url = require('url');
2045
var fs = require('fs');
@@ -107,14 +132,8 @@ function start(config, onServerReady) {
107132

108133
app.get("/", function(req, res) {
109134
var content = loggedIn(req) ? "My Programs" : "Log In";
110-
res.render("index.html", {
111-
PYRET: process.env.PYRET,
135+
res.render("index.html", { ...defaultOpts,
112136
LEFT_LINK: content,
113-
GOOGLE_API_KEY: config.google.apiKey,
114-
BASE_URL: config.baseUrl,
115-
LOG_URL: config.logURL,
116-
GIT_REV : config.gitRev,
117-
GIT_BRANCH: config.gitBranch
118137
});
119138
});
120139

@@ -180,24 +199,74 @@ function start(config, onServerReady) {
180199
});
181200
}
182201

183-
app.get("/downloadImg", function(req, response) {
184-
var parsed = url.parse(req.url);
185-
var googleLink = decodeURIComponent(parsed.query.slice(0));
186-
var googleParsed = url.parse(googleLink);
187-
var gReq = request({url: googleLink, encoding: 'binary'}, function(error, imgResponse, body) {
188-
if(error) {
189-
response.status(400).send({type: "image-load-failure", error: "Unable to load image " + String(error)});
202+
function proxyStreamFetch(opts) {
203+
var res = opts.res;
204+
res.set('X-Content-Type-Options', 'nosniff');
205+
res.set('Content-Security-Policy', 'sandbox');
206+
207+
var parsed;
208+
try { parsed = new URL(opts.url); }
209+
catch (e) { return res.status(400).send({ error: 'invalid-url' }); }
210+
if (opts.allowedHosts && !opts.allowedHosts(parsed.hostname)) {
211+
return res.status(400).send({ error: 'host-not-allowed' });
212+
}
213+
214+
var bytes = 0;
215+
var upstream = request({
216+
url: opts.url,
217+
timeout: opts.timeoutMs,
218+
agent: requestFilteringAgent.useAgent(opts.url),
219+
followRedirect: function(resp) {
220+
if (!opts.allowedHosts) return true;
221+
try {
222+
var next = new URL(resp.headers.location, opts.url);
223+
return opts.allowedHosts(next.hostname);
224+
} catch (_) { return false; }
225+
},
226+
});
227+
// If the client disconnects (e.g. the browser aborts /load-shareurl after
228+
// direct succeeded), tear down the upstream connection too — otherwise
229+
// we'd keep streaming bytes from raw.githubusercontent.com to nowhere.
230+
res.on('close', function() { upstream.destroy(); });
231+
upstream.on('error', function(err) {
232+
if (!res.headersSent) opts.onError(res, err);
233+
});
234+
upstream.on('response', function(upRes) {
235+
if (opts.contentTypeOk && !opts.contentTypeOk(upRes.headers['content-type'])) {
236+
upstream.destroy();
237+
return res.status(400).send({ error: 'content-type-not-allowed', detail: upRes.headers['content-type'] });
190238
}
191-
else {
192-
var h = imgResponse.headers;
193-
var ct = h['content-type'];
194-
if((!ct) || (ct.indexOf('image/') !== 0)) {
195-
response.status(400).send({type: "non-image", error: "Invalid image type " + ct});
196-
return;
197-
}
198-
response.set('content-type', ct);
199-
response.end(body, 'binary');
239+
res.status(upRes.statusCode);
240+
if (upRes.headers['content-type']) {
241+
res.set('content-type', upRes.headers['content-type']);
200242
}
243+
upRes.on('data', function(chunk) {
244+
bytes += chunk.length;
245+
if (bytes > opts.maxBytes) {
246+
upstream.destroy();
247+
if (!res.headersSent) res.status(502).send({ error: 'too-large' });
248+
else res.destroy();
249+
}
250+
});
251+
// Pipe upRes (IncomingMessage), not upstream (request object). The
252+
// request library's .pipe copies upstream headers verbatim, which
253+
// would overwrite the security headers set above.
254+
upRes.pipe(res);
255+
});
256+
}
257+
258+
app.get("/downloadImg", function(req, response) {
259+
var googleLink = decodeURIComponent(url.parse(req.url).query.slice(0));
260+
proxyStreamFetch({
261+
res: response,
262+
url: googleLink,
263+
allowedHosts: null,
264+
maxBytes: IMAGE_PROXY_MAX_BYTES,
265+
timeoutMs: IMAGE_PROXY_TIMEOUT_MS,
266+
contentTypeOk: function(ct) { return ct && ct.indexOf('image/') === 0; },
267+
onError: function(res, err) {
268+
res.status(400).send({ type: 'image-load-failure', error: 'Unable to load image ' + String(err) });
269+
},
201270
});
202271
});
203272

@@ -529,30 +598,14 @@ function start(config, onServerReady) {
529598
});
530599

531600
app.get("/editor", function(req, res) {
532-
res.render("editor.html", {
533-
PYRET: process.env.PYRET,
534-
BASE_URL: config.baseUrl,
535-
GOOGLE_API_KEY: config.google.apiKey,
536-
GOOGLE_APP_ID: config.google.appId,
601+
res.render("editor.html", { ...defaultOpts,
537602
CSRF_TOKEN: req.csrfToken(),
538-
LOG_URL: config.logURL,
539-
GIT_REV : config.gitRev,
540-
GIT_BRANCH: config.gitBranch,
541-
POSTMESSAGE_ORIGIN: process.env.POSTMESSAGE_ORIGIN
542603
});
543604
});
544605

545606
app.get("/blocks", function(req, res) {
546-
res.render("blocks.html", {
547-
PYRET: process.env.PYRET,
548-
BASE_URL: config.baseUrl,
549-
GOOGLE_API_KEY: config.google.apiKey,
550-
GOOGLE_APP_ID: config.google.appId,
607+
res.render("blocks.html", { ...defaultOpts,
551608
CSRF_TOKEN: req.csrfToken(),
552-
LOG_URL: config.logURL,
553-
GIT_REV : config.gitRev,
554-
GIT_BRANCH: config.gitBranch,
555-
POSTMESSAGE_ORIGIN: process.env.POSTMESSAGE_ORIGIN
556609
});
557610
});
558611

@@ -575,6 +628,26 @@ function start(config, onServerReady) {
575628

576629
});
577630

631+
// Server-side proxy for #shareurl loads from hosts that some school networks
632+
// block or will likely block (notably raw.githubusercontent.com).
633+
// Eager-proxied client-side for any URL whose host is in
634+
// SHAREURL_ALLOWED_HOSTS. We can expand this list as needed.
635+
var SHAREURL_ALLOWED_HOSTS = new Set(['raw.githubusercontent.com']);
636+
637+
app.get("/load-shareurl", function(req, res) {
638+
proxyStreamFetch({
639+
res: res,
640+
url: req.query.url,
641+
allowedHosts: function(h) { return SHAREURL_ALLOWED_HOSTS.has(h); },
642+
maxBytes: SHAREURL_PROXY_MAX_BYTES,
643+
timeoutMs: SHAREURL_PROXY_TIMEOUT_MS,
644+
contentTypeOk: null,
645+
onError: function(res, err) {
646+
res.status(502).send({ error: 'upstream-error' });
647+
},
648+
});
649+
});
650+
578651

579652
app.post("/share-image", function(req, res) {
580653
var driveFileId = req.body.fileId;

0 commit comments

Comments
 (0)