import { join } from "path";
import ms from "ms";
import { isEqual } from "lodash";
import { Router, json } from "express";
import {
analytics_cookie_name,
is_valid_uuid_string,
uuid,
} from "@cocalc/util/misc";
import type { PostgreSQL } from "@cocalc/database/postgres/types";
import { get_server_settings } from "@cocalc/database/postgres/server-settings";
import { pii_retention_to_future } from "@cocalc/database/postgres/pii";
import * as fs from "fs";
const UglifyJS = require("uglify-js");
import cors from "cors";
import {
parseDomain,
fromUrl,
ParseResultType,
ParseResult,
} from "parse-domain";
import { getLogger } from "./logger";
const result = UglifyJS.minify(
fs.readFileSync(join(__dirname, "analytics-script.js")).toString()
);
if (result.error) {
throw Error(`Error minifying analytics-script.js -- ${result.error}`);
}
export const analytics_js =
"if (window.exports === undefined) { var exports={}; } \n" + result.code;
function create_log(name) {
return getLogger(`analytics.${name}`).debug;
}
function sanitize(obj: object, recursive = 0): any {
if (recursive >= 2) return { error: "recursion limit" };
const ret: any = {};
let cnt = 0;
for (const key of Object.keys(obj)) {
cnt += 1;
if (cnt > 20) break;
const key_san = key.slice(0, 50);
let val_san = obj[key];
if (val_san == null) continue;
if (typeof val_san === "object") {
val_san = sanitize(val_san, recursive + 1);
} else if (typeof val_san === "string") {
val_san = val_san.slice(0, 2000);
} else {
}
ret[key_san] = val_san;
}
return ret;
}
function recordAnalyticsData(
db: any,
token: string,
payload: object | undefined,
pii_retention: number | false
): void {
if (payload == null) return;
if (!is_valid_uuid_string(token)) return;
const dbg = create_log("record");
dbg({ token, payload });
const rec_data = sanitize(payload);
dbg("sanitized data", rec_data);
const expire = pii_retention_to_future(pii_retention);
if (rec_data.account_id != null) {
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",
});
}
}
function check_cors(
origin: string | undefined,
dns_parsed: ParseResult,
dbg: Function
): boolean {
if (origin == null) return true;
const origin_parsed = parseDomain(fromUrl(origin));
if (origin_parsed.type === ParseResultType.Reserved) {
return true;
}
if (dns_parsed.type !== ParseResultType.Listed) {
dbg(`parsed DNS domain invalid: ${JSON.stringify(dns_parsed)}`);
return false;
}
if (origin_parsed.type === ParseResultType.Listed) {
if (
isEqual(origin_parsed.topLevelDomains, dns_parsed.topLevelDomains) &&
origin_parsed.domain === dns_parsed.domain
) {
return true;
}
if (isEqual(origin_parsed.topLevelDomains, ["com"])) {
if (
origin_parsed.domain === "cocalc" ||
origin_parsed.domain === "sagemath"
) {
return true;
}
}
if (
isEqual(origin_parsed.topLevelDomains, ["org"]) &&
origin_parsed.domain === "sagemath"
) {
return true;
}
}
return false;
}
import base_path from "@cocalc/backend/base-path";
export async function initAnalytics(
router: Router,
database: PostgreSQL
): Promise<void> {
const dbg = create_log("analytics_js/cors");
const settings = await get_server_settings();
const DNS = settings.dns;
const dns_parsed = parseDomain(DNS);
const pii_retention = settings.pii_retention;
if (
dns_parsed.type !== ParseResultType.Listed &&
dns_parsed.type !== ParseResultType.Reserved
) {
dbg(
`WARNING: the configured domain name ${DNS} cannot be parsed properly. ` +
`Please fix it in Admin → Site Settings!\n` +
`dns_parsed="${JSON.stringify(dns_parsed)}}"`
);
}
const 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.use("/analytics.js", json());
router.get("/analytics.js", cors(analytics_cors), function (req, res) {
res.header("Content-Type", "text/javascript");
dbg(
`/analytics.js GET analytics_cookie='${req.cookies[analytics_cookie_name]}'`
);
if (!req.cookies[analytics_cookie_name]) {
setAnalyticsCookie(res );
}
if (
req.cookies[analytics_cookie_name] ||
dns_parsed.type !== ParseResultType.Listed
) {
res.header(
"Cache-Control",
`private, max-age=${6 * 60 * 60}, must-revalidate`
);
res.write("// NOOP");
res.end();
return;
}
res.header("Cache-Control", "no-cache, no-store");
const DOMAIN = `${dns_parsed.domain}.${dns_parsed.topLevelDomains.join(
"."
)}`;
res.write(`var NAME = '${analytics_cookie_name}';\n`);
res.write(`var ID = '${uuid()}';\n`);
res.write(`var DOMAIN = '${DOMAIN}';\n`);
if (req.query.fqd === "false") {
res.write(`var PREFIX = '${base_path}';\n`);
} else {
const prefix = `//${DOMAIN}${base_path}`;
res.write(`var PREFIX = '${prefix}';\n\n`);
}
res.write(analytics_js);
return res.end();
});
router.post("/analytics.js", cors(analytics_cors), function (req, res): void {
const token = req.cookies[analytics_cookie_name];
dbg(`/analytics.js POST token='${token}'`);
if (token) {
recordAnalyticsData(database, token, req.body, pii_retention);
}
res.end();
});
router.options("/analytics.js", cors(analytics_cors));
}
function setAnalyticsCookie(res ): void {
const analytics_token = uuid();
res.cookie(analytics_cookie_name, analytics_token, {
path: "/",
maxAge: ms("7 days"),
});
}