diff --git a/package-lock.json b/package-lock.json index 7c73e2f1..c341d949 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,7 @@ "react-dom": "^15.7.0", "redis": "^0.10.3", "request": "^2.88.2", + "request-filtering-agent": "^3.2.0", "requirejs": "2.1.14", "s-expression": "~2.2.0", "script-loader": "^0.7.2", @@ -77,7 +78,7 @@ "webpack": "^5.89.0" }, "devDependencies": { - "chromedriver": "^141.0.1", + "chromedriver": "^146.0.4", "selenium-webdriver": "^3.6.0", "webpack-cli": "^5.1.4" } @@ -8549,14 +8550,14 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", - "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "dev": true, "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -10031,19 +10032,19 @@ } }, "node_modules/chromedriver": { - "version": "141.0.1", - "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-141.0.1.tgz", - "integrity": "sha512-BvBP/wlZDU/oDSQ7cbolKE2DI/PP2T2qDWN75+QiPkW5bUs/pd5uz4LYREl1fyoIerhLGhS0OSmMxpUfDbP4Tg==", + "version": "146.0.4", + "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-146.0.4.tgz", + "integrity": "sha512-/yNqo99Xm0qgAjSh7DYy2KX6pfgyGnIM/k+2jUSc0T6CUCP5k26DWrAYU7Sy0t/dKCntMl45FDWfqP+yRWaCZQ==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@testim/chrome-version": "^1.1.4", - "axios": "^1.12.0", + "axios": "^1.13.5", "compare-versions": "^6.1.0", "extract-zip": "^2.0.1", - "proxy-agent": "^6.4.0", - "proxy-from-env": "^1.1.0", + "proxy-agent": "^6.5.0", + "proxy-from-env": "^2.0.0", "tcp-port-used": "^1.0.2" }, "bin": { @@ -10053,6 +10054,16 @@ "node": ">=20" } }, + "node_modules/chromedriver/node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/cipher-base": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.6.tgz", @@ -12954,9 +12965,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "funding": [ { "type": "individual", @@ -13016,9 +13027,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "dev": true, "license": "MIT", "dependencies": { @@ -20013,6 +20024,27 @@ "node": ">= 6" } }, + "node_modules/request-filtering-agent": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/request-filtering-agent/-/request-filtering-agent-3.2.0.tgz", + "integrity": "sha512-tKPrKdsmTFuGG1/pBEpzTB66mDZ2lZLW8kjW4N6jj4QjnxUTKrIfv5p2zuJRfztOos86jRPD41lRaGjh+1QqDw==", + "license": "MIT", + "dependencies": { + "ipaddr.js": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/request-filtering-agent/node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/request/node_modules/form-data": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", diff --git a/package.json b/package.json index 07406f4b..55646236 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "react-dom": "^15.7.0", "redis": "^0.10.3", "request": "^2.88.2", + "request-filtering-agent": "^3.2.0", "requirejs": "2.1.14", "s-expression": "~2.2.0", "script-loader": "^0.7.2", @@ -88,7 +89,7 @@ "author": "Joe Politz", "license": "Apache-2.0", "devDependencies": { - "chromedriver": "^141.0.1", + "chromedriver": "^146.0.4", "selenium-webdriver": "^3.6.0", "webpack-cli": "^5.1.4" } diff --git a/src/google-auth.js b/src/google-auth.js index 7357b137..4dc92308 100644 --- a/src/google-auth.js +++ b/src/google-auth.js @@ -4,12 +4,14 @@ var OAuth2 = gapi.auth.OAuth2; var DEFAULT_OAUTH_SCOPES = [ "email", + "profile", "https://www.googleapis.com/auth/drive.file", "https://www.googleapis.com/auth/drive.install", ]; var FULL_OAUTH_SCOPES = [ "email", + "profile", "https://www.googleapis.com/auth/spreadsheets", "https://www.googleapis.com/auth/drive.file", "https://www.googleapis.com/auth/drive", diff --git a/src/run.js b/src/run.js index e5fcac29..f80254cb 100644 --- a/src/run.js +++ b/src/run.js @@ -23,6 +23,8 @@ var res = Q.fcall(function(db) { development: process.env["NODE_ENV"] !== "production", baseUrl: process.env["BASE_URL"], logURL: process.env["LOG_URL"], + logUser: process.env["LOG_USER"], + logPassword: process.env["LOG_PASSWORD"], gitRev: process.env["GIT_REV"] || git.short(), gitBranch: process.env["GIT_BRANCH"] || git.branch(), port: process.env["PORT"], diff --git a/src/server.js b/src/server.js index 75a40d57..9395dab6 100644 --- a/src/server.js +++ b/src/server.js @@ -7,7 +7,31 @@ const { drive } = require("googleapis/build/src/apis/drive/index.js"); var BACKREF_KEY = "originalProgram"; +// Limits for the streaming proxy. /downloadImg gets larger/looser caps because +// images can legitimately be tens of MB; also we've seen e.g. Drive ?export= +// take a while to get going. SHAREURL is intended to always be program +// plaintext. +// NOTE(joe + claude): really the timeout maybe should be on idleness at +// startup/between bytes, not overall per completed request, but that's work to +// plumb into `request` +var IMAGE_PROXY_MAX_BYTES = 20 * 1024 * 1024; // 20 MB +var IMAGE_PROXY_TIMEOUT_MS = 30 * 1000; // 30 s +var SHAREURL_PROXY_MAX_BYTES = 1 * 1024 * 1024; // 1 MB +var SHAREURL_PROXY_TIMEOUT_MS = 10 * 1000; // 10 s + function start(config, onServerReady) { + var defaultOpts = { + PYRET: process.env.PYRET, + BASE_URL: config.baseUrl, + GOOGLE_API_KEY: config.google.apiKey, + GOOGLE_APP_ID: config.google.appId, + LOG_URL: config.logURL, + LOG_PASSWORD: config.logPassword, + LOG_USER: config.logUser, + GIT_REV : config.gitRev, + GIT_BRANCH: config.gitBranch, + POSTMESSAGE_ORIGIN: process.env.POSTMESSAGE_ORIGIN + }; var express = require('express'); var cookieSession = require('cookie-session'); var cookieParser = require('cookie-parser'); @@ -15,6 +39,7 @@ function start(config, onServerReady) { var csrf = require('csurf'); var googleAuth = require('./google-auth.js'); var request = require('request'); + var requestFilteringAgent = require('request-filtering-agent'); var mustache = require('mustache-express'); var url = require('url'); var fs = require('fs'); @@ -107,14 +132,8 @@ function start(config, onServerReady) { app.get("/", function(req, res) { var content = loggedIn(req) ? "My Programs" : "Log In"; - res.render("index.html", { - PYRET: process.env.PYRET, + res.render("index.html", { ...defaultOpts, LEFT_LINK: content, - GOOGLE_API_KEY: config.google.apiKey, - BASE_URL: config.baseUrl, - LOG_URL: config.logURL, - GIT_REV : config.gitRev, - GIT_BRANCH: config.gitBranch }); }); @@ -180,24 +199,74 @@ function start(config, onServerReady) { }); } - app.get("/downloadImg", function(req, response) { - var parsed = url.parse(req.url); - var googleLink = decodeURIComponent(parsed.query.slice(0)); - var googleParsed = url.parse(googleLink); - var gReq = request({url: googleLink, encoding: 'binary'}, function(error, imgResponse, body) { - if(error) { - response.status(400).send({type: "image-load-failure", error: "Unable to load image " + String(error)}); + function proxyStreamFetch(opts) { + var res = opts.res; + res.set('X-Content-Type-Options', 'nosniff'); + res.set('Content-Security-Policy', 'sandbox'); + + var parsed; + try { parsed = new URL(opts.url); } + catch (e) { return res.status(400).send({ error: 'invalid-url' }); } + if (opts.allowedHosts && !opts.allowedHosts(parsed.hostname)) { + return res.status(400).send({ error: 'host-not-allowed' }); + } + + var bytes = 0; + var upstream = request({ + url: opts.url, + timeout: opts.timeoutMs, + agent: requestFilteringAgent.useAgent(opts.url), + followRedirect: function(resp) { + if (!opts.allowedHosts) return true; + try { + var next = new URL(resp.headers.location, opts.url); + return opts.allowedHosts(next.hostname); + } catch (_) { return false; } + }, + }); + // If the client disconnects (e.g. the browser aborts /load-shareurl after + // direct succeeded), tear down the upstream connection too — otherwise + // we'd keep streaming bytes from raw.githubusercontent.com to nowhere. + res.on('close', function() { upstream.destroy(); }); + upstream.on('error', function(err) { + if (!res.headersSent) opts.onError(res, err); + }); + upstream.on('response', function(upRes) { + if (opts.contentTypeOk && !opts.contentTypeOk(upRes.headers['content-type'])) { + upstream.destroy(); + return res.status(400).send({ error: 'content-type-not-allowed', detail: upRes.headers['content-type'] }); } - else { - var h = imgResponse.headers; - var ct = h['content-type']; - if((!ct) || (ct.indexOf('image/') !== 0)) { - response.status(400).send({type: "non-image", error: "Invalid image type " + ct}); - return; - } - response.set('content-type', ct); - response.end(body, 'binary'); + res.status(upRes.statusCode); + if (upRes.headers['content-type']) { + res.set('content-type', upRes.headers['content-type']); } + upRes.on('data', function(chunk) { + bytes += chunk.length; + if (bytes > opts.maxBytes) { + upstream.destroy(); + if (!res.headersSent) res.status(502).send({ error: 'too-large' }); + else res.destroy(); + } + }); + // Pipe upRes (IncomingMessage), not upstream (request object). The + // request library's .pipe copies upstream headers verbatim, which + // would overwrite the security headers set above. + upRes.pipe(res); + }); + } + + app.get("/downloadImg", function(req, response) { + var googleLink = decodeURIComponent(url.parse(req.url).query.slice(0)); + proxyStreamFetch({ + res: response, + url: googleLink, + allowedHosts: null, + maxBytes: IMAGE_PROXY_MAX_BYTES, + timeoutMs: IMAGE_PROXY_TIMEOUT_MS, + contentTypeOk: function(ct) { return ct && ct.indexOf('image/') === 0; }, + onError: function(res, err) { + res.status(400).send({ type: 'image-load-failure', error: 'Unable to load image ' + String(err) }); + }, }); }); @@ -529,30 +598,14 @@ function start(config, onServerReady) { }); app.get("/editor", function(req, res) { - res.render("editor.html", { - PYRET: process.env.PYRET, - BASE_URL: config.baseUrl, - GOOGLE_API_KEY: config.google.apiKey, - GOOGLE_APP_ID: config.google.appId, + res.render("editor.html", { ...defaultOpts, CSRF_TOKEN: req.csrfToken(), - LOG_URL: config.logURL, - GIT_REV : config.gitRev, - GIT_BRANCH: config.gitBranch, - POSTMESSAGE_ORIGIN: process.env.POSTMESSAGE_ORIGIN }); }); app.get("/blocks", function(req, res) { - res.render("blocks.html", { - PYRET: process.env.PYRET, - BASE_URL: config.baseUrl, - GOOGLE_API_KEY: config.google.apiKey, - GOOGLE_APP_ID: config.google.appId, + res.render("blocks.html", { ...defaultOpts, CSRF_TOKEN: req.csrfToken(), - LOG_URL: config.logURL, - GIT_REV : config.gitRev, - GIT_BRANCH: config.gitBranch, - POSTMESSAGE_ORIGIN: process.env.POSTMESSAGE_ORIGIN }); }); @@ -575,6 +628,26 @@ function start(config, onServerReady) { }); + // Server-side proxy for #shareurl loads from hosts that some school networks + // block or will likely block (notably raw.githubusercontent.com). + // Eager-proxied client-side for any URL whose host is in + // SHAREURL_ALLOWED_HOSTS. We can expand this list as needed. + var SHAREURL_ALLOWED_HOSTS = new Set(['raw.githubusercontent.com']); + + app.get("/load-shareurl", function(req, res) { + proxyStreamFetch({ + res: res, + url: req.query.url, + allowedHosts: function(h) { return SHAREURL_ALLOWED_HOSTS.has(h); }, + maxBytes: SHAREURL_PROXY_MAX_BYTES, + timeoutMs: SHAREURL_PROXY_TIMEOUT_MS, + contentTypeOk: null, + onError: function(res, err) { + res.status(502).send({ error: 'upstream-error' }); + }, + }); + }); + app.post("/share-image", function(req, res) { var driveFileId = req.body.fileId; diff --git a/src/web/css/editor.css b/src/web/css/editor.css index 6cea932b..424288b1 100644 --- a/src/web/css/editor.css +++ b/src/web/css/editor.css @@ -486,7 +486,7 @@ div.repl-animation { div.trace { overflow-x: auto; - padding-top: 1px; + margin-top: 1px; } div.trace.error { background-color: var(--trace-err-bg); @@ -2304,23 +2304,42 @@ table.pyret-table.pyret-matrix { } -table.pyret-table { width: 98%; overflow: scroll; } +table.pyret-table { width: 98%; overflow: scroll; border: none; } +/* make sure rotated elts in the table (like roughnums!) don't punch through */ table.pyret-table thead { box-shadow: 0px 2px 2px var(--shadow-color); position: sticky; top: 0; + z-index: 1; } -table.pyret-table tr:last-child td:first-child { - border-bottom-left-radius: var(--table-radius); +/* top/bottom borders on the first and last rows */ +table.pyret-table th { border-top: solid 1px black; } +table.pyret-table > :nth-last-child(1 of :has(tr)) > tr:last-child td { + border-bottom: solid 1px black; } -table.pyret-table tr:last-child td:last-child { - border-bottom-right-radius: var(--table-radius); + +/* left/right borders on the first and last cells */ +table.pyret-table td:first-child, table.pyret-table th:first-child { + border-left: solid 1px black; +} +table.pyret-table td:last-child, table.pyret-table th:last-child { + border-right: solid 1px black; } -/* style first and last th elements to use the table's rounded corners */ -table.pyret-table th:first-child { border-top-left-radius: var(--table-radius); } -table.pyret-table th:last-child { border-top-right-radius: var(--table-radius); } +/* rounded borders for corners */ +table.pyret-table th:first-child { + border-top-left-radius: var(--table-radius); +} +table.pyret-table th:last-child { + border-top-right-radius: var(--table-radius); +} +table.pyret-table > :nth-last-child(1 of :has(tr)) > tr:last-child td:first-child { + border-bottom-left-radius: var(--table-radius); +} +table.pyret-table > :nth-last-child(1 of :has(tr)) > tr:last-child td:last-child { + border-bottom-right-radius: var(--table-radius); +} diff --git a/src/web/editor.html b/src/web/editor.html index f8ebe386..fd59d9e7 100644 --- a/src/web/editor.html +++ b/src/web/editor.html @@ -445,6 +445,8 @@

Announcements

diff --git a/src/web/js/beforeBlocks.js b/src/web/js/beforeBlocks.js index 58b5e756..f0973b0d 100644 --- a/src/web/js/beforeBlocks.js +++ b/src/web/js/beforeBlocks.js @@ -305,13 +305,13 @@ $(function() { }; function setUsername(target) { - return gwrap.load({name: 'plus', + return gwrap.load({name: 'people', version: 'v1', }).then((api) => { - api.people.get({ userId: "me" }).then(function(user) { - var name = user.displayName; - if (user.emails && user.emails[0] && user.emails[0].value) { - name = user.emails[0].value; + api.people.get({ resourceName: "people/me", personFields: "names,emailAddresses" }).then(function(user) { + var name = user.names && user.names[0] ? user.names[0].displayName : undefined; + if (user.emailAddresses && user.emailAddresses[0] && user.emailAddresses[0].value) { + name = user.emailAddresses[0].value; } target.text(name); }); diff --git a/src/web/js/beforePyret.js b/src/web/js/beforePyret.js index 38651f41..b4941603 100644 --- a/src/web/js/beforePyret.js +++ b/src/web/js/beforePyret.js @@ -3,6 +3,130 @@ var originalPageLoad = Date.now(); console.log("originalPageLoad: ", originalPageLoad); +// Transparently route browser fetches to allowlisted hosts through the +// server-side proxy at /load-shareurl, but only when the direct path doesn't +// work. +// +// Strategy: the FIRST fetch to an allowlisted host fires direct + proxied in +// parallel. We decide shouldProxy for the rest of the page-load from direct's +// response *headers*: +// - direct returned 2xx with content-type text/plain -> shouldProxy=false: +// serve direct's response, abort the in-flight proxy fetch. +// - direct failed, hung past timeout, or returned anything else +// -> shouldProxy=true: +// serve proxy's response. +// A key idea is that network-blocky things sometimes return 200 with a +// message page about blocking (or an error, but that counts as a fail). We +// don't want to accidentally think that's a success. +// shouldProxy state is in-memory and per-host — never persisted, since +// reachability changes between networks and a stale value would silently +// break loads. +// +// Installed on the global fetch as early as possible so it catches every fetch +// caller; some of them are in the pyret-lang runtime and would be otherwise +// difficult to configure. +const SHAREURL_PROXY_HOSTS = new Set(['raw.githubusercontent.com']); +const SHAREURL_DIRECT_TIMEOUT_MS = 5000; +const _origFetch = window.fetch.bind(window); + +const _shareurlShouldProxy = new Map(); // host -> boolean +const _shareurlShouldProxyInflight = new Map(); // host -> Promise + +function _shareurlProxyUrl(fetchInput) { + return '/load-shareurl?url=' + encodeURIComponent(_shareurlInputToUrl(fetchInput)); +} + +function _shareurlInputToUrl(fetchInput) { + return (typeof fetchInput === 'string') ? fetchInput + : (typeof Request !== 'undefined' && fetchInput instanceof Request) ? fetchInput.url + : String(fetchInput); +} + +function _shareurlVerifyDirect(r) { + if (!r.ok) return false; + const ct = (r.headers.get('content-type') || '').toLowerCase(); + // Source files served from raw.githubusercontent.com come back as + // text/plain (.arr, .json, .csv, .md all do). Anything else — HTML block + // pages, captive portals, surprise content types — we don't trust as a + // real upstream response. + return ct.startsWith('text/plain'); +} + +function _shareurlFetch(shouldProxy, fetchInput, fetchInit) { + const maybeProxyInput = shouldProxy ? _shareurlProxyUrl(fetchInput) : fetchInput; + return _origFetch(maybeProxyInput, fetchInit); +} + +function _shareurlRace(fetchInput, fetchInit) { + const proxyCtrl = new AbortController(); + // NOTE(joe): The signal overwrite is technically not the right fetch() + // polyfill. If the caller elsewhere in the codebase provided a different + // signal (which in the fetch API is only for aborting as of April '26), that + // caller aborting through that signal won't cancel the proxy fetch. + // I'm OK letting that case slip through here in exchange for not having a + // bunch of extra event handler forwarding + const proxyP = _origFetch(_shareurlProxyUrl(fetchInput), + Object.assign({}, fetchInit, { signal: proxyCtrl.signal })); + const directP = _origFetch(fetchInput, fetchInit).then(r => { + if (!_shareurlVerifyDirect(r)) throw new Error('direct request failed'); + return r; + }); + + // shouldProxy: false iff direct verified before the timeout, else true. + // Whether to proxy is decided solely on whether direct succeeds or not + const shouldProxyPromise = Promise.race([ + directP.then(() => false, () => true), + new Promise(resolve => setTimeout(() => resolve(true), SHAREURL_DIRECT_TIMEOUT_MS)), + ]); + + // Settlement-order check: if direct verifies before proxy returns, abort + // the in-flight proxy to stop wasting server bandwidth. We must NOT + // abort once proxy has already returned, since by then the caller is + // reading proxy's body and aborting would error its stream mid-read. + const directFinishedSuccessfullyAndFirstP = Promise.race([ + directP.then(() => true, () => false), + proxyP.then(() => false, () => false), + ]); + directFinishedSuccessfullyAndFirstP.then(directFirst => { + if (directFirst) proxyCtrl.abort(); + }); + + // Caller's response: whichever of direct-verified or proxy fulfills + // first. If both fail, surface proxy's error (the more authoritative + // upstream — direct's may just be 'direct-not-verified'). + const responsePromise = Promise.any([directP, proxyP]).catch( + aggErr => Promise.reject(aggErr.errors[1] || aggErr.errors[0]) + ); + + return { responsePromise, shouldProxyPromise }; +} + +window.fetch = function(fetchInput, fetchInit) { + let host; + try { host = new URL(_shareurlInputToUrl(fetchInput), window.location.href).hostname; } + catch (_) { return _origFetch(fetchInput, fetchInit); } + if (!SHAREURL_PROXY_HOSTS.has(host)) return _origFetch(fetchInput, fetchInit); + + const shouldProxy = _shareurlShouldProxy.get(host); + const inflight = _shareurlShouldProxyInflight.get(host); + if (shouldProxy !== undefined) { + return _shareurlFetch(shouldProxy, fetchInput, fetchInit); + } else if (inflight) { + // shouldProxy pending: queue this fetch on it and issue a single fresh + // request once shouldProxy is decided. + return inflight.then(sp => _shareurlFetch(sp, fetchInput, fetchInit)); + } else { + // First fetch to this host this page-load: run the race. + const { responsePromise, shouldProxyPromise } = _shareurlRace(fetchInput, fetchInit); + _shareurlShouldProxyInflight.set(host, shouldProxyPromise); + shouldProxyPromise.then(sp => { + _shareurlShouldProxy.set(host, sp); + _shareurlShouldProxyInflight.delete(host); + }); + return responsePromise; + } +}; + const isEmbedded = window.parent !== window; var shareAPI = makeShareAPI(process.env.CURRENT_PYRET_RELEASE); @@ -333,13 +457,13 @@ $(function() { }; function setUsername(target) { - return gwrap.load({name: 'plus', + return gwrap.load({name: 'people', version: 'v1', }).then((api) => { - api.people.get({ userId: "me" }).then(function(user) { - var name = user.displayName; - if (user.emails && user.emails[0] && user.emails[0].value) { - name = user.emails[0].value; + api.people.get({ resourceName: "people/me", personFields: "names,emailAddresses" }).then(function(user) { + var name = user.names && user.names[0] ? user.names[0].displayName : undefined; + if (user.emailAddresses && user.emailAddresses[0] && user.emailAddresses[0].value) { + name = user.emailAddresses[0].value; } target.text(name); }); diff --git a/src/web/js/cpo-ide-hooks.js b/src/web/js/cpo-ide-hooks.js index c5406ee0..935cb6c9 100644 --- a/src/web/js/cpo-ide-hooks.js +++ b/src/web/js/cpo-ide-hooks.js @@ -32,13 +32,12 @@ ], nativeRequires: [ "cpo/cpo-builtin-modules", - "pyret-base/js/js-numbers", ], provides: {}, theModule: function(runtime, namespace, uri, compileLib, compileStructs, pyRepl, cpo, runtimeLib, loadLib, cpoBuiltins, parsePyret, - cpoModules, jsnums + cpoModules ) { window.CPOIDEHooks = { runtime: runtime, @@ -49,7 +48,7 @@ compileStructs: compileStructs, compileLib: compileLib, cpoModules: cpoModules, - jsnums: jsnums + jsnums: runtime.jsnums }; return runtime.makeModuleReturn({}, {}); } diff --git a/src/web/js/dashboard/GoogleAPI.js b/src/web/js/dashboard/GoogleAPI.js index 22571472..6fe442b8 100644 --- a/src/web/js/dashboard/GoogleAPI.js +++ b/src/web/js/dashboard/GoogleAPI.js @@ -151,11 +151,11 @@ class GoogleAPI { } getUsername = () => { - return gwrap.load({name: 'plus', + return gwrap.load({name: 'people', version: 'v1', }).then((api) => { console.log("Api: ", api); - return api.people.get({ userId: "me" }); + return api.people.get({ resourceName: "people/me", personFields: "names,emailAddresses" }); }); } } diff --git a/src/web/js/dashboard/StudentDashboard.js b/src/web/js/dashboard/StudentDashboard.js index b64edeb7..cf3f46aa 100644 --- a/src/web/js/dashboard/StudentDashboard.js +++ b/src/web/js/dashboard/StudentDashboard.js @@ -31,7 +31,7 @@ class StudentDashboard extends Component { this.setState({signedIn: SIGNED_IN}); this.updateRecentFiles(); this.api.getUsername().then((userInfo) => { - this.setState({ userName: userInfo.emails[0].value }); + this.setState({ userName: userInfo.emailAddresses[0].value }); }); } else { @@ -52,7 +52,7 @@ class StudentDashboard extends Component { this.api.signIn().then((resp) => { this.setState({signedIn: SIGNED_IN}); this.api.getUsername().then((userInfo) => { - this.setState({ userName: userInfo.emails[0].value }); + this.setState({ userName: userInfo.emailAddresses[0].value }); }); this.updateRecentFiles(); }) diff --git a/src/web/js/google-apis/api-wrapper.js b/src/web/js/google-apis/api-wrapper.js index 7cb72cf1..2d2308d1 100644 --- a/src/web/js/google-apis/api-wrapper.js +++ b/src/web/js/google-apis/api-wrapper.js @@ -394,10 +394,23 @@ function loadAPIWrapper(immediate) { } function processDelta() { + debugger; var newKeys = Object.keys(gapi.client) .filter(function(k) {return (preKeys.indexOf(k) === -1);}); var ret; - if (newKeys.length > 1) { + if (params.name && newKeys.indexOf(params.name) !== -1) { + // When we requested a specific API by name, return only that one. + // Other new keys (from concurrent loads) will be picked up by + // their own loadAPI calls. Process all keys to populate the cache, + // but only return the one we asked for. + newKeys.forEach(processKey); + ret = _GWRAP_APIS[params.name]; + } else if (newKeys.length > 1) { + // NOTE(joe, Apr 2026): We added this to diagnose if it actually ever + // happens in prod that we return a list of new APIs. + // We think it probably doesn't based on call sites, but don't want to + // break anything. + logger.log('processdelta-multi-key-return', { newKeys, preKeys }); ret = newKeys.map(processKey); } else if (params.name && newKeys.length === 0) { // Hack to make drive-loading happy on login diff --git a/src/web/js/log.js b/src/web/js/log.js index cafcbe33..35619eb5 100644 --- a/src/web/js/log.js +++ b/src/web/js/log.js @@ -10,6 +10,25 @@ var DummyBackend = function () { this.log = function(_, __){}; }; +var FetchWithCredsBackend = function (url, user, pass) { + const auth = btoa(user + ":" + pass); + this.log = function(name, obj) { + const opts = { + method: 'POST', + credentials: "include", + headers: { + "Content-Type": "application/json", + 'Authorization': 'Basic ' + auth + }, + body: JSON.stringify(obj) + }; + const req = fetch("https://bootstrapworld.org/data/actions/LogActions.php?method=pyretLog", opts).then((resp) => { + console.log(resp); + }).catch((e) => console.error(e)); + }; + +} + var AJAXBackend = function (url) { this.log = function (name, obj) { var request = new XMLHttpRequest(); @@ -18,6 +37,17 @@ var AJAXBackend = function (url) { } } +var backend; +if(window.LOG_URL && window.LOG_USER) { + backend = new FetchWithCredsBackend(LOG_URL, LOG_USER, LOG_PASSWORD); +} +else if(window.LOG_URL) { + backend = new AJAXBackend(LOG_URL); +} +else { + backend = new DummyBackend(); +} + var logger = (function(backend) { var sessionStorage; var localStorage; @@ -100,7 +130,7 @@ var logger = (function(backend) { return nowIsDetailed; } }; -})( LOG_URL ? new AJAXBackend(LOG_URL) : new DummyBackend() ); +})(backend); if(window.CodeMirror) { CodeMirror.defineOption('logging', false, diff --git a/src/web/js/output-ui.js b/src/web/js/output-ui.js index acba54a8..8dfbbe96 100644 --- a/src/web/js/output-ui.js +++ b/src/web/js/output-ui.js @@ -16,10 +16,10 @@ provides: {}, nativeRequires: [ "pyret-base/js/runtime-util", - "pyret-base/js/js-numbers" ], - theModule: function(runtime, _, uri, parsePyret, errordisplayLib, srclocLib, astLib, imageLib, loadLib, util, jsnums) { + theModule: function(runtime, _, uri, parsePyret, errordisplayLib, srclocLib, astLib, imageLib, loadLib, util) { + var jsnums = runtime.jsnums; var image = runtime.getField(imageLib, "internal"); var srcloc = runtime.getField(srclocLib, "values"); var isSrcloc = runtime.getField(srcloc, "is-Srcloc"); @@ -1399,7 +1399,7 @@ // after, and numerals to be repeated. var numr = num.numerator(); var denr = num.denominator(); - var decimal = jsnums.toRepeatingDecimal(numr, denr, runtime.NumberErrbacks); + var decimal = jsnums.toRepeatingDecimal(numr, denr); var prePointString = decimal[0]; var postPointString = decimal[1]; var decRpt = decimal[2]; diff --git a/src/web/js/trove/chart-lib.js b/src/web/js/trove/chart-lib.js index 95461502..e18f32e7 100644 --- a/src/web/js/trove/chart-lib.js +++ b/src/web/js/trove/chart-lib.js @@ -4,7 +4,6 @@ { 'import-type': 'builtin', 'name': 'image-lib' }, ], nativeRequires: [ - 'pyret-base/js/js-numbers', 'google-charts', ], provides: { @@ -17,8 +16,9 @@ 'plot': "tany", } }, - theModule: function (RUNTIME, NAMESPACE, uri, IMAGELIB, jsnums , google) { - 'use strict'; + theModule: function (RUNTIME, NAMESPACE, uri, IMAGELIB, google) { + 'use strict'; + var jsnums = RUNTIME.jsnums; // Load google library via editor.html to avoid loading issues function notImp(name) { @@ -89,7 +89,7 @@ function getPrettyNumToStringDigits(d) { // this accepts Pyret num return n => - jsnums.toStringDigits(n, d, RUNTIME.NumberErrbacks).replace(/\.?0*$/, ''); + jsnums.toStringDigits(n, d).replace(/\.?0*$/, ''); } const prettyNumToStringDigits5 = getPrettyNumToStringDigits(5); @@ -180,8 +180,7 @@ xMaxC.removeClass('error-bg'); xMaxC.addClass('ok-bg'); - if (jsnums.greaterThanOrEqual(xMinVal, xMaxVal, - RUNTIME.NumberErrbacks)) { + if (jsnums.greaterThanOrEqual(xMinVal, xMaxVal)) { xMinC.addClass('error-bg'); xMaxC.addClass('error-bg'); xMinC.removeClass('ok-bg'); @@ -211,8 +210,7 @@ yMaxC.removeClass('error-bg'); yMaxC.addClass('ok-bg'); - if (jsnums.greaterThanOrEqual(xMinVal, xMaxVal, - RUNTIME.NumberErrbacks)) { + if (jsnums.greaterThanOrEqual(xMinVal, xMaxVal)) { yMinC.addClass('error-bg'); yMaxC.addClass('error-bg'); yMinC.removeClass('ok-bg'); @@ -232,8 +230,7 @@ numSamplesC.addClass('ok-bg'); if (!isTrue(RUNTIME.num_is_integer(numSamplesVal)) || - jsnums.lessThanOrEqual(numSamplesVal, 1, - RUNTIME.NumberErrbacks)) { + jsnums.lessThanOrEqual(numSamplesVal, 1)) { numSamplesC.addClass('error-bg'); numSamplesC.removeClass('ok-bg'); return null; diff --git a/src/web/js/trove/d3-lib-list.js b/src/web/js/trove/d3-lib-list.js index 46c88065..d74b1817 100644 --- a/src/web/js/trove/d3-lib-list.js +++ b/src/web/js/trove/d3-lib-list.js @@ -3,13 +3,13 @@ { 'import-type': 'builtin', 'name': 'image-lib' }, ], nativeRequires: [ - 'pyret-base/js/js-numbers', 'd3' ], provides: {}, - theModule: function (RUNTIME, NAMESPACE, uri, IMAGELIB, jsnums, d3) { + theModule: function (RUNTIME, NAMESPACE, uri, IMAGELIB, d3) { 'use strict'; + var jsnums = RUNTIME.jsnums; var IMAGE = RUNTIME.getField(IMAGELIB, "internal"); @@ -34,13 +34,13 @@ * @return {jsnums -> jsnums} */ return function (k) { - var oldDiff = jsnums.subtract(k, oldX, RUNTIME.NumberErrbacks); - var oldRange = jsnums.subtract(oldY, oldX, RUNTIME.NumberErrbacks); - var portion = jsnums.divide(oldDiff, oldRange, RUNTIME.NumberErrbacks); - var newRange = jsnums.subtract(newY, newX, RUNTIME.NumberErrbacks); - var newPortion = jsnums.multiply(portion, newRange, RUNTIME.NumberErrbacks); - var result = jsnums.add(newPortion, newX, RUNTIME.NumberErrbacks); - return toFixnum ? jsnums.toFixnum(result, RUNTIME.NumberErrbacks) : result; + var oldDiff = jsnums.subtract(k, oldX); + var oldRange = jsnums.subtract(oldY, oldX); + var portion = jsnums.divide(oldDiff, oldRange); + var newRange = jsnums.subtract(newY, newX); + var newPortion = jsnums.multiply(portion, newRange); + var result = jsnums.add(newPortion, newX); + return toFixnum ? jsnums.toFixnum(result) : result; }; } @@ -50,13 +50,13 @@ function getPrettyNumToStringDigits(digits) { return function (num) { - return jsnums.toStringDigits(num, digits, RUNTIME.NumberErrbacks).replace(/\.?0*$/, ''); + return jsnums.toStringDigits(num, digits).replace(/\.?0*$/, ''); }; } function between(b, a, c) { - return (jsnums.lessThanOrEqual(a, b, RUNTIME.NumberErrbacks) && jsnums.lessThanOrEqual(b, c, RUNTIME.NumberErrbacks)) || - (jsnums.lessThanOrEqual(c, b, RUNTIME.NumberErrbacks) && jsnums.lessThanOrEqual(b, a, RUNTIME.NumberErrbacks)); + return (jsnums.lessThanOrEqual(a, b) && jsnums.lessThanOrEqual(b, c)) || + (jsnums.lessThanOrEqual(c, b) && jsnums.lessThanOrEqual(b, a)); } function numMin(a, b) { /* ignore the rest */ diff --git a/src/web/js/trove/d3-lib.js b/src/web/js/trove/d3-lib.js index 421bdd09..73b85596 100644 --- a/src/web/js/trove/d3-lib.js +++ b/src/web/js/trove/d3-lib.js @@ -3,13 +3,13 @@ { 'import-type': 'builtin', 'name': 'image-lib' }, ], nativeRequires: [ - 'pyret-base/js/js-numbers', 'd3' ], provides: {}, - theModule: function (RUNTIME, NAMESPACE, uri, IMAGELIB, jsnums, d3) { + theModule: function (RUNTIME, NAMESPACE, uri, IMAGELIB, d3) { 'use strict'; + var jsnums = RUNTIME.jsnums; var IMAGE = RUNTIME.getField(IMAGELIB, "internal"); function assert(val, msg) { @@ -33,13 +33,13 @@ * @return {jsnums -> jsnums} */ return function (k) { - var oldDiff = jsnums.subtract(k, oldX, RUNTIME.NumberErrbacks); - var oldRange = jsnums.subtract(oldY, oldX, RUNTIME.NumberErrbacks); - var portion = jsnums.divide(oldDiff, oldRange, RUNTIME.NumberErrbacks); - var newRange = jsnums.subtract(newY, newX, RUNTIME.NumberErrbacks); - var newPortion = jsnums.multiply(portion, newRange, RUNTIME.NumberErrbacks); - var result = jsnums.add(newPortion, newX, RUNTIME.NumberErrbacks); - return toFixnum ? jsnums.toFixnum(result, RUNTIME.NumberErrbacks) : result; + var oldDiff = jsnums.subtract(k, oldX); + var oldRange = jsnums.subtract(oldY, oldX); + var portion = jsnums.divide(oldDiff, oldRange); + var newRange = jsnums.subtract(newY, newX); + var newPortion = jsnums.multiply(portion, newRange); + var result = jsnums.add(newPortion, newX); + return toFixnum ? jsnums.toFixnum(result) : result; }; } @@ -49,13 +49,13 @@ function getPrettyNumToStringDigits(digits) { return function (num) { - return jsnums.toStringDigits(num, digits, RUNTIME.NumberErrbacks).replace(/\.?0*$/, ''); + return jsnums.toStringDigits(num, digits).replace(/\.?0*$/, ''); }; } function between(b, a, c) { - return (jsnums.lessThanOrEqual(a, b, RUNTIME.NumberErrbacks) && jsnums.lessThanOrEqual(b, c, RUNTIME.NumberErrbacks)) || - (jsnums.lessThanOrEqual(c, b, RUNTIME.NumberErrbacks) && jsnums.lessThanOrEqual(b, a, RUNTIME.NumberErrbacks)); + return (jsnums.lessThanOrEqual(a, b) && jsnums.lessThanOrEqual(b, c)) || + (jsnums.lessThanOrEqual(c, b) && jsnums.lessThanOrEqual(b, a)); } function numMin(a, b) { /* ignore the rest */ diff --git a/src/web/js/trove/plot-lib-list.js b/src/web/js/trove/plot-lib-list.js index c285c11a..05672e0f 100644 --- a/src/web/js/trove/plot-lib-list.js +++ b/src/web/js/trove/plot-lib-list.js @@ -3,7 +3,6 @@ { 'import-type': 'builtin', 'name': 'd3-lib-list' }, ], nativeRequires: [ - 'pyret-base/js/js-numbers', 'd3', 'd3-tip' ], @@ -17,8 +16,9 @@ 'box-chart': "tany" } }, - theModule: function (RUNTIME, NAMESPACE, uri, CLIB, jsnums, d3, D3TIP) { - 'use strict'; + theModule: function (RUNTIME, NAMESPACE, uri, CLIB, d3, D3TIP) { + 'use strict'; + var jsnums = RUNTIME.jsnums; var gf = RUNTIME.getField, cases = RUNTIME.ffi.cases; var libNum = CLIB.libNum, @@ -69,7 +69,7 @@ function getAxisConf(aMin, aMax) { const conf = {}, scaler = libNum.scaler(aMin, aMax, 0, 1, false), - pos = jsnums.toFixnum(scaler(0), RUNTIME.NumberErrbacks); + pos = jsnums.toFixnum(scaler(0)); if (0 <= pos && pos <= 1) { conf.bold = true; @@ -285,7 +285,7 @@ xMaxC.removeClass('error-bg'); xMaxC.addClass('ok-bg'); - if (jsnums.greaterThanOrEqual(xMin_val, xMax_val, RUNTIME.NumberErrbacks)) { + if (jsnums.greaterThanOrEqual(xMin_val, xMax_val)) { xMinC.addClass('error-bg'); xMaxC.addClass('error-bg'); xMinC.removeClass('ok-bg'); @@ -313,7 +313,7 @@ yMaxC.removeClass('error-bg'); yMaxC.addClass('ok-bg'); - if (jsnums.greaterThanOrEqual(xMin_val, xMax_val, RUNTIME.NumberErrbacks)) { + if (jsnums.greaterThanOrEqual(xMin_val, xMax_val)) { yMinC.addClass('error-bg'); yMaxC.addClass('error-bg'); yMinC.removeClass('ok-bg'); @@ -332,7 +332,7 @@ numSamplesC.addClass('ok-bg'); if (RUNTIME.isPyretFalse(RUNTIME.num_is_integer(numSamples_val)) || - jsnums.lessThanOrEqual(numSamples_val, 1, RUNTIME.NumberErrbacks)) { + jsnums.lessThanOrEqual(numSamples_val, 1)) { numSamplesC.addClass('error-bg'); numSamplesC.removeClass('ok-bg'); return null; @@ -372,9 +372,9 @@ if (newWindow === null) { return; } var xMin_val = newWindow['_x-min']; var xMax_val = newWindow['_x-max']; - var move = jsnums.divide(jsnums.subtract(xMax_val, xMin_val, RUNTIME.NumberErrbacks), 10, RUNTIME.NumberErrbacks); - xMinC.val(prettyNumToStringDigits20(jsnums.subtract(xMin_val, move, RUNTIME.NumberErrbacks))); - xMaxC.val(prettyNumToStringDigits20(jsnums.subtract(xMax_val, move, RUNTIME.NumberErrbacks))); + var move = jsnums.divide(jsnums.subtract(xMax_val, xMin_val), 10); + xMinC.val(prettyNumToStringDigits20(jsnums.subtract(xMin_val, move))); + xMaxC.val(prettyNumToStringDigits20(jsnums.subtract(xMax_val, move))); })); controller.append($('