smc-hub
Version:
CoCalc: Backend webserver component
342 lines (339 loc) • 16.4 kB
JavaScript
;
/*
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
* License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (_) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
var __values = (this && this.__values) || function(o) {
var s = typeof Symbol === "function" && Symbol.iterator, m = s && o[s], i = 0;
if (m) return m.call(o);
if (o && typeof o.length === "number") return {
next: function () {
if (o && i >= o.length) o = void 0;
return { value: o && o[i++], done: !o };
}
};
throw new TypeError(s ? "Object is not iterable." : "Symbol.iterator is not defined.");
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.initAnalytics = exports.analytics_js = void 0;
var path_1 = require("path");
var ms_1 = __importDefault(require("ms"));
var lodash_1 = require("lodash");
var misc_1 = require("smc-util/misc");
var utils_1 = require("./utils");
var fs = __importStar(require("fs"));
var UglifyJS = require("uglify-js");
// express-js cors plugin:
var cors_1 = __importDefault(require("cors"));
var parse_domain_1 = require("parse-domain");
var logger_1 = require("./logger");
// Minifying analytics-script.js. Note
// that this file analytics.ts gets compiled to
// dist/analytics.js and also analytics-script.ts
// gets compiled to dist/analytics-script.js.
var result = UglifyJS.minify(fs.readFileSync(path_1.join(__dirname, "analytics-script.js")).toString());
if (result.error) {
throw Error("Error minifying analytics-script.js -- " + result.error);
}
exports.analytics_js = "if (window.exports === undefined) { var exports={}; } \n" + result.code;
function create_log(name) {
return logger_1.getLogger("analytics." + name).debug;
}
// base64 encoded PNG (white), 1x1 pixels
var _PNG_DATA = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=";
var PNG_1x1 = Buffer.from(_PNG_DATA, "base64");
function sanitize(obj) {
var e_1, _a;
var ret = {};
var cnt = 0;
try {
for (var _b = __values(Object.keys(obj)), _c = _b.next(); !_c.done; _c = _b.next()) {
var key = _c.value;
cnt += 1;
if (cnt > 20)
break;
var key_san = key.slice(0, 50);
var val_san = obj[key];
if (val_san == null)
continue;
if (typeof val_san === "object") {
val_san = sanitize(val_san);
}
else if (typeof val_san === "string") {
val_san = val_san.slice(0, 2000);
}
else {
// do nothing
}
ret[key_san] = val_san;
}
}
catch (e_1_1) { e_1 = { error: e_1_1 }; }
finally {
try {
if (_c && !_c.done && (_a = _b.return)) _a.call(_b);
}
finally { if (e_1) throw e_1.error; }
}
return ret;
}
// record analytics data
// case 1: store "token" with associated "data", referrer, utm, etc.
// case 2: update entry with a known "token" with the account_id + 2nd timestamp
function recordAnalyticsData(db, token, payload, pii_retention) {
if (payload == null)
return;
if (!misc_1.is_valid_uuid_string(token))
return;
var dbg = create_log("rec");
dbg(token, payload);
// sanitize data (limits size and number of characters)
var rec_data = sanitize(payload);
dbg("rec_data", rec_data);
var expire = utils_1.pii_retention_to_future(pii_retention);
if (rec_data.account_id != null) {
// dbg("update analytics", rec_data.account_id);
// only update if account id isn't already set!
db._query({
query: "UPDATE analytics",
where: [{ "token = $::UUID": token }, "account_id IS NULL"],
set: {
"account_id :: UUID": rec_data.account_id,
"account_id_time :: TIMESTAMP": new Date(),
"expire :: TIMESTAMP": expire,
},
});
}
else {
db._query({
query: "INSERT INTO analytics",
values: {
"token :: UUID": token,
"data :: JSONB": rec_data,
"data_time :: TIMESTAMP": new Date(),
"expire :: TIMESTAMP": expire,
},
conflict: "token",
});
}
}
// could throw an error
function check_cors(origin, dns_parsed, dbg) {
// no origin, e.g. when loaded as usual in a script tag
if (origin == null)
return true;
// origin could be https://...
var origin_parsed = parse_domain_1.parseDomain(parse_domain_1.fromUrl(origin));
if (origin_parsed.type === parse_domain_1.ParseResultType.Reserved) {
// This happens, e.g., when origin is https://localhost, which happens with cocalc-docker.
return true;
}
// the configured DNS name is not ok
if (dns_parsed.type !== parse_domain_1.ParseResultType.Listed) {
dbg("parsed DNS domain invalid: " + JSON.stringify(dns_parsed));
return false;
}
// now, we want dns_parsed and origin_parsed to be valid and listed
if (origin_parsed.type === parse_domain_1.ParseResultType.Listed) {
if (lodash_1.isEqual(origin_parsed.topLevelDomains, dns_parsed.topLevelDomains) &&
origin_parsed.domain === dns_parsed.domain) {
return true;
}
if (lodash_1.isEqual(origin_parsed.topLevelDomains, ["com"])) {
if (origin_parsed.domain === "cocalc" ||
origin_parsed.domain === "sagemath") {
return true;
}
}
if (lodash_1.isEqual(origin_parsed.topLevelDomains, ["org"]) &&
origin_parsed.domain === "sagemath") {
return true;
}
}
return false;
}
/*
cocalc analytics setup -- this is used in http_hub_server to setup the /analytics.js endpoint
this extracts tracking information about landing pages, measure campaign performance, etc.
1. it sends a static js file (which is included in a script tag) to a page
2. a unique ID is generated and stored in a cookie
3. the script (should) send back a POST request, telling us about
the UTM params, referral, landing page, etc.
The query param "fqd" (fully qualified domain) can be set to true or false (default true)
It controls if the bounce back URL mentions the domain.
*/
var base_path_1 = __importDefault(require("smc-util-node/base-path"));
function initAnalytics(router, database) {
return __awaiter(this, void 0, void 0, function () {
var dbg, settings, DNS, dns_parsed, pii_retention, analytics_cors;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
dbg = create_log("analytics_js/cors");
return [4 /*yield*/, utils_1.get_server_settings(database)];
case 1:
settings = _a.sent();
DNS = settings.dns;
dns_parsed = parse_domain_1.parseDomain(DNS);
pii_retention = settings.pii_retention;
if (dns_parsed.type !== parse_domain_1.ParseResultType.Listed &&
dns_parsed.type !== parse_domain_1.ParseResultType.Reserved) {
dbg("WARNING: the configured domain name " + DNS + " cannot be parsed properly. " +
"Please fix it in Admin \u2192 Site Settings!\n" +
("dns_parsed=\"" + JSON.stringify(dns_parsed) + "}\""));
}
analytics_cors = {
credentials: true,
methods: ["GET", "POST"],
allowedHeaders: ["Content-Type", "*"],
origin: function (origin, cb) {
dbg("check origin='" + origin + "'");
try {
if (check_cors(origin, dns_parsed, dbg)) {
cb(null, true);
}
else {
cb("origin=\"" + origin + "\" is not allowed", false);
}
}
catch (e) {
cb(e);
return;
}
},
};
router.get("/analytics.js", cors_1.default(analytics_cors), function (req, res) {
res.header("Content-Type", "text/javascript");
// in case user was already here, do not send it again.
// only the first hit is interesting.
dbg("/analytics.js GET analytics_cookie='" + req.cookies[misc_1.analytics_cookie_name] + "'");
// also, don't write a script if the DNS is not valid
if (req.cookies[misc_1.analytics_cookie_name] ||
dns_parsed.type !== parse_domain_1.ParseResultType.Listed) {
// cache for 6 hours
res.header("Cache-Control", "private, max-age=" + 6 * 60 * 60);
res.write("// NOOP");
res.end();
return;
}
// write response script
// this only runs once, hence no caching
res.header("Cache-Control", "private, no-cache, no-store, must-revalidate");
//analytics_cookie(DNS, res)
var DOMAIN = dns_parsed.domain + "." + dns_parsed.topLevelDomains.join(".");
res.write("var NAME = '" + misc_1.analytics_cookie_name + "';\n");
res.write("var ID = '" + misc_1.uuid() + "';\n");
res.write("var DOMAIN = '" + DOMAIN + "';\n");
// BASE_PATH
if (req.query.fqd === "false") {
res.write("var PREFIX = '" + base_path_1.default + "';\n");
}
else {
var prefix = "//" + DOMAIN + base_path_1.default;
res.write("var PREFIX = '" + prefix + "';\n\n");
}
res.write(exports.analytics_js);
return res.end();
});
// tracking image: this is a 100% experimental idea and not used
router.get("/analytics.js/track.png", cors_1.default(analytics_cors), function (req, res) {
// in case user was already here, do not set a cookie
if (!req.cookies[misc_1.analytics_cookie_name]) {
analytics_cookie(DNS, res);
}
res.header("Content-Type", "image/png");
res.header("Content-Length", "" + PNG_1x1.length);
return res.end(PNG_1x1);
});
router.post("/analytics.js", cors_1.default(analytics_cors), function (req, res) {
// check if token is in the cookie (see above)
// if not, ignore it
var token = req.cookies[misc_1.analytics_cookie_name];
dbg("/analytics.js POST token='" + token + "'");
if (token) {
// req.body is an object (json middlewhere somewhere?)
// e.g. {"utm":{"source":"asdfasdf"},"landing":"https://cocalc.com/..."}
// ATTN key/values could be malicious
dbg("/analytics.js -- TOKEN=" + token + " -- DATA=" + JSON.stringify(req.body));
// record it, there is no need for a callback
recordAnalyticsData(database, token, req.body, pii_retention);
}
res.end();
});
// additionally, custom content types require a preflight cors check
router.options("/analytics.js", cors_1.default(analytics_cors));
return [2 /*return*/];
}
});
});
}
exports.initAnalytics = initAnalytics;
function analytics_cookie(DNS, res) {
// set the cookie (TODO sign it?)
var analytics_token = misc_1.uuid();
res.cookie(misc_1.analytics_cookie_name, analytics_token, {
path: "/",
maxAge: ms_1.default("7 days"),
// httpOnly: true,
domain: DNS,
});
}
//# sourceMappingURL=analytics.js.map